diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d391cac16546..490ec5a0d826 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -810,6 +810,13 @@ "includeHome": { "description": "If true, include detection under the user's home directory.", "type": "boolean" + }, + "source": { + "description": "Source product to detect. Missing or unrecognized values use Claude Code for backwards compatibility; `cursor` selects Cursor.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -823,7 +830,7 @@ "type": "array" }, "source": { - "description": "Source product that produced the migration items. Missing means unspecified.", + "description": "Source product that produced the migration items. Use `cursor` for Cursor items; missing or unrecognized values use Claude Code for backwards compatibility.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b7526b080da1..3956104bfd3f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9237,6 +9237,13 @@ "includeHome": { "description": "If true, include detection under the user's home directory.", "type": "boolean" + }, + "source": { + "description": "Source product to detect. Missing or unrecognized values use Claude Code for backwards compatibility; `cursor` selects Cursor.", + "type": [ + "string", + "null" + ] } }, "title": "ExternalAgentConfigDetectParams", @@ -9400,7 +9407,7 @@ "type": "array" }, "source": { - "description": "Source product that produced the migration items. Missing means unspecified.", + "description": "Source product that produced the migration items. Use `cursor` for Cursor items; missing or unrecognized values use Claude Code for backwards compatibility.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e1afc7883a51..a7a738cab4ba 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5477,6 +5477,13 @@ "includeHome": { "description": "If true, include detection under the user's home directory.", "type": "boolean" + }, + "source": { + "description": "Source product to detect. Missing or unrecognized values use Claude Code for backwards compatibility; `cursor` selects Cursor.", + "type": [ + "string", + "null" + ] } }, "title": "ExternalAgentConfigDetectParams", @@ -5640,7 +5647,7 @@ "type": "array" }, "source": { - "description": "Source product that produced the migration items. Missing means unspecified.", + "description": "Source product that produced the migration items. Use `cursor` for Cursor items; missing or unrecognized values use Claude Code for backwards compatibility.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json index 01fda3b07139..0ae43e9515d0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json @@ -14,6 +14,13 @@ "includeHome": { "description": "If true, include detection under the user's home directory.", "type": "boolean" + }, + "source": { + "description": "Source product to detect. Missing or unrecognized values use Claude Code for backwards compatibility; `cursor` selects Cursor.", + "type": [ + "string", + "null" + ] } }, "title": "ExternalAgentConfigDetectParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json index d02241f95b28..8bdc470bcf08 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json @@ -204,7 +204,7 @@ "type": "array" }, "source": { - "description": "Source product that produced the migration items. Missing means unspecified.", + "description": "Source product that produced the migration items. Use `cursor` for Cursor items; missing or unrecognized values use Claude Code for backwards compatibility.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts index 48b55e9b9ac3..45c8a7c3160a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts @@ -10,4 +10,9 @@ includeHome?: boolean, /** * Zero or more working directories to include for repo-scoped detection. */ -cwds?: Array | null, }; +cwds?: Array | null, +/** + * Source product to detect. Missing or unrecognized values use Claude Code for backwards + * compatibility; `cursor` selects Cursor. + */ +source?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts index be7f7ffe3018..2f69deb23e4b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts @@ -5,6 +5,7 @@ import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigr export type ExternalAgentConfigImportParams = { migrationItems: Array, /** - * Source product that produced the migration items. Missing means unspecified. + * Source product that produced the migration items. Use `cursor` for Cursor items; missing or + * unrecognized values use Claude Code for backwards compatibility. */ source?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 24d574a935d6..519bf458a82c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -684,6 +684,10 @@ pub struct ExternalAgentConfigDetectParams { /// Zero or more working directories to include for repo-scoped detection. #[ts(optional = nullable)] pub cwds: Option>, + /// Source product to detect. Missing or unrecognized values use Claude Code for backwards + /// compatibility; `cursor` selects Cursor. + #[ts(optional = nullable)] + pub source: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -691,7 +695,8 @@ pub struct ExternalAgentConfigDetectParams { #[ts(export_to = "v2/")] pub struct ExternalAgentConfigImportParams { pub migration_items: Vec, - /// Source product that produced the migration items. Missing means unspecified. + /// Source product that produced the migration items. Use `cursor` for Cursor items; missing or + /// unrecognized values use Claude Code for backwards compatibility. #[ts(optional = nullable)] pub source: Option, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6ee6baf2dc6c..e2c50b9adfa9 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -239,8 +239,8 @@ Example with notification opt-out: - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. -- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names. -- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately. +- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome`, optional `cwds`, and optional `source`. Omitted or unrecognized `source` values preserve the existing Claude Code behavior; `source: "cursor"` detects portable Cursor rules, skills, commands, subagents, and MCP configuration. Each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names. +- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Pass the same `source` used for detection so the importer reads the correct product paths; omitted, `null`, or unrecognized values use Claude Code for backwards compatibility. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately. - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index ca1023e52918..16383e14a6c2 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -1,3 +1,5 @@ +mod cursor; + use codex_config::types::PluginConfig; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -8,11 +10,14 @@ use codex_core_plugins::marketplace::find_marketplace_manifest_path; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; use codex_core_plugins::marketplace_add::add_marketplace; use codex_core_plugins::marketplace_add::is_local_marketplace_source; +use codex_external_agent_migration::build_mcp_config_from_cursor; use codex_external_agent_migration::build_mcp_config_from_external; use codex_external_agent_migration::count_missing_commands; use codex_external_agent_migration::count_missing_subagents; use codex_external_agent_migration::hook_migration_event_names; use codex_external_agent_migration::import_commands; +use codex_external_agent_migration::import_cursor_commands; +use codex_external_agent_migration::import_cursor_subagents; use codex_external_agent_migration::import_hooks; use codex_external_agent_migration::import_subagents; use codex_external_agent_migration::missing_command_names; @@ -36,9 +41,35 @@ const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.d const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import"; const EXTERNAL_AGENT_DIR: &str = ".claude"; const EXTERNAL_AGENT_CONFIG_MD: &str = "CLAUDE.md"; +const CURSOR_AGENT_DIR: &str = ".cursor"; +const CURSOR_LEGACY_RULES_FILE: &str = ".cursorrules"; const EXTERNAL_OFFICIAL_MARKETPLACE_NAME: &str = "claude-plugins-official"; const EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/claude-plugins-official"; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum ExternalAgentSource { + #[default] + ClaudeCode, + Cursor, +} + +impl ExternalAgentSource { + fn from_api_source(source: Option<&str>) -> Self { + if source.is_some_and(|source| source.eq_ignore_ascii_case("cursor")) { + Self::Cursor + } else { + Self::ClaudeCode + } + } + + fn config_dir(self) -> &'static str { + match self { + Self::ClaudeCode => EXTERNAL_AGENT_DIR, + Self::Cursor => CURSOR_AGENT_DIR, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ExternalAgentConfigDetectOptions { pub include_home: bool, @@ -176,22 +207,36 @@ pub(crate) struct ExternalAgentConfigMigrationItem { pub(crate) struct ExternalAgentConfigService { codex_home: PathBuf, external_agent_home: PathBuf, + source: ExternalAgentSource, } impl ExternalAgentConfigService { pub(crate) fn new(codex_home: PathBuf) -> Self { - let external_agent_home = default_external_agent_home(); + Self::new_for_source(codex_home, ExternalAgentSource::ClaudeCode) + } + + fn new_for_source(codex_home: PathBuf, source: ExternalAgentSource) -> Self { + let external_agent_home = default_external_agent_home(source); Self { codex_home, external_agent_home, + source, } } + pub(crate) fn with_api_source(&self, source: Option<&str>) -> Self { + Self::new_for_source( + self.codex_home.clone(), + ExternalAgentSource::from_api_source(source), + ) + } + #[cfg(test)] fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self { Self { codex_home, external_agent_home, + source: ExternalAgentSource::ClaudeCode, } } @@ -219,6 +264,9 @@ impl ExternalAgentConfigService { &self, path: &Path, ) -> io::Result> { + if self.source != ExternalAgentSource::ClaudeCode { + return Ok(None); + } if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { return Ok(None); } @@ -444,11 +492,12 @@ impl ExternalAgentConfigService { items: &mut Vec, ) -> io::Result<()> { let cwd = repo_root.map(Path::to_path_buf); - let source_settings = repo_root.map_or_else( - || self.external_agent_home.join("settings.json"), - |repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), - ); - let settings = effective_external_settings(&source_settings)?; + let source_settings = self.source_settings(repo_root); + let settings = if self.source == ExternalAgentSource::ClaudeCode { + effective_external_settings(&source_settings)? + } else { + None + }; let target_config = repo_root.map_or_else( || self.codex_home.join("config.toml"), |repo_root| repo_root.join(".codex").join("config.toml"), @@ -489,13 +538,7 @@ impl ExternalAgentConfigService { } } - let source_root = self.source_root(repo_root); - let mcp_settings = self.mcp_settings(repo_root, settings.clone())?; - let migrated_mcp = build_mcp_config_from_external( - source_root.as_path(), - Some(self.external_agent_home.as_path()), - mcp_settings.as_ref(), - )?; + let migrated_mcp = self.build_mcp_config(repo_root, settings.clone())?; let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp); if !is_empty_toml_table(&migrated_mcp) { if target_config.exists() { @@ -515,7 +558,7 @@ impl ExternalAgentConfigService { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: format!( "Migrate MCP servers from {} into {}", - source_root.display(), + self.mcp_source_path(repo_root).display(), target_config.display() ), cwd: cwd.clone(), @@ -532,16 +575,16 @@ impl ExternalAgentConfigService { } } - let source_external_agent_dir = repo_root.map_or_else( - || self.external_agent_home.clone(), - |repo_root| repo_root.join(EXTERNAL_AGENT_DIR), - ); + let source_external_agent_dir = self.source_config_dir(repo_root); let target_hooks = repo_root.map_or_else( || self.codex_home.join("hooks.json"), |repo_root| repo_root.join(".codex").join("hooks.json"), ); - let hook_event_names = - hook_migration_event_names(source_external_agent_dir.as_path(), &target_hooks)?; + let hook_event_names = if self.source == ExternalAgentSource::ClaudeCode { + hook_migration_event_names(source_external_agent_dir.as_path(), &target_hooks)? + } else { + Vec::new() + }; if !hook_event_names.is_empty() && is_missing_or_empty_text_file(&target_hooks)? { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Hooks, @@ -565,7 +608,7 @@ impl ExternalAgentConfigService { let source_skills = repo_root.map_or_else( || self.external_agent_home.join("skills"), - |repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("skills"), + |repo_root| repo_root.join(self.source.config_dir()).join("skills"), ); let target_skills = repo_root.map_or_else( || self.home_target_skills_dir(), @@ -651,23 +694,20 @@ impl ExternalAgentConfigService { } let source_agents_md = if let Some(repo_root) = repo_root { - find_repo_agents_md_source(repo_root)? + self.repo_agents_md_sources(repo_root)? } else { - let path = self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD); - is_non_empty_text_file(&path)?.then_some(path) + self.home_agents_md_sources()? }; let target_agents_md = repo_root.map_or_else( || self.codex_home.join("AGENTS.md"), |repo_root| repo_root.join("AGENTS.md"), ); - if let Some(source_agents_md) = source_agents_md - && is_missing_or_empty_text_file(&target_agents_md)? - { + if !source_agents_md.is_empty() && is_missing_or_empty_text_file(&target_agents_md)? { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( "Migrate {} to {}", - source_agents_md.display(), + display_source_paths(&source_agents_md), target_agents_md.display() ), cwd: cwd.clone(), @@ -680,7 +720,9 @@ impl ExternalAgentConfigService { ); } - if let Some(settings) = settings.as_ref() { + if self.source == ExternalAgentSource::ClaudeCode + && let Some(settings) = settings.as_ref() + { match ConfigBuilder::default() .codex_home(self.codex_home.clone()) .fallback_cwd(Some(self.codex_home.clone())) @@ -728,7 +770,7 @@ impl ExternalAgentConfigService { } } - if repo_root.is_none() { + if repo_root.is_none() && self.source == ExternalAgentSource::ClaudeCode { let sessions = detect_recent_sessions(&self.external_agent_home, &self.codex_home)?; if !sessions.is_empty() { items.push(ExternalAgentConfigMigrationItem { @@ -761,6 +803,61 @@ impl ExternalAgentConfigService { .unwrap_or_else(|| PathBuf::from(".agents").join("skills")) } + fn source_config_dir(&self, repo_root: Option<&Path>) -> PathBuf { + repo_root.map_or_else( + || self.external_agent_home.clone(), + |repo_root| repo_root.join(self.source.config_dir()), + ) + } + + fn source_settings(&self, repo_root: Option<&Path>) -> PathBuf { + self.source_config_dir(repo_root).join("settings.json") + } + + fn mcp_source_path(&self, repo_root: Option<&Path>) -> PathBuf { + match self.source { + ExternalAgentSource::ClaudeCode => self.source_root(repo_root), + ExternalAgentSource::Cursor => self.source_config_dir(repo_root).join("mcp.json"), + } + } + + fn build_mcp_config( + &self, + repo_root: Option<&Path>, + settings: Option, + ) -> io::Result { + match self.source { + ExternalAgentSource::ClaudeCode => build_mcp_config_from_external( + self.source_root(repo_root).as_path(), + Some(self.external_agent_home.as_path()), + settings.as_ref(), + ), + ExternalAgentSource::Cursor => { + build_mcp_config_from_cursor(self.source_config_dir(repo_root).as_path()) + } + } + } + + fn repo_agents_md_sources(&self, repo_root: &Path) -> io::Result> { + match self.source { + ExternalAgentSource::ClaudeCode => { + Ok(find_repo_agents_md_source(repo_root)?.into_iter().collect()) + } + ExternalAgentSource::Cursor => cursor::rule_sources(repo_root), + } + } + + fn home_agents_md_sources(&self) -> io::Result> { + if self.source != ExternalAgentSource::ClaudeCode { + return Ok(Vec::new()); + } + let path = self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD); + Ok(is_non_empty_text_file(&path)? + .then_some(path) + .into_iter() + .collect()) + } + fn mcp_settings( &self, repo_root: Option<&Path>, @@ -830,10 +927,7 @@ impl ExternalAgentConfigService { cwd: Option<&Path>, details: MigrationDetails, ) -> io::Result<(Option, Option)> { - let source_settings = cwd.map_or_else( - || self.external_agent_home.join("settings.json"), - |cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"), - ); + let source_settings = self.source_settings(cwd); let source_root = cwd.unwrap_or(self.external_agent_home.as_path()); let import_sources = effective_external_settings(&source_settings)? .map(|settings| collect_marketplace_import_sources(&settings, source_root)) @@ -901,10 +995,7 @@ impl ExternalAgentConfigService { .iter() .map(|plugin_name| format!("{plugin_name}@{marketplace_name}")) .collect::>(); - let source_settings = cwd.map_or_else( - || self.external_agent_home.join("settings.json"), - |cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"), - ); + let source_settings = self.source_settings(cwd); let source_root = cwd.unwrap_or(self.external_agent_home.as_path()); let import_source = effective_external_settings(&source_settings)?.and_then(|settings| { @@ -1024,6 +1115,9 @@ impl ExternalAgentConfigService { } fn import_config(&self, cwd: Option<&Path>) -> io::Result> { + if self.source != ExternalAgentSource::ClaudeCode { + return Ok(None); + } let repo_root = find_repo_root(cwd)?; let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() { ( @@ -1082,7 +1176,7 @@ impl ExternalAgentConfigService { let repo_root = find_repo_root(cwd)?; let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() { ( - repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + self.source_settings(Some(repo_root)), repo_root.join(".codex").join("config.toml"), ) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { @@ -1093,15 +1187,15 @@ impl ExternalAgentConfigService { self.codex_home.join("config.toml"), ) }; - let settings = self.mcp_settings( - repo_root.as_deref(), - effective_external_settings(&source_settings)?, - )?; - let migrated = build_mcp_config_from_external( - self.source_root(repo_root.as_deref()).as_path(), - Some(self.external_agent_home.as_path()), - settings.as_ref(), - )?; + let settings = if self.source == ExternalAgentSource::ClaudeCode { + self.mcp_settings( + repo_root.as_deref(), + effective_external_settings(&source_settings)?, + )? + } else { + None + }; + let migrated = self.build_mcp_config(repo_root.as_deref(), settings)?; if is_empty_toml_table(&migrated) { return Ok(Vec::new()); } @@ -1133,7 +1227,7 @@ impl ExternalAgentConfigService { fn import_subagents(&self, cwd: Option<&Path>) -> io::Result> { let (source_agents, target_agents) = if let Some(repo_root) = find_repo_root(cwd)? { ( - repo_root.join(EXTERNAL_AGENT_DIR).join("agents"), + repo_root.join(self.source.config_dir()).join("agents"), repo_root.join(".codex").join("agents"), ) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { @@ -1145,14 +1239,20 @@ impl ExternalAgentConfigService { ) }; - import_subagents(&source_agents, &target_agents) + match self.source { + ExternalAgentSource::ClaudeCode => import_subagents(&source_agents, &target_agents), + ExternalAgentSource::Cursor => import_cursor_subagents(&source_agents, &target_agents), + } } fn import_hooks(&self, cwd: Option<&Path>) -> io::Result> { + if self.source != ExternalAgentSource::ClaudeCode { + return Ok(Vec::new()); + } let (source_external_agent_dir, target_hooks) = if let Some(repo_root) = find_repo_root(cwd)? { ( - repo_root.join(EXTERNAL_AGENT_DIR), + repo_root.join(self.source.config_dir()), repo_root.join(".codex").join("hooks.json"), ) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { @@ -1175,7 +1275,7 @@ impl ExternalAgentConfigService { fn import_commands(&self, cwd: Option<&Path>) -> io::Result> { let (source_commands, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? { ( - repo_root.join(EXTERNAL_AGENT_DIR).join("commands"), + repo_root.join(self.source.config_dir()).join("commands"), repo_root.join(".agents").join("skills"), ) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { @@ -1187,13 +1287,16 @@ impl ExternalAgentConfigService { ) }; - import_commands(&source_commands, &target_skills) + match self.source { + ExternalAgentSource::ClaudeCode => import_commands(&source_commands, &target_skills), + ExternalAgentSource::Cursor => import_cursor_commands(&source_commands, &target_skills), + } } fn import_skills(&self, cwd: Option<&Path>) -> io::Result> { let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? { ( - repo_root.join(EXTERNAL_AGENT_DIR).join("skills"), + repo_root.join(self.source.config_dir()).join("skills"), repo_root.join(".agents").join("skills"), ) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { @@ -1223,7 +1326,7 @@ impl ExternalAgentConfigService { continue; } - copy_dir_recursive(&entry.path(), &target)?; + copy_dir_recursive(&entry.path(), &target, self.source)?; copied_names.push(entry.file_name().to_string_lossy().to_string()); } @@ -1232,21 +1335,21 @@ impl ExternalAgentConfigService { fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result> { let (source_agents_md, target_agents_md) = if let Some(repo_root) = find_repo_root(cwd)? { - let Some(source_agents_md) = find_repo_agents_md_source(&repo_root)? else { + let source_agents_md = self.repo_agents_md_sources(&repo_root)?; + if source_agents_md.is_empty() { return Ok(None); - }; + } (source_agents_md, repo_root.join("AGENTS.md")) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { return Ok(None); } else { - ( - self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD), - self.codex_home.join("AGENTS.md"), - ) + let source_agents_md = self.home_agents_md_sources()?; + if source_agents_md.is_empty() { + return Ok(None); + } + (source_agents_md, self.codex_home.join("AGENTS.md")) }; - if !is_non_empty_text_file(&source_agents_md)? - || !is_missing_or_empty_text_file(&target_agents_md)? - { + if !is_missing_or_empty_text_file(&target_agents_md)? { return Ok(None); } @@ -1255,20 +1358,25 @@ impl ExternalAgentConfigService { }; fs::create_dir_all(target_parent)?; - rewrite_and_copy_text_file(&source_agents_md, &target_agents_md)?; + let source_contents = source_agents_md + .iter() + .map(|source| read_agents_md_source(source, self.source)) + .collect::>>()? + .join("\n\n"); + fs::write(&target_agents_md, source_contents)?; Ok(Some(( - source_agents_md.display().to_string(), + display_source_paths(&source_agents_md), target_agents_md.display().to_string(), ))) } } -fn default_external_agent_home() -> PathBuf { +fn default_external_agent_home(source: ExternalAgentSource) -> PathBuf { if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { - return PathBuf::from(home).join(EXTERNAL_AGENT_DIR); + return PathBuf::from(home).join(source.config_dir()); } - PathBuf::from(EXTERNAL_AGENT_DIR) + PathBuf::from(source.config_dir()) } fn read_external_settings(path: &Path) -> io::Result> { @@ -1620,7 +1728,31 @@ fn find_repo_agents_md_source(repo_root: &Path) -> io::Result> { Ok(None) } -fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> { +fn read_agents_md_source(path: &Path, source: ExternalAgentSource) -> io::Result { + let contents = fs::read_to_string(path)?; + let contents = if source == ExternalAgentSource::Cursor + && path.extension().and_then(|extension| extension.to_str()) == Some("mdc") + { + cursor::strip_markdown_frontmatter(&contents) + } else { + &contents + }; + Ok(rewrite_external_agent_terms(contents, source)) +} + +fn display_source_paths(paths: &[PathBuf]) -> String { + paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") +} + +fn copy_dir_recursive( + source: &Path, + target: &Path, + source_agent: ExternalAgentSource, +) -> io::Result<()> { fs::create_dir_all(target)?; for entry in fs::read_dir(source)? { @@ -1630,13 +1762,13 @@ fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> { let file_type = entry.file_type()?; if file_type.is_dir() { - copy_dir_recursive(&source_path, &target_path)?; + copy_dir_recursive(&source_path, &target_path, source_agent)?; continue; } if file_type.is_file() { if is_skill_md(&source_path) { - rewrite_and_copy_text_file(&source_path, &target_path)?; + rewrite_and_copy_text_file(&source_path, &target_path, source_agent)?; } else { fs::copy(source_path, target_path)?; } @@ -1652,25 +1784,36 @@ fn is_skill_md(path: &Path) -> bool { .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) } -fn rewrite_and_copy_text_file(source: &Path, target: &Path) -> io::Result<()> { +fn rewrite_and_copy_text_file( + source: &Path, + target: &Path, + source_agent: ExternalAgentSource, +) -> io::Result<()> { let source_contents = fs::read_to_string(source)?; - let rewritten = rewrite_external_agent_terms(&source_contents); + let rewritten = rewrite_external_agent_terms(&source_contents, source_agent); fs::write(target, rewritten) } -fn rewrite_external_agent_terms(content: &str) -> String { - let mut rewritten = replace_case_insensitive_with_boundaries( - content, - &EXTERNAL_AGENT_CONFIG_MD.to_ascii_lowercase(), - "AGENTS.md", - ); - for from in [ - "claude code", - "claude-code", - "claude_code", - "claudecode", - "claude", - ] { +fn rewrite_external_agent_terms(content: &str, source: ExternalAgentSource) -> String { + let (doc_file_name, term_variants): (&str, &[&str]) = match source { + ExternalAgentSource::ClaudeCode => ( + EXTERNAL_AGENT_CONFIG_MD, + &[ + "claude code", + "claude-code", + "claude_code", + "claudecode", + "claude", + ], + ), + ExternalAgentSource::Cursor => ( + CURSOR_LEGACY_RULES_FILE, + &["cursor agent", "cursor-agent", "cursor"], + ), + }; + let mut rewritten = + replace_case_insensitive_with_boundaries(content, doc_file_name, "AGENTS.md"); + for from in term_variants { rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex"); } rewritten diff --git a/codex-rs/app-server/src/config/external_agent_config/cursor.rs b/codex-rs/app-server/src/config/external_agent_config/cursor.rs new file mode 100644 index 000000000000..38e5b4da2945 --- /dev/null +++ b/codex-rs/app-server/src/config/external_agent_config/cursor.rs @@ -0,0 +1,86 @@ +use std::fs; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use super::CURSOR_AGENT_DIR; +use super::CURSOR_LEGACY_RULES_FILE; + +pub(super) fn rule_sources(repo_root: &Path) -> io::Result> { + let mut sources = Vec::new(); + let legacy_rules = repo_root.join(CURSOR_LEGACY_RULES_FILE); + if is_non_empty_text_file(&legacy_rules)? { + sources.push(legacy_rules); + } + + let rules_dir = repo_root.join(CURSOR_AGENT_DIR).join("rules"); + if rules_dir.is_dir() { + for entry in fs::read_dir(rules_dir)? { + let entry = entry?; + let path = entry.path(); + if !entry.file_type()?.is_file() + || !matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("md" | "mdc") + ) + { + continue; + } + let contents = fs::read_to_string(&path)?; + if rule_is_always_applied(&contents) + && !strip_markdown_frontmatter(&contents).trim().is_empty() + { + sources.push(path); + } + } + } + sources.sort(); + Ok(sources) +} + +fn rule_is_always_applied(contents: &str) -> bool { + let Some(frontmatter) = markdown_frontmatter(contents) else { + return false; + }; + frontmatter.lines().any(|line| { + line.split_once(':').is_some_and(|(key, value)| { + key.trim() == "alwaysApply" && value.trim().eq_ignore_ascii_case("true") + }) + }) +} + +fn markdown_frontmatter(contents: &str) -> Option<&str> { + let contents = contents.strip_prefix("---")?; + let contents = contents + .strip_prefix("\r\n") + .or_else(|| contents.strip_prefix('\n'))?; + contents + .find("\n---") + .map(|frontmatter_end| &contents[..frontmatter_end]) +} + +pub(super) fn strip_markdown_frontmatter(contents: &str) -> &str { + let Some(contents) = contents.strip_prefix("---") else { + return contents; + }; + let Some(contents) = contents + .strip_prefix("\r\n") + .or_else(|| contents.strip_prefix('\n')) + else { + return contents; + }; + let Some(frontmatter_end) = contents.find("\n---") else { + return contents; + }; + contents[frontmatter_end + "\n---".len()..] + .strip_prefix("\r\n") + .or_else(|| contents[frontmatter_end + "\n---".len()..].strip_prefix('\n')) + .unwrap_or(&contents[frontmatter_end + "\n---".len()..]) +} + +fn is_non_empty_text_file(path: &Path) -> io::Result { + if !path.is_file() { + return Ok(false); + } + Ok(!fs::read_to_string(path)?.trim().is_empty()) +} diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 5bae994ca5e2..deb09e046682 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -8,7 +8,6 @@ use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as Co use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType; use crate::config::external_agent_config::ExternalAgentConfigService; use crate::config::external_agent_config::NamedMigration as CoreNamedMigration; -use crate::config::external_agent_config::PendingPluginImport; use crate::config::external_agent_config::PluginImportOutcome; use crate::config::external_agent_config::record_import_error; use crate::config_manager::ConfigManager; @@ -111,8 +110,10 @@ impl ExternalAgentConfigRequestProcessor { &self, params: ExternalAgentConfigDetectParams, ) -> Result { - let items = self + let migration_service = self .migration_service + .with_api_source(params.source.as_deref()); + let items = migration_service .detect(ExternalAgentConfigDetectOptions { include_home: params.include_home, cwds: params.cwds, @@ -213,6 +214,9 @@ impl ExternalAgentConfigRequestProcessor { ) -> Result<(), JSONRPCErrorError> { let import_id = Uuid::new_v4().to_string(); let analytics_source = params.source.clone().unwrap_or_default(); + let migration_service = self + .migration_service + .with_api_source(params.source.as_deref()); let needs_runtime_refresh = migration_items_need_runtime_refresh(¶ms.migration_items); let has_migration_items = !params.migration_items.is_empty(); let has_plugin_imports = params.migration_items.iter().any(|item| { @@ -222,8 +226,10 @@ impl ExternalAgentConfigRequestProcessor { ) }); let (pending_session_imports, session_validation_result) = - self.validate_pending_session_imports(¶ms); - let import_outcome = self.import_external_agent_config(params).await; + self.validate_pending_session_imports(¶ms, &migration_service); + let import_outcome = self + .import_external_agent_config(params, &migration_service) + .await; if needs_runtime_refresh { self.config_processor.handle_config_mutation().await; } @@ -266,7 +272,7 @@ impl ExternalAgentConfigRequestProcessor { } let session_importer = self.session_importer.clone(); - let plugin_processor = self.clone(); + let plugin_migration_service = migration_service; let outgoing = Arc::clone(&self.outgoing); let state_db = self.state_db.clone(); let analytics_events_client = self.analytics_events_client.clone(); @@ -301,8 +307,11 @@ impl ExternalAgentConfigRequestProcessor { pending_plugin_import.description.clone(), pending_plugin_import.cwd.clone(), ); - match plugin_processor - .complete_pending_plugin_import(pending_plugin_import) + match plugin_migration_service + .import_plugins( + pending_plugin_import.cwd.as_deref(), + Some(pending_plugin_import.details), + ) .await { Ok(plugin_outcome) => { @@ -312,7 +321,7 @@ impl ExternalAgentConfigRequestProcessor { record_import_error( &mut item_result, "plugin_import", - error.message.clone(), + error.to_string(), /*source*/ None, ); } @@ -374,6 +383,7 @@ impl ExternalAgentConfigRequestProcessor { fn validate_pending_session_imports( &self, params: &ExternalAgentConfigImportParams, + migration_service: &ExternalAgentConfigService, ) -> (Vec, Option) { let sessions = params .migration_items @@ -403,33 +413,31 @@ impl ExternalAgentConfigRequestProcessor { let mut selected_session_paths = HashSet::new(); let mut selected_sessions = Vec::new(); for session in sessions { - let canonical_path = match self - .migration_service - .external_agent_session_source_path(&session.path) - { - Ok(Some(canonical_path)) => canonical_path, - Ok(None) => { - record_import_error( - &mut item_result, - "session_missing", - format!( - "external agent session was not detected for import: {}", - session.path.display() - ), - Some(session.path.display().to_string()), - ); - continue; - } - Err(err) => { - record_import_error( - &mut item_result, - "session_source_path", - err.to_string(), - Some(session.path.display().to_string()), - ); - continue; - } - }; + let canonical_path = + match migration_service.external_agent_session_source_path(&session.path) { + Ok(Some(canonical_path)) => canonical_path, + Ok(None) => { + record_import_error( + &mut item_result, + "session_missing", + format!( + "external agent session was not detected for import: {}", + session.path.display() + ), + Some(session.path.display().to_string()), + ); + continue; + } + Err(err) => { + record_import_error( + &mut item_result, + "session_source_path", + err.to_string(), + Some(session.path.display().to_string()), + ); + continue; + } + }; if selected_session_paths.insert(canonical_path) { selected_sessions.push(session); } @@ -440,8 +448,9 @@ impl ExternalAgentConfigRequestProcessor { async fn import_external_agent_config( &self, params: ExternalAgentConfigImportParams, + migration_service: &ExternalAgentConfigService, ) -> CoreImportOutcome { - self.migration_service + migration_service .import( params .migration_items @@ -541,19 +550,6 @@ impl ExternalAgentConfigRequestProcessor { ) .await } - - async fn complete_pending_plugin_import( - &self, - pending_plugin_import: PendingPluginImport, - ) -> Result { - self.migration_service - .import_plugins( - pending_plugin_import.cwd.as_deref(), - Some(pending_plugin_import.details), - ) - .await - .map_err(|err| internal_error(err.to_string())) - } } async fn send_import_progress( diff --git a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs index e0a53a9c983e..c1d94ff690d1 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs @@ -1,6 +1,8 @@ +use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; +use chrono::DateTime; use chrono::Utc; use codex_arg0::Arg0DispatchPaths; use codex_core::ThreadManager; @@ -20,11 +22,13 @@ use codex_protocol::protocol::ThreadMemoryMode; use codex_rollout::is_persisted_rollout_item; use codex_thread_store::AppendThreadItemsParams; use codex_thread_store::CreateThreadParams; +use codex_thread_store::HistoricalThreadTimestamps; use codex_thread_store::ThreadMetadataPatch; use codex_thread_store::ThreadPersistenceMetadata; use codex_thread_store::ThreadStore; use codex_thread_store::UpdateThreadMetadataParams; use futures::StreamExt; +use tokio::sync::Mutex; use tokio::sync::Semaphore; use crate::config::external_agent_config::ExternalAgentConfigImportItemResult; @@ -37,6 +41,7 @@ const SESSION_IMPORT_CONCURRENCY: usize = 5; pub(super) struct ExternalAgentSessionImporter { codex_home: PathBuf, permits: Arc, + reserved_source_identities: Arc>>, thread_manager: Arc, thread_store: Arc, config_manager: ConfigManager, @@ -54,6 +59,7 @@ impl ExternalAgentSessionImporter { Self { codex_home, permits: Arc::new(Semaphore::new(1)), + reserved_source_identities: Arc::new(Mutex::new(HashSet::new())), thread_manager, thread_store, config_manager, @@ -134,16 +140,38 @@ impl ExternalAgentSessionImporter { else { return Ok(None); }; - let imported_thread_id = - self.persist_session(pending_import.session) - .await - .map_err(|message| SessionImportFailure { - source_path: pending_import.source_path.clone(), + let source_identity = pending_import + .session + .source_session_id + .as_ref() + .map(|session_id| format!("session:{session_id}")) + .unwrap_or_else(|| format!("path:{}", pending_import.source_path.display())); + if !self + .reserved_source_identities + .lock() + .await + .insert(source_identity.clone()) + { + return Ok(None); + } + let source_session_id = pending_import.session.source_session_id.clone(); + let imported_thread_id = match self.persist_session(pending_import.session).await { + Ok(imported_thread_id) => imported_thread_id, + Err(message) => { + self.reserved_source_identities + .lock() + .await + .remove(&source_identity); + return Err(SessionImportFailure { + source_path: pending_import.source_path, message, stage: "session_persist", - })?; + }); + } + }; Ok(Some(CompletedExternalAgentSessionImport { source_path: pending_import.source_path, + source_session_id, source_content_sha256: pending_import.source_content_sha256, imported_thread_id, })) @@ -168,6 +196,9 @@ impl ExternalAgentSessionImporter { cwd, title, first_user_message, + source_session_id: _, + created_at, + updated_at, mut rollout_items, } = session; let config = self @@ -204,6 +235,12 @@ impl ExternalAgentSessionImporter { ThreadMemoryMode::Disabled }; let now = Utc::now(); + let created_at = created_at + .and_then(|timestamp| DateTime::::from_timestamp(timestamp, 0)) + .unwrap_or(now); + let updated_at = updated_at + .and_then(|timestamp| DateTime::::from_timestamp(timestamp, 0)) + .unwrap_or(created_at); let create_params = CreateThreadParams { session_id: thread_id.into(), thread_id, @@ -228,6 +265,10 @@ impl ExternalAgentSessionImporter { cwd: Some(cwd.clone()), model_provider: model_provider.clone(), memory_mode, + historical_timestamps: Some(HistoricalThreadTimestamps { + created_at, + updated_at, + }), }, }; rollout_items.retain(is_persisted_rollout_item); @@ -238,8 +279,9 @@ impl ExternalAgentSessionImporter { title, preview: first_user_message.clone(), model_provider: Some(model_provider), - created_at: Some(now), - updated_at: Some(now), + created_at: Some(created_at), + updated_at: Some(updated_at), + recency_at: Some(updated_at), source: Some(source.clone()), thread_source: Some(None), agent_nickname: Some(source.get_nickname()), @@ -269,14 +311,6 @@ impl ExternalAgentSessionImporter { return Err(format!("failed to import session: {err}")); } - self.thread_store - .update_thread_metadata(UpdateThreadMetadataParams { - thread_id, - patch: metadata, - include_archived: false, - }) - .await - .map_err(|err| format!("failed to update imported session: {err}"))?; self.thread_store .persist_thread(thread_id) .await @@ -285,6 +319,14 @@ impl ExternalAgentSessionImporter { .shutdown_thread(thread_id) .await .map_err(|err| format!("failed to shutdown imported session: {err}"))?; + self.thread_store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: metadata, + include_archived: false, + }) + .await + .map_err(|err| format!("failed to update imported session: {err}"))?; Ok(thread_id) } } diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 9cb1409d7986..697c7a3ff2a4 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -143,6 +143,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, + historical_timestamps: None, }, }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 6bef968941ad..43bc7aeda40b 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -51,6 +51,133 @@ fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String response.import_id } +#[tokio::test] +async fn external_agent_config_imports_cursor_repo_artifacts() -> Result<()> { + let codex_home = TempDir::new()?; + let project_root = codex_home.path().join("repo"); + let cursor_dir = project_root.join(".cursor"); + std::fs::create_dir_all(project_root.join(".git"))?; + std::fs::create_dir_all(cursor_dir.join("skills/release"))?; + std::fs::create_dir_all(cursor_dir.join("commands"))?; + std::fs::create_dir_all(cursor_dir.join("agents"))?; + std::fs::create_dir_all(cursor_dir.join("rules"))?; + std::fs::write( + cursor_dir.join("mcp.json"), + r#"{"mcpServers":{"docs":{"command":"docs-server","args":["--stdio"]}}}"#, + )?; + std::fs::write( + cursor_dir.join("skills/release/SKILL.md"), + "---\nname: release\ndescription: Release with Cursor\n---\n\nUse Cursor to release.\n", + )?; + std::fs::write( + cursor_dir.join("commands/review.md"), + "---\ndescription: Review with Cursor\n---\nReview the change in Cursor.\n", + )?; + std::fs::write( + cursor_dir.join("agents/researcher.md"), + "---\nname: researcher\ndescription: Cursor research agent\n---\nResearch with Cursor.\n", + )?; + std::fs::write( + project_root.join(".cursorrules"), + "Use Cursor for repository work.\n", + )?; + std::fs::write( + cursor_dir.join("rules/always.mdc"), + "---\ndescription: Always apply\nalwaysApply: true\n---\nRun checks in Cursor.\n", + )?; + std::fs::write( + cursor_dir.join("rules/scoped.mdc"), + "---\ndescription: TypeScript only\nglobs: '*.ts'\nalwaysApply: false\n---\nUse TypeScript.\n", + )?; + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/detect", + Some(serde_json::json!({ + "includeHome": false, + "cwds": [&project_root], + "source": "cursor" + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let detected: ExternalAgentConfigDetectResponse = to_response(response)?; + assert_eq!( + detected + .items + .iter() + .map(|item| item.item_type) + .collect::>(), + vec![ + ExternalAgentConfigMigrationItemType::McpServerConfig, + ExternalAgentConfigMigrationItemType::Skills, + ExternalAgentConfigMigrationItemType::Commands, + ExternalAgentConfigMigrationItemType::Subagents, + ExternalAgentConfigMigrationItemType::AgentsMd, + ] + ); + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "migrationItems": detected.items, + "source": "cursor" + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + let import_id = assert_import_response(response); + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + let completed: ExternalAgentConfigImportCompletedNotification = + serde_json::from_value(notification.params.expect("completed params"))?; + assert_eq!(completed.import_id, import_id); + assert!( + completed + .item_type_results + .iter() + .all(|result| result.failures.is_empty()) + ); + + let config = std::fs::read_to_string(project_root.join(".codex/config.toml"))?; + assert!(config.contains("[mcp_servers.docs]")); + assert!(config.contains("command = \"docs-server\"")); + let skill = std::fs::read_to_string(project_root.join(".agents/skills/release/SKILL.md"))?; + assert!(skill.contains("Release with Codex")); + let command = std::fs::read_to_string( + project_root.join(".agents/skills/source-command-review/SKILL.md"), + )?; + assert!(command.contains("Review with Codex")); + let subagent = std::fs::read_to_string(project_root.join(".codex/agents/researcher.toml"))?; + assert!(subagent.contains("Codex research agent")); + let agents_md = std::fs::read_to_string(project_root.join("AGENTS.md"))?; + assert!(agents_md.contains("Use Codex for repository work.")); + assert!(agents_md.contains("Run checks in Codex.")); + assert!(!agents_md.contains("Use TypeScript.")); + assert!(!agents_md.contains("alwaysApply")); + + Ok(()) +} + #[tokio::test] async fn external_agent_config_import_sends_completion_notification_for_sync_only_import() -> Result<()> { @@ -612,7 +739,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); - let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let source_created_at = "2026-06-03T12:00:00Z"; + let source_updated_at = "2026-06-03T12:00:05Z"; let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; @@ -623,14 +751,14 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { serde_json::json!({ "type": "user", "cwd": &project_root, - "timestamp": &recent_timestamp, + "timestamp": source_created_at, "message": { "content": "first request" }, }) .to_string(), serde_json::json!({ "type": "assistant", "cwd": &project_root, - "timestamp": &recent_timestamp, + "timestamp": source_updated_at, "message": { "content": "first answer" }, }) .to_string(), @@ -745,6 +873,22 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { assert_eq!(imported_thread_id, thread.id.to_string()); assert_eq!(thread.preview, "first request"); assert_eq!(thread.name.as_deref(), Some("source session title")); + let source_created_at = chrono::DateTime::parse_from_rfc3339(source_created_at)?.timestamp(); + let source_updated_at = chrono::DateTime::parse_from_rfc3339(source_updated_at)?.timestamp(); + assert_eq!( + ( + thread.created_at, + thread.updated_at, + thread.recency_at, + thread.cwd.as_path(), + ), + ( + source_created_at, + source_updated_at, + Some(source_updated_at), + project_root.as_path(), + ) + ); let request_id = mcp .send_thread_read_request(ThreadReadParams { @@ -759,6 +903,18 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { .await??; let response: ThreadReadResponse = to_response(response)?; assert_eq!(response.thread.turns.len(), 1); + assert_eq!( + ( + response.thread.turns[0].started_at, + response.thread.turns[0].completed_at, + response.thread.turns[0].duration_ms, + ), + ( + Some(source_created_at), + Some(source_updated_at), + Some(5_000), + ) + ); let items = &response.thread.turns[0].items; assert_eq!(items.len(), 3); assert_eq!( diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index 2002b08a8887..86391bde9bf6 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -166,6 +166,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist cwd: Some(codex_home.path().to_path_buf()), model_provider: "mock_provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, + historical_timestamps: None, }, }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 4162e7ebd60b..f99e9123eb67 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1549,6 +1549,7 @@ async fn seed_pathless_store_thread( cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, + historical_timestamps: None, }, }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 66d51d278052..e8bd7606e2ee 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -229,6 +229,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, + historical_timestamps: None, }, }) .await?; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 08abb4637749..e12e1d85b58f 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -614,6 +614,7 @@ impl Session { } else { ThreadMemoryMode::Disabled }, + historical_timestamps: None, }, }; LiveThread::create(Arc::clone(&thread_store), params).await? @@ -632,6 +633,7 @@ impl Session { } else { ThreadMemoryMode::Disabled }, + historical_timestamps: None, }, }; LiveThread::resume(Arc::clone(&thread_store), params).await? diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index ac8f9d049e22..92bc1a837e98 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4082,6 +4082,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { } else { ThreadMemoryMode::Disabled }, + historical_timestamps: None, }, }, ) @@ -6974,6 +6975,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { } else { ThreadMemoryMode::Disabled }, + historical_timestamps: None, }, }, ) @@ -7051,6 +7053,7 @@ async fn submission_loop_channel_close_runs_full_thread_teardown() { } else { ThreadMemoryMode::Disabled }, + historical_timestamps: None, }, }, ) @@ -9360,6 +9363,7 @@ async fn attach_in_memory_thread_store( } else { ThreadMemoryMode::Disabled }, + historical_timestamps: None, }, }, ) diff --git a/codex-rs/external-agent-migration/src/lib.rs b/codex-rs/external-agent-migration/src/lib.rs index 7e5f626d8d01..210709f8f3a0 100644 --- a/codex-rs/external-agent-migration/src/lib.rs +++ b/codex-rs/external-agent-migration/src/lib.rs @@ -19,6 +19,12 @@ const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks"; const COMMAND_SKILL_PREFIX: &str = "source-command"; const MAX_SKILL_NAME_LEN: usize = 64; +#[derive(Clone, Copy)] +enum SourceAgent { + ClaudeCode, + Cursor, +} + #[derive(Debug)] struct ParsedDocument { frontmatter: BTreeMap, @@ -46,6 +52,26 @@ pub fn build_mcp_config_from_external( settings: Option<&JsonValue>, ) -> io::Result { let mcp_servers = read_external_mcp_servers(source_root, external_agent_home)?; + build_mcp_config(mcp_servers, settings) +} + +pub fn build_mcp_config_from_cursor(cursor_dir: &Path) -> io::Result { + let source_file = cursor_dir.join("mcp.json"); + if !source_file.is_file() { + return Ok(TomlValue::Table(Default::default())); + } + let raw = fs::read_to_string(&source_file)?; + let parsed: JsonValue = serde_json::from_str(&raw) + .map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?; + let mut mcp_servers = BTreeMap::new(); + append_mcp_servers_from_value(&parsed, &mut mcp_servers, McpServerMerge::Overwrite); + build_mcp_config(mcp_servers, /*settings*/ None) +} + +fn build_mcp_config( + mcp_servers: BTreeMap, + settings: Option<&JsonValue>, +) -> io::Result { if mcp_servers.is_empty() { return Ok(TomlValue::Table(Default::default())); } @@ -155,6 +181,21 @@ pub fn missing_subagent_names( } pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Result> { + import_subagents_for_source(source_agents, target_agents, SourceAgent::ClaudeCode) +} + +pub fn import_cursor_subagents( + source_agents: &Path, + target_agents: &Path, +) -> io::Result> { + import_subagents_for_source(source_agents, target_agents, SourceAgent::Cursor) +} + +fn import_subagents_for_source( + source_agents: &Path, + target_agents: &Path, + source: SourceAgent, +) -> io::Result> { if !source_agents.is_dir() { return Ok(Vec::new()); } @@ -172,7 +213,10 @@ pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Resul let Some(metadata) = agent_metadata(&document) else { continue; }; - fs::write(&target, render_agent_toml(&document.body, &metadata)?)?; + fs::write( + &target, + render_agent_toml(&document.body, &metadata, source)?, + )?; imported.push(metadata.name); } @@ -195,6 +239,21 @@ pub fn missing_command_names( } pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Result> { + import_commands_for_source(source_commands, target_skills, SourceAgent::ClaudeCode) +} + +pub fn import_cursor_commands( + source_commands: &Path, + target_skills: &Path, +) -> io::Result> { + import_commands_for_source(source_commands, target_skills, SourceAgent::Cursor) +} + +fn import_commands_for_source( + source_commands: &Path, + target_skills: &Path, + source: SourceAgent, +) -> io::Result> { if !source_commands.is_dir() { return Ok(Vec::new()); } @@ -214,7 +273,7 @@ pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Resu }; fs::write( target_dir.join("SKILL.md"), - render_command_skill(&document.body, &name, &description, &source_name), + render_command_skill(&document.body, &name, &description, &source_name, source), )?; imported.push(name); } @@ -627,7 +686,10 @@ fn append_convertible_hook_groups( { command_payload.insert( "statusMessage".to_string(), - JsonValue::String(rewrite_external_agent_terms(status_message)), + JsonValue::String(rewrite_external_agent_terms( + status_message, + SourceAgent::ClaudeCode, + )), ); } hook_commands.push(JsonValue::Object(command_payload)); @@ -1069,12 +1131,16 @@ fn agent_metadata(document: &ParsedDocument) -> Option { }) } -fn render_agent_toml(body: &str, metadata: &AgentMetadata) -> io::Result { +fn render_agent_toml( + body: &str, + metadata: &AgentMetadata, + source: SourceAgent, +) -> io::Result { let mut document = toml::map::Map::new(); document.insert("name".to_string(), TomlValue::String(metadata.name.clone())); document.insert( "description".to_string(), - TomlValue::String(rewrite_external_agent_terms(&metadata.description)), + TomlValue::String(rewrite_external_agent_terms(&metadata.description, source)), ); if let Some(effort) = metadata.effort.as_ref() && let Some(effort) = map_agent_reasoning_effort(effort) @@ -1096,7 +1162,7 @@ fn render_agent_toml(body: &str, metadata: &AgentMetadata) -> io::Result } document.insert( "developer_instructions".to_string(), - TomlValue::String(render_agent_body(body)), + TomlValue::String(render_agent_body(body, source)), ); let serialized = toml::to_string_pretty(&TomlValue::Table(document)) @@ -1104,8 +1170,8 @@ fn render_agent_toml(body: &str, metadata: &AgentMetadata) -> io::Result Ok(format!("{}\n", serialized.trim_end())) } -fn render_agent_body(body: &str) -> String { - let body = rewrite_external_agent_terms(body.trim()); +fn render_agent_body(body: &str, source: SourceAgent) -> String { + let body = rewrite_external_agent_terms(body.trim(), source); if body.is_empty() { "No subagent instructions were found.".to_string() } else { @@ -1160,8 +1226,14 @@ fn command_source_name(source_commands: &Path, source_file: &Path) -> String { .join("-") } -fn render_command_skill(body: &str, name: &str, description: &str, source_name: &str) -> String { - let body = rewrite_external_agent_terms(body.trim()); +fn render_command_skill( + body: &str, + name: &str, + description: &str, + source_name: &str, + source: SourceAgent, +) -> String { + let body = rewrite_external_agent_terms(body.trim(), source); let template_body = if body.is_empty() { "No command template body was found.".to_string() } else { @@ -1170,7 +1242,7 @@ fn render_command_skill(body: &str, name: &str, description: &str, source_name: format!( "---\nname: {}\ndescription: {}\n---\n\n# {name}\n\nUse this skill when the user asks to run the migrated source command `{source_name}`.\n\n## Command Template\n\n{template_body}\n", yaml_string(name), - yaml_string(&rewrite_external_agent_terms(description)), + yaml_string(&rewrite_external_agent_terms(description, source)), ) } @@ -1291,14 +1363,24 @@ fn is_missing_or_empty_text_file(path: &Path) -> io::Result { Ok(fs::read_to_string(path)?.trim().is_empty()) } -fn rewrite_external_agent_terms(content: &str) -> String { - let mut rewritten = replace_case_insensitive_with_boundaries( - content, - &external_agent_doc_file_name(), - "AGENTS.md", - ); - for from in external_agent_term_variants() { - rewritten = replace_case_insensitive_with_boundaries(&rewritten, &from, "Codex"); +fn rewrite_external_agent_terms(content: &str, source: SourceAgent) -> String { + let (doc_file_name, term_variants): (&str, &[&str]) = match source { + SourceAgent::ClaudeCode => ( + "CLAUDE.md", + &[ + "claude code", + "claude-code", + "claude_code", + "claudecode", + "claude", + ], + ), + SourceAgent::Cursor => (".cursorrules", &["cursor agent", "cursor-agent", "cursor"]), + }; + let mut rewritten = + replace_case_insensitive_with_boundaries(content, doc_file_name, "AGENTS.md"); + for from in term_variants { + rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex"); } rewritten } @@ -1365,20 +1447,6 @@ fn external_agent_project_dir_env_var() -> String { ) } -fn external_agent_doc_file_name() -> String { - format!("{SOURCE_EXTERNAL_AGENT_NAME}.md") -} - -fn external_agent_term_variants() -> [String; 5] { - [ - format!("{SOURCE_EXTERNAL_AGENT_NAME} code"), - format!("{SOURCE_EXTERNAL_AGENT_NAME}-code"), - format!("{SOURCE_EXTERNAL_AGENT_NAME}_code"), - format!("{SOURCE_EXTERNAL_AGENT_NAME}code"), - SOURCE_EXTERNAL_AGENT_NAME.to_string(), - ] -} - #[cfg(test)] mod tests { use super::*; @@ -1738,6 +1806,7 @@ command = "enabled-server" "source-command-review", &description, "review", + SourceAgent::ClaudeCode, ); let rendered_document = parse_document_content(&rendered); assert_eq!( @@ -1817,9 +1886,11 @@ command = "enabled-server" "---\nname: reviewer\ndescription: Review code\nmodel: source-opus\neffort: max\n---\nReview carefully.\n", ); let metadata = agent_metadata(&document).expect("metadata"); - let rendered: TomlValue = - toml::from_str(&render_agent_toml(&document.body, &metadata).expect("render agent")) - .expect("parse rendered agent"); + let rendered: TomlValue = toml::from_str( + &render_agent_toml(&document.body, &metadata, SourceAgent::ClaudeCode) + .expect("render agent"), + ) + .expect("parse rendered agent"); let expected: TomlValue = toml::from_str( r#" name = "reviewer" diff --git a/codex-rs/external-agent-sessions/src/detect.rs b/codex-rs/external-agent-sessions/src/detect.rs index f42c90766b10..78e895e71304 100644 --- a/codex-rs/external-agent-sessions/src/detect.rs +++ b/codex-rs/external-agent-sessions/src/detect.rs @@ -84,7 +84,14 @@ pub fn detect_recent_sessions( let mut migrations = Vec::new(); let mut ledger_changed = false; for (modified_at, path) in file_candidates { - match ledger.refresh_current_source(&path, modified_at.0) { + let Ok(Some(summary)) = summarize_session(&path) else { + continue; + }; + match ledger.refresh_current_source( + &path, + summary.source_session_id.as_deref(), + modified_at.0, + ) { Ok(false) => {} Ok(true) => { ledger_changed = true; @@ -92,9 +99,6 @@ pub fn detect_recent_sessions( } Err(_) => continue, } - let Ok(Some(summary)) = summarize_session(&path) else { - continue; - }; let migration = summary.migration; if !migration.cwd.is_dir() { continue; @@ -323,7 +327,7 @@ mod tests { let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); - assert_eq!(sessions, vec![oldest_session.clone()]); + assert_eq!(sessions, vec![oldest_session]); for session in sessions { record_imported_session(root.path(), &session.path, ThreadId::new()) .expect("record import"); @@ -347,17 +351,11 @@ mod tests { ); } - let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); - - assert_eq!(sessions, expected); - for session in sessions { - record_imported_session(root.path(), &session.path, ThreadId::new()) - .expect("record import"); - } - - let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); - - assert_eq!(sessions, vec![oldest_session]); + assert!( + detect_recent_sessions(&external_agent_home, root.path()) + .expect("detect") + .is_empty() + ); } #[test] @@ -383,7 +381,7 @@ mod tests { } #[test] - fn redetects_sessions_when_source_contents_change_after_import() { + fn skips_imported_sessions_when_source_contents_change() { let root = TempDir::new().expect("tempdir"); let external_agent_home = root.path().join(".external"); let project_root = root.path().join("repo"); @@ -405,14 +403,10 @@ mod tests { ) .expect("update session"); - let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); - assert_eq!( - sessions, - vec![ExternalAgentSessionMigration { - path: session_path, - cwd: project_root, - title: Some("hello there".to_string()), - }] + assert!( + detect_recent_sessions(&external_agent_home, root.path()) + .expect("detect") + .is_empty() ); } diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 6391d27bd378..82cdefe831e8 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -41,6 +41,14 @@ pub(crate) fn load_session_for_import_with_content_sha256( .find(|message| message.role == MessageRole::User) .map(|message| summarize_for_label(&message.text)); let title = parsed.source_title.or_else(|| first_user_message.clone()); + let created_at = messages + .iter() + .filter_map(|message| message.timestamp) + .min(); + let updated_at = messages + .iter() + .filter_map(|message| message.timestamp) + .max(); let rollout_items = rollout_items_from_messages(messages); if rollout_items.is_empty() { return Ok(None); @@ -50,6 +58,9 @@ pub(crate) fn load_session_for_import_with_content_sha256( cwd, title, first_user_message, + source_session_id: parsed.source_session_id, + created_at, + updated_at, rollout_items, }, parsed.content_sha256, @@ -62,16 +73,23 @@ fn rollout_items_from_messages(messages: Vec) -> Vec { if let Some(turn_id) = current_turn.take() { - items.push(turn_complete_item(turn_id, /*completed_at*/ None)); + items.push(turn_complete_item( + turn_id, + current_turn_started_at, + current_turn_completed_at, + )); } user_turn_count += 1; let turn_id = format!("external-import-turn-{user_turn_count}"); + current_turn_started_at = message.timestamp; + current_turn_completed_at = message.timestamp; items.push(RolloutItem::EventMsg(EventMsg::TurnStarted( TurnStartedEvent { turn_id: turn_id.clone(), @@ -96,6 +114,12 @@ fn rollout_items_from_messages(messages: Vec) -> Vec) -> Vec RolloutItem { })) } -fn turn_complete_item(turn_id: String, completed_at: Option) -> RolloutItem { +fn turn_complete_item( + turn_id: String, + started_at: Option, + completed_at: Option, +) -> RolloutItem { + let duration_ms = started_at + .zip(completed_at) + .and_then(|(started_at, completed_at)| completed_at.checked_sub(started_at)) + .filter(|duration| *duration >= 0) + .and_then(|duration| duration.checked_mul(1_000)); RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, completed_at, - duration_ms: None, + duration_ms, time_to_first_token_ms: None, })) } @@ -218,6 +255,78 @@ mod tests { ); } + #[test] + fn preserves_source_session_and_turn_timing() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + let first_started_at = "2026-06-03T12:00:00Z"; + let first_completed_at = "2026-06-03T12:00:05Z"; + let second_started_at = "2026-06-03T12:01:00Z"; + let second_completed_at = "2026-06-03T12:01:09Z"; + std::fs::write( + &path, + jsonl(&[ + record_at("user", "first request", &project_root, first_started_at), + record_at( + "assistant", + "first answer", + &project_root, + first_completed_at, + ), + record_at("user", "second request", &project_root, second_started_at), + record_at( + "assistant", + "second answer", + &project_root, + second_completed_at, + ), + ]), + ) + .expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + let turns = build_turns_from_rollout_items(&imported.rollout_items); + let first_started_at = chrono::DateTime::parse_from_rfc3339(first_started_at) + .expect("first started at") + .timestamp(); + let first_completed_at = chrono::DateTime::parse_from_rfc3339(first_completed_at) + .expect("first completed at") + .timestamp(); + let second_started_at = chrono::DateTime::parse_from_rfc3339(second_started_at) + .expect("second started at") + .timestamp(); + let second_completed_at = chrono::DateTime::parse_from_rfc3339(second_completed_at) + .expect("second completed at") + .timestamp(); + + assert_eq!( + (imported.created_at, imported.updated_at), + (Some(first_started_at), Some(second_completed_at)) + ); + assert_eq!( + turns + .iter() + .map(|turn| (turn.started_at, turn.completed_at, turn.duration_ms)) + .collect::>(), + vec![ + ( + Some(first_started_at), + Some(first_completed_at), + Some(5_000) + ), + ( + Some(second_started_at), + Some(second_completed_at), + Some(9_000), + ), + ] + ); + } + #[test] fn adds_import_marker_without_copying_last_agent_message() { let root = TempDir::new().expect("tempdir"); @@ -407,6 +516,10 @@ mod tests { fn record(role: &str, text: &str, cwd: &Path) -> JsonValue { let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + record_at(role, text, cwd, ×tamp) + } + + fn record_at(role: &str, text: &str, cwd: &Path, timestamp: &str) -> JsonValue { serde_json::json!({ "type": role, "cwd": cwd, diff --git a/codex-rs/external-agent-sessions/src/ledger.rs b/codex-rs/external-agent-sessions/src/ledger.rs index 9a3b2042b86d..b5f8fd3da9ab 100644 --- a/codex-rs/external-agent-sessions/src/ledger.rs +++ b/codex-rs/external-agent-sessions/src/ledger.rs @@ -23,6 +23,8 @@ pub(super) struct ImportedExternalAgentSessionLedger { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct ImportedExternalAgentSessionRecord { source_path: PathBuf, + #[serde(default)] + source_session_id: Option, content_sha256: String, imported_thread_id: ThreadId, imported_at: i64, @@ -33,6 +35,7 @@ struct ImportedExternalAgentSessionRecord { #[derive(Debug, PartialEq, Eq)] pub struct CompletedExternalAgentSessionImport { pub source_path: PathBuf, + pub source_session_id: Option, pub source_content_sha256: String, pub imported_thread_id: ThreadId, } @@ -46,8 +49,9 @@ pub(super) struct ImportedSourceState { pub fn has_current_session_been_imported( codex_home: &Path, source_path: &Path, + source_session_id: Option<&str>, ) -> io::Result { - load_import_ledger(codex_home)?.contains_current_source(source_path) + load_import_ledger(codex_home)?.contains_source_identity(source_path, source_session_id) } #[cfg(test)] @@ -62,6 +66,7 @@ pub(crate) fn record_imported_session( vec![CompletedExternalAgentSessionImport { source_content_sha256: session_content_sha256(&source_path)?, source_path, + source_session_id: None, imported_thread_id, }], ) @@ -79,10 +84,18 @@ pub fn record_completed_session_imports( for import in imports { let source_modified_at = session_modified_at(&import.source_path).ok().flatten(); if let Some(index) = ledger.records.iter().rposition(|record| { - record.source_path == import.source_path - && record.content_sha256 == import.source_content_sha256 + record_matches_source_identity( + record, + &import.source_path, + import.source_session_id.as_deref(), + ) }) { let mut record = ledger.records.remove(index); + record.source_path = import.source_path; + if import.source_session_id.is_some() { + record.source_session_id = import.source_session_id; + } + record.content_sha256 = import.source_content_sha256; record.imported_thread_id = import.imported_thread_id; record.imported_at = imported_at; record.source_modified_at = source_modified_at.or(record.source_modified_at); @@ -91,6 +104,7 @@ pub fn record_completed_session_imports( } ledger.records.push(ImportedExternalAgentSessionRecord { source_path: import.source_path, + source_session_id: import.source_session_id, content_sha256: import.source_content_sha256, imported_thread_id: import.imported_thread_id, imported_at, @@ -115,44 +129,40 @@ impl ImportedExternalAgentSessionLedger { states } - pub(super) fn contains_current_source(&self, source_path: &Path) -> io::Result { + pub(super) fn contains_source_identity( + &self, + source_path: &Path, + source_session_id: Option<&str>, + ) -> io::Result { if self.records.is_empty() { return Ok(false); } let source_path = canonical_source_path(source_path)?; - if !self + Ok(self .records .iter() - .any(|record| record.source_path == source_path) - { - return Ok(false); - } - let content_sha256 = session_content_sha256(&source_path)?; - Ok(self.records.iter().any(|record| { - record.source_path == source_path && record.content_sha256 == content_sha256 - })) + .any(|record| record_matches_source_identity(record, &source_path, source_session_id))) } pub(super) fn refresh_current_source( &mut self, source_path: &Path, + source_session_id: Option<&str>, source_modified_at: i64, ) -> io::Result { let source_path = canonical_source_path(source_path)?; - if !self - .records - .iter() - .any(|record| record.source_path == source_path) - { - return Ok(false); - } - let content_sha256 = session_content_sha256(&source_path)?; let Some(index) = self.records.iter().rposition(|record| { - record.source_path == source_path && record.content_sha256 == content_sha256 + record_matches_source_identity(record, &source_path, source_session_id) }) else { return Ok(false); }; + let content_sha256 = session_content_sha256(&source_path)?; let mut record = self.records.remove(index); + record.source_path = source_path; + if let Some(source_session_id) = source_session_id { + record.source_session_id = Some(source_session_id.to_string()); + } + record.content_sha256 = content_sha256; record.imported_at = now_unix_seconds(); record.source_modified_at = Some(source_modified_at); self.records.push(record); @@ -160,6 +170,23 @@ impl ImportedExternalAgentSessionLedger { } } +fn record_matches_source_identity( + record: &ImportedExternalAgentSessionRecord, + source_path: &Path, + source_session_id: Option<&str>, +) -> bool { + record.source_path == source_path + || source_session_id.is_some_and(|source_session_id| { + record.source_session_id.as_deref() == Some(source_session_id) + || record.source_session_id.is_none() + && record + .source_path + .file_stem() + .and_then(|stem| stem.to_str()) + == Some(source_session_id) + }) +} + pub(super) fn load_import_ledger( codex_home: &Path, ) -> io::Result { diff --git a/codex-rs/external-agent-sessions/src/ledger_tests.rs b/codex-rs/external-agent-sessions/src/ledger_tests.rs index f4b9dd6f7c2a..6bf34391cc2e 100644 --- a/codex-rs/external-agent-sessions/src/ledger_tests.rs +++ b/codex-rs/external-agent-sessions/src/ledger_tests.rs @@ -13,7 +13,7 @@ fn empty_ledger_does_not_read_source() { assert!( !ImportedExternalAgentSessionLedger::default() - .contains_current_source(&missing_source) + .contains_source_identity(&missing_source, None) .expect("empty ledger cannot contain sources") ); } @@ -33,6 +33,7 @@ fn completed_imports_do_not_read_source_files() { &codex_home, vec![CompletedExternalAgentSessionImport { source_path: source_path.clone(), + source_session_id: None, source_content_sha256: format!("{:x}", Sha256::digest(contents)), imported_thread_id, }], @@ -62,6 +63,7 @@ fn completed_import_refreshes_existing_record_metadata() { &codex_home, vec![CompletedExternalAgentSessionImport { source_path: source_path.clone(), + source_session_id: None, source_content_sha256: content_sha256.clone(), imported_thread_id: first_thread_id, }], @@ -71,6 +73,7 @@ fn completed_import_refreshes_existing_record_metadata() { &codex_home, vec![CompletedExternalAgentSessionImport { source_path: source_path.clone(), + source_session_id: None, source_content_sha256: content_sha256, imported_thread_id: second_thread_id, }], @@ -83,3 +86,77 @@ fn completed_import_refreshes_existing_record_metadata() { assert_eq!(ledger.records[0].imported_thread_id, second_thread_id); assert!(ledger.records[0].source_modified_at.is_some()); } + +#[test] +fn stable_session_id_deduplicates_moved_and_changed_sources() { + let root = TempDir::new().expect("tempdir"); + let codex_home = root.path().join("codex-home"); + let first_path = root.path().join("first-session.jsonl"); + let second_path = root.path().join("moved-session.jsonl"); + std::fs::write(&first_path, "first contents").expect("first source"); + std::fs::write(&second_path, "updated contents").expect("moved source"); + let first_path = std::fs::canonicalize(first_path).expect("canonical first source"); + let second_path = std::fs::canonicalize(second_path).expect("canonical moved source"); + let source_session_id = "source-session-id"; + let first_thread_id = ThreadId::new(); + let second_thread_id = ThreadId::new(); + + record_completed_session_imports( + &codex_home, + vec![CompletedExternalAgentSessionImport { + source_path: first_path, + source_session_id: Some(source_session_id.to_string()), + source_content_sha256: format!("{:x}", Sha256::digest(b"first contents")), + imported_thread_id: first_thread_id, + }], + ) + .expect("record first import"); + + let ledger = super::load_import_ledger(&codex_home).expect("ledger"); + assert!( + ledger + .contains_source_identity(&second_path, Some(source_session_id)) + .expect("match moved source") + ); + + record_completed_session_imports( + &codex_home, + vec![CompletedExternalAgentSessionImport { + source_path: second_path.clone(), + source_session_id: Some(source_session_id.to_string()), + source_content_sha256: format!("{:x}", Sha256::digest(b"updated contents")), + imported_thread_id: second_thread_id, + }], + ) + .expect("record moved import"); + + let ledger = super::load_import_ledger(&codex_home).expect("updated ledger"); + assert_eq!(ledger.records.len(), 1); + assert_eq!(ledger.records[0].source_path, second_path); + assert_eq!( + ledger.records[0].source_session_id.as_deref(), + Some(source_session_id) + ); + assert_eq!(ledger.records[0].imported_thread_id, second_thread_id); +} + +#[test] +fn legacy_ledger_uses_source_filename_as_session_id() { + let root = TempDir::new().expect("tempdir"); + let codex_home = root.path().join("codex-home"); + let source_session_id = "source-session-id"; + let first_path = root.path().join(format!("{source_session_id}.jsonl")); + let moved_path = root.path().join("moved-session.jsonl"); + std::fs::write(&first_path, "first contents").expect("first source"); + std::fs::write(&moved_path, "first contents").expect("moved source"); + super::record_imported_session(&codex_home, &first_path, ThreadId::new()) + .expect("record legacy import"); + let moved_path = std::fs::canonicalize(moved_path).expect("canonical moved source"); + + let ledger = super::load_import_ledger(&codex_home).expect("ledger"); + assert!( + ledger + .contains_source_identity(&moved_path, Some(source_session_id)) + .expect("match legacy source") + ); +} diff --git a/codex-rs/external-agent-sessions/src/lib.rs b/codex-rs/external-agent-sessions/src/lib.rs index 0b7a4eb2bac9..781edfe207e9 100644 --- a/codex-rs/external-agent-sessions/src/lib.rs +++ b/codex-rs/external-agent-sessions/src/lib.rs @@ -32,6 +32,9 @@ pub struct ImportedExternalAgentSession { pub cwd: PathBuf, pub title: Option, pub first_user_message: Option, + pub source_session_id: Option, + pub created_at: Option, + pub updated_at: Option, pub rollout_items: Vec, } @@ -46,15 +49,19 @@ pub fn prepare_validated_session_import( codex_home: &Path, session: ExternalAgentSessionMigration, ) -> io::Result> { - let has_been_imported = has_current_session_been_imported(codex_home, &session.path)?; - if has_been_imported { - return Ok(None); - } let Some((source_path, imported_session, source_content_sha256)) = load_importable_session(&session.path)? else { return Ok(None); }; + let has_been_imported = has_current_session_been_imported( + codex_home, + &source_path, + imported_session.source_session_id.as_deref(), + )?; + if has_been_imported { + return Ok(None); + } Ok(Some(PendingSessionImport { source_path, source_content_sha256, @@ -127,9 +134,31 @@ mod tests { let root = TempDir::new().expect("tempdir"); let codex_home = root.path().join("codex-home"); let source_path = root.path().join("session.jsonl"); - std::fs::write(&source_path, "{}\n").expect("session"); + let source_record = serde_json::json!({ + "type": "user", + "cwd": root.path(), + "sessionId": "source-session-id", + "timestamp": "2026-06-03T12:00:00Z", + "message": { "content": "first request" }, + }); + std::fs::write(&source_path, source_record.to_string()).expect("session"); ledger::record_imported_session(&codex_home, &source_path, ThreadId::new()) .expect("record import"); + std::fs::write( + &source_path, + format!( + "{}\n{}", + source_record, + serde_json::json!({ + "type": "assistant", + "cwd": root.path(), + "sessionId": "source-session-id", + "timestamp": "2026-06-03T12:00:05Z", + "message": { "content": "first answer" }, + }) + ), + ) + .expect("updated session"); let pending = prepare_validated_session_import(&codex_home, session_migration(&source_path)) @@ -156,6 +185,7 @@ mod tests { let contents = serde_json::json!({ "type": "user", "cwd": root.path(), + "sessionId": "source-session-id", "timestamp": "2026-06-03T12:00:00Z", "message": { "content": "first request" }, }) @@ -171,6 +201,10 @@ mod tests { pending.source_content_sha256, format!("{:x}", Sha256::digest(contents)) ); + assert_eq!( + pending.session.source_session_id.as_deref(), + Some("source-session-id") + ); } fn session_migration(path: &Path) -> ExternalAgentSessionMigration { diff --git a/codex-rs/external-agent-sessions/src/records.rs b/codex-rs/external-agent-sessions/src/records.rs index 00307fa1d52f..9b9c9d21c7cb 100644 --- a/codex-rs/external-agent-sessions/src/records.rs +++ b/codex-rs/external-agent-sessions/src/records.rs @@ -20,12 +20,14 @@ const EXTERNAL_AGENT_TOOL_RESULT_TAG: &str = "external_agent_tool_result"; pub struct SessionSummary { pub latest_timestamp: i64, + pub source_session_id: Option, pub migration: ExternalAgentSessionMigration, } pub(super) struct ParsedSessionImport { pub cwd: Option, pub source_title: Option, + pub source_session_id: Option, pub messages: Vec, pub content_sha256: String, } @@ -37,6 +39,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { let mut custom_title = None; let mut ai_title = None; let mut title = None; + let mut source_session_id = None; let mut latest_timestamp = None; let mut saw_message = false; @@ -49,6 +52,9 @@ pub fn summarize_session(path: &Path) -> io::Result> { let Ok(mut record) = serde_json::from_str::(trimmed) else { continue; }; + if source_session_id.is_none() { + source_session_id = source_session_id_from_record(&record).map(str::to_string); + } if cwd.is_none() { cwd = record .get("cwd") @@ -85,6 +91,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { }; Ok(Some(SessionSummary { latest_timestamp, + source_session_id, migration: ExternalAgentSessionMigration { path: path.to_path_buf(), cwd, @@ -99,6 +106,7 @@ pub(super) fn read_session_import(path: &Path) -> io::Result io::Result(trimmed) else { continue; }; + if source_session_id.is_none() { + source_session_id = source_session_id_from_record(&record).map(str::to_string); + } if cwd.is_none() { cwd = record .get("cwd") @@ -134,6 +145,7 @@ pub(super) fn read_session_import(path: &Path) -> io::Result Option<&str> { title_from_record(record, "ai-title", "aiTitle") } +fn source_session_id_from_record(record: &JsonValue) -> Option<&str> { + record + .get("sessionId") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) +} + fn title_from_record<'a>(record: &'a JsonValue, record_type: &str, field: &str) -> Option<&'a str> { (record.get("type").and_then(JsonValue::as_str) == Some(record_type)) .then(|| record.get(field).and_then(JsonValue::as_str)) @@ -351,6 +371,7 @@ mod tests { serde_json::json!({ "type": "user", "cwd": root.path(), + "sessionId": "source-session-id", "timestamp": "2026-06-03T12:00:00Z", "message": { "content": "first request" }, }) @@ -374,6 +395,10 @@ mod tests { assert_eq!(parsed.cwd.as_deref(), Some(root.path())); assert_eq!(parsed.source_title.as_deref(), Some("custom title")); + assert_eq!( + parsed.source_session_id.as_deref(), + Some("source-session-id") + ); assert_eq!(parsed.messages.len(), 1); assert_eq!(parsed.messages[0].text, "first request"); assert_eq!( diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 87e97997a245..529ccfbf76cb 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -9,7 +9,9 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; +use chrono::DateTime; use chrono::SecondsFormat; +use chrono::Utc; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::capabilities::SelectedCapabilityRoot; @@ -96,13 +98,21 @@ pub enum RolloutRecorderParams { selected_capability_roots: Vec, multi_agent_version: Option, history_mode: ThreadHistoryMode, - initial_window_id: Option, + initial_window_id: Box>, + historical_timestamps: Option>, }, Resume { path: PathBuf, }, } +#[doc(hidden)] +#[derive(Clone, Default)] +pub struct HistoricalRolloutTimestamps { + created_at: Option>, + updated_at: Option>, +} + enum RolloutCmd { AddItems(Vec), Persist { @@ -189,7 +199,8 @@ impl RolloutRecorderParams { selected_capability_roots: Vec::new(), multi_agent_version: None, history_mode: Default::default(), - initial_window_id: None, + initial_window_id: Box::default(), + historical_timestamps: None, } } @@ -244,7 +255,33 @@ impl RolloutRecorderParams { .. } = &mut self { - *window_id = Some(initial_window_id); + **window_id = Some(initial_window_id); + } + self + } + + pub fn with_created_at(mut self, created_at: DateTime) -> Self { + if let Self::Create { + historical_timestamps, + .. + } = &mut self + { + historical_timestamps + .get_or_insert_with(Default::default) + .created_at = Some(created_at); + } + self + } + + pub fn with_updated_at(mut self, updated_at: DateTime) -> Self { + if let Self::Create { + historical_timestamps, + .. + } = &mut self + { + historical_timestamps + .get_or_insert_with(Default::default) + .updated_at = Some(updated_at); } self } @@ -751,7 +788,7 @@ impl RolloutRecorder { config: &impl RolloutConfigView, params: RolloutRecorderParams, ) -> std::io::Result { - let (file, deferred_log_file_info, rollout_path, meta) = match params { + let (file, deferred_log_file_info, rollout_path, meta, updated_at) = match params { RolloutRecorderParams::Create { session_id, conversation_id, @@ -766,8 +803,16 @@ impl RolloutRecorder { multi_agent_version, history_mode, initial_window_id, + historical_timestamps, } => { - let log_file_info = precompute_log_file_info(config, conversation_id)?; + let historical_timestamps = historical_timestamps + .map(|timestamps| *timestamps) + .unwrap_or_default(); + let log_file_info = precompute_log_file_info( + config, + conversation_id, + historical_timestamps.created_at, + )?; let path = log_file_info.path.clone(); let thread_id = log_file_info.conversation_id; let started_at = log_file_info.timestamp; @@ -805,10 +850,16 @@ impl RolloutRecorder { memory_mode: (!config.generate_memories()).then_some("disabled".to_string()), history_mode, multi_agent_version, - context_window: initial_window_id.map(SessionContextWindow::new), + context_window: (*initial_window_id).map(SessionContextWindow::new), }; - (None, Some(log_file_info), path, Some(session_meta)) + ( + None, + Some(log_file_info), + path, + Some(session_meta), + historical_timestamps.updated_at, + ) } RolloutRecorderParams::Resume { path } => { let path = compression::materialize_rollout_for_append(path.as_path()).await?; @@ -822,6 +873,7 @@ impl RolloutRecorder { None, path, None, + None, ) } }; @@ -847,6 +899,7 @@ impl RolloutRecorder { meta, cwd, rollout_path_for_spawn.clone(), + updated_at, ) .await; if let Err(err) = result { @@ -1498,10 +1551,14 @@ struct LogFileInfo { fn precompute_log_file_info( config: &impl RolloutConfigView, conversation_id: ThreadId, + created_at: Option>, ) -> std::io::Result { // Resolve ~/.codex/sessions/YYYY/MM/DD path. - let timestamp = OffsetDateTime::now_local() - .map_err(|e| IoError::other(format!("failed to get local time: {e}")))?; + let timestamp = match created_at { + Some(created_at) => OffsetDateTime::from(std::time::SystemTime::from(created_at)), + None => OffsetDateTime::now_local() + .map_err(|e| IoError::other(format!("failed to get local time: {e}")))?, + }; let mut dir = config.codex_home().to_path_buf(); dir.push(SESSIONS_SUBDIR); dir.push(timestamp.year().to_string()); @@ -1554,6 +1611,7 @@ struct RolloutWriterState { meta: Option, cwd: PathBuf, rollout_path: PathBuf, + updated_at: Option>, last_logged_error: Option, } @@ -1564,6 +1622,7 @@ impl RolloutWriterState { meta: Option, cwd: PathBuf, rollout_path: PathBuf, + updated_at: Option>, ) -> Self { Self { writer: file.map(|file| JsonlWriter { file }), @@ -1572,6 +1631,7 @@ impl RolloutWriterState { meta, cwd, rollout_path, + updated_at, last_logged_error: None, } } @@ -1604,7 +1664,14 @@ impl RolloutWriterState { if self.is_deferred() && self.pending_items.is_empty() { return Ok(()); } - self.write_pending_with_recovery("shutdown").await + self.write_pending_with_recovery("shutdown").await?; + if let Some(updated_at) = self.updated_at { + std::fs::OpenOptions::new() + .write(true) + .open(self.rollout_path.as_path())? + .set_times(std::fs::FileTimes::new().set_modified(updated_at.into()))?; + } + Ok(()) } async fn write_pending_with_recovery(&mut self, operation: &str) -> std::io::Result<()> { @@ -1722,8 +1789,16 @@ async fn rollout_writer( meta: Option, cwd: PathBuf, rollout_path: PathBuf, + updated_at: Option>, ) -> std::io::Result<()> { - let mut state = RolloutWriterState::new(file, deferred_log_file_info, meta, cwd, rollout_path); + let mut state = RolloutWriterState::new( + file, + deferred_log_file_info, + meta, + cwd, + rollout_path, + updated_at, + ); // Process rollout commands while let Some(cmd) = rx.recv().await { diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 7fcaff08abb8..4586af894d78 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -574,6 +574,7 @@ async fn writer_state_retries_write_error_before_reporting_flush_success() -> st /*meta*/ None, home.path().to_path_buf(), rollout_path.clone(), + /*updated_at*/ None, ); state.add_items(vec![RolloutItem::EventMsg(EventMsg::AgentMessage( AgentMessageEvent { diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index c80b2b671679..f9899a3339bd 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -687,6 +687,26 @@ WHERE id = ? Ok(result.rows_affected() > 0) } + /// Replace product recency exactly for historical import and repair paths. + pub async fn set_thread_recency_at( + &self, + thread_id: ThreadId, + recency_at: DateTime, + ) -> anyhow::Result { + let recency_at_seconds = datetime_to_epoch_seconds(recency_at); + let recency_at_millis = datetime_to_epoch_millis(recency_at); + self.thread_recency_at_millis + .fetch_max(recency_at_millis, Ordering::Relaxed); + let result = + sqlx::query("UPDATE threads SET recency_at = ?, recency_at_ms = ? WHERE id = ?") + .bind(recency_at_seconds) + .bind(recency_at_millis) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + /// Allocate a persisted `updated_at` value for thread-list cursor ordering. /// /// We keep a process-local high-water mark so hot rollout writes can get unique, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index cb3f198ff3c5..e08664a02c00 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -137,6 +137,7 @@ mod tests { cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, + historical_timestamps: None, }, }) .await @@ -337,6 +338,7 @@ mod tests { cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, + historical_timestamps: None, } } @@ -777,7 +779,12 @@ fn stored_thread_from_state( .and_then(|metadata| metadata.updated_at) .unwrap_or_else(Utc::now), recency_at: metadata - .and_then(|metadata| metadata.advance_recency_at.or(metadata.updated_at)) + .and_then(|metadata| { + metadata + .recency_at + .or(metadata.advance_recency_at) + .or(metadata.updated_at) + }) .unwrap_or_else(Utc::now), archived_at: None, cwd: metadata diff --git a/codex-rs/thread-store/src/lib.rs b/codex-rs/thread-store/src/lib.rs index 34a0ab2ba1a1..38fffd261c26 100644 --- a/codex-rs/thread-store/src/lib.rs +++ b/codex-rs/thread-store/src/lib.rs @@ -29,6 +29,7 @@ pub use types::CreateThreadParams; pub use types::DeleteThreadParams; pub use types::ExtraConfig; pub use types::GitInfoPatch; +pub use types::HistoricalThreadTimestamps; pub use types::ItemPage; pub use types::ListItemsParams; pub use types::ListThreadsParams; diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index 9b370ef095f2..d058ba629fc9 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -27,26 +27,29 @@ pub(super) async fn create_thread( model_provider_id: params.metadata.model_provider.clone(), generate_memories: matches!(params.metadata.memory_mode, ThreadMemoryMode::Enabled), }; - RolloutRecorder::new( - &config, - RolloutRecorderParams::new( - params.thread_id, - params.forked_from_id, - params.parent_thread_id, - params.source, - params.thread_source, - params.originator, - params.base_instructions, - params.dynamic_tools, - ) - .with_session_id(params.session_id) - .with_selected_capability_roots(params.selected_capability_roots) - .with_multi_agent_version(params.multi_agent_version) - .with_history_mode(params.history_mode) - .with_initial_window_id(params.initial_window_id), + let mut recorder_params = RolloutRecorderParams::new( + params.thread_id, + params.forked_from_id, + params.parent_thread_id, + params.source, + params.thread_source, + params.originator, + params.base_instructions, + params.dynamic_tools, ) - .await - .map_err(|err| ThreadStoreError::Internal { - message: format!("failed to initialize local thread recorder: {err}"), - }) + .with_session_id(params.session_id) + .with_selected_capability_roots(params.selected_capability_roots) + .with_multi_agent_version(params.multi_agent_version) + .with_history_mode(params.history_mode) + .with_initial_window_id(params.initial_window_id); + if let Some(timestamps) = params.metadata.historical_timestamps { + recorder_params = recorder_params + .with_created_at(timestamps.created_at) + .with_updated_at(timestamps.updated_at); + } + RolloutRecorder::new(&config, recorder_params) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to initialize local thread recorder: {err}"), + }) } diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 5939ea606e22..b8e0b1a592fc 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -703,6 +703,7 @@ mod tests { cwd: Some(home.path().to_path_buf()), model_provider: "different-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, + historical_timestamps: None, }, }, ) @@ -757,6 +758,7 @@ mod tests { cwd: Some(home.path().to_path_buf()), model_provider: "different-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, + historical_timestamps: None, }, }, ) @@ -968,6 +970,7 @@ mod tests { cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, + historical_timestamps: None, }, }) .await @@ -1300,6 +1303,7 @@ mod tests { cwd: Some(std::env::current_dir().expect("cwd")), model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, + historical_timestamps: None, } } diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 580df1dc612c..82fabb0defe9 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -221,6 +221,7 @@ async fn apply_metadata_update( message: format!("failed to read thread metadata for {thread_id}: {err}"), })?; let advance_recency_at = patch.advance_recency_at; + let recency_at = patch.recency_at; if existing.is_none() && rollout_path.is_none() { let resolved = resolve_rollout_path(store, thread_id, include_archived).await?; rollout_path_archived = resolved.archived; @@ -274,7 +275,11 @@ async fn apply_metadata_update( if let Some(updated_at) = patch.updated_at { metadata.updated_at = updated_at; } - if existing.is_none() + if let Some(recency_at) = recency_at { + metadata.recency_at = recency_at; + } + if recency_at.is_none() + && existing.is_none() && let Some(recency_at) = advance_recency_at { metadata.recency_at = recency_at; @@ -329,7 +334,25 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to update thread metadata for {thread_id}: {err}"), })?; - if existing.is_some() + if let Some(recency_at) = recency_at { + let replaced = state_db + .set_thread_recency_at(thread_id, recency_at) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!( + "failed to replace thread recency_at for {thread_id}: {err}" + ), + })?; + if !replaced { + return Err(ThreadStoreError::Internal { + message: format!( + "thread metadata disappeared before replacing recency_at for {thread_id}" + ), + }); + } + } + if recency_at.is_none() + && existing.is_some() && let Some(recency_at) = advance_recency_at { state_db @@ -409,6 +432,9 @@ async fn metadata_for_missing_sqlite_row( builder.cwd = patch.cwd.clone().map(normalize_cwd).unwrap_or_default(); builder.cli_version = patch.cli_version.clone(); let mut metadata = builder.build(store.config.default_model_provider_id.as_str()); + if let Some(recency_at) = patch.recency_at { + metadata.recency_at = recency_at; + } if rollout_path_archived { metadata.archived_at = Some(metadata.updated_at); } @@ -486,6 +512,7 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { || patch.model.is_some() || patch.reasoning_effort.is_some() || patch.created_at.is_some() + || patch.recency_at.is_some() || patch.source.is_some() || patch.thread_source.is_some() || patch.agent_nickname.is_some() @@ -1822,6 +1849,7 @@ mod tests { cwd: Some(std::env::current_dir().expect("cwd")), model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, + historical_timestamps: None, } } diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 1057715a4a58..c8f3cb65cd85 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -361,6 +361,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.model.is_some() || update.reasoning_effort.is_some() || update.created_at.is_some() + || update.recency_at.is_some() || update.advance_recency_at.is_some() || update.source.is_some() || update.thread_source.is_some() @@ -569,6 +570,7 @@ mod tests { cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, + historical_timestamps: None, }, } } diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 4e688eca6f1f..af5df48fc4cf 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -58,6 +58,16 @@ pub struct ThreadPersistenceMetadata { pub model_provider: String, /// Memory mode associated with the live thread. pub memory_mode: MemoryMode, + /// Original timestamps to retain when materializing imported history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub historical_timestamps: Option, +} + +/// Original timestamps for a thread imported from another agent. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct HistoricalThreadTimestamps { + pub created_at: DateTime, + pub updated_at: DateTime, } /// Extra configuration fields for a thread. @@ -548,6 +558,8 @@ pub struct ThreadMetadataPatch { pub created_at: Option>, /// Last update timestamp for this metadata observation. pub updated_at: Option>, + /// Replacement product-recency timestamp when historical chronology must be restored. + pub recency_at: Option>, /// Advance product recency to at least this timestamp. pub advance_recency_at: Option>, /// Session source. @@ -632,8 +644,16 @@ impl ThreadMetadataPatch { if next.updated_at.is_some() { self.updated_at = next.updated_at; } - if next.advance_recency_at.is_some() { - self.advance_recency_at = next.advance_recency_at; + match (next.recency_at, next.advance_recency_at) { + (Some(recency_at), _) => { + self.recency_at = Some(recency_at); + self.advance_recency_at = None; + } + (None, Some(advance_recency_at)) => { + self.recency_at = None; + self.advance_recency_at = Some(advance_recency_at); + } + (None, None) => {} } if next.source.is_some() { self.source = next.source; @@ -688,6 +708,7 @@ impl ThreadMetadataPatch { && self.reasoning_effort.is_none() && self.created_at.is_none() && self.updated_at.is_none() + && self.recency_at.is_none() && self.advance_recency_at.is_none() && self.source.is_none() && self.thread_source.is_none() @@ -853,6 +874,35 @@ mod tests { ); } + #[test] + fn thread_metadata_patch_merge_uses_latest_recency_operation() { + let historical_recency = + DateTime::::from_timestamp(1_700_000_000, 0).expect("historical recency"); + let live_recency = DateTime::::from_timestamp(1_800_000_000, 0).expect("live recency"); + let mut current = ThreadMetadataPatch { + advance_recency_at: Some(live_recency), + ..Default::default() + }; + + current.merge(ThreadMetadataPatch { + recency_at: Some(historical_recency), + ..Default::default() + }); + assert_eq!( + (current.recency_at, current.advance_recency_at), + (Some(historical_recency), None) + ); + + current.merge(ThreadMetadataPatch { + advance_recency_at: Some(live_recency), + ..Default::default() + }); + assert_eq!( + (current.recency_at, current.advance_recency_at), + (None, Some(live_recency)) + ); + } + fn session_meta(history_mode: ThreadHistoryMode) -> RolloutItem { RolloutItem::SessionMeta(codex_protocol::protocol::SessionMetaLine { meta: codex_protocol::protocol::SessionMeta { diff --git a/codex-rs/tui/src/external_agent_config_migration_flow.rs b/codex-rs/tui/src/external_agent_config_migration_flow.rs index c1cef68dfd9d..3d1dadf0bc63 100644 --- a/codex-rs/tui/src/external_agent_config_migration_flow.rs +++ b/codex-rs/tui/src/external_agent_config_migration_flow.rs @@ -216,6 +216,7 @@ pub(crate) async fn handle_external_agent_config_migration_prompt( .external_agent_config_detect(ExternalAgentConfigDetectParams { include_home: true, cwds: Some(vec![cwd.clone()]), + source: None, }) .await {