mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Import external agent sessions in background (#20284)
Summary: - Return from external agent import before session history import finishes - Run session import work in the background and emit the existing completion notification when it is done - Serialize session imports so duplicate requests do not create duplicate imported threads Verification: - cargo test -p codex-app-server external_agent_config_ - cargo test -p codex-external-agent-sessions - just fix -p codex-app-server - just fix -p codex-external-agent-sessions - git diff --check
This commit is contained in:
@@ -220,8 +220,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`.
|
- `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.
|
- `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.
|
- `config/read` — fetch the effective config on disk after resolving config layering.
|
||||||
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin migration items may additionally include structured `details` grouping plugin ids under each detected marketplace name.
|
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata.
|
||||||
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect. When a request includes plugin imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background remote imports finish).
|
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes plugin or session imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
|
||||||
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
|
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
|
||||||
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
|
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
|
||||||
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
|
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
|
||||||
|
|||||||
@@ -518,6 +518,7 @@ impl Drop for ActiveLogin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handles JSON-RPC messages for Codex threads (and legacy conversation APIs).
|
/// Handles JSON-RPC messages for Codex threads (and legacy conversation APIs).
|
||||||
|
#[derive(Clone)]
|
||||||
pub(crate) struct CodexMessageProcessor {
|
pub(crate) struct CodexMessageProcessor {
|
||||||
auth_manager: Arc<AuthManager>,
|
auth_manager: Arc<AuthManager>,
|
||||||
thread_manager: Arc<ThreadManager>,
|
thread_manager: Arc<ThreadManager>,
|
||||||
|
|||||||
@@ -144,8 +144,24 @@ impl ExternalAgentConfigService {
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn detect_recent_sessions(&self) -> io::Result<Vec<ExternalAgentSessionMigration>> {
|
pub(crate) fn external_agent_session_source_path(
|
||||||
detect_recent_sessions(&self.external_agent_home, &self.codex_home)
|
&self,
|
||||||
|
path: &Path,
|
||||||
|
) -> io::Result<Option<PathBuf>> {
|
||||||
|
if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let path = match fs::canonicalize(path) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
let projects_root = match fs::canonicalize(self.external_agent_home.join("projects")) {
|
||||||
|
Ok(projects_root) => projects_root,
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
Ok(path.starts_with(projects_root).then_some(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn import(
|
pub(crate) async fn import(
|
||||||
|
|||||||
@@ -20,16 +20,19 @@ use codex_app_server_protocol::PluginsMigration;
|
|||||||
use codex_app_server_protocol::SubagentMigration;
|
use codex_app_server_protocol::SubagentMigration;
|
||||||
use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration;
|
use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration;
|
||||||
use codex_external_agent_sessions::PendingSessionImport;
|
use codex_external_agent_sessions::PendingSessionImport;
|
||||||
use codex_external_agent_sessions::PrepareSessionImportsError;
|
use codex_external_agent_sessions::prepare_validated_session_imports;
|
||||||
use codex_external_agent_sessions::prepare_pending_session_imports;
|
|
||||||
use codex_external_agent_sessions::record_imported_session;
|
use codex_external_agent_sessions::record_imported_session;
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct ExternalAgentConfigApi {
|
pub(crate) struct ExternalAgentConfigApi {
|
||||||
codex_home: PathBuf,
|
codex_home: PathBuf,
|
||||||
migration_service: ExternalAgentConfigService,
|
migration_service: ExternalAgentConfigService,
|
||||||
|
session_import_permits: Arc<Semaphore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExternalAgentConfigApi {
|
impl ExternalAgentConfigApi {
|
||||||
@@ -37,6 +40,7 @@ impl ExternalAgentConfigApi {
|
|||||||
Self {
|
Self {
|
||||||
migration_service: ExternalAgentConfigService::new(codex_home.clone()),
|
migration_service: ExternalAgentConfigService::new(codex_home.clone()),
|
||||||
codex_home,
|
codex_home,
|
||||||
|
session_import_permits: Arc::new(Semaphore::new(1)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,18 +138,10 @@ impl ExternalAgentConfigApi {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn detect_recent_sessions(
|
pub(crate) fn validate_pending_session_imports(
|
||||||
&self,
|
|
||||||
) -> Result<Vec<CoreSessionMigration>, JSONRPCErrorError> {
|
|
||||||
self.migration_service
|
|
||||||
.detect_recent_sessions()
|
|
||||||
.map_err(|err| internal_error(err.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn prepare_pending_session_imports(
|
|
||||||
&self,
|
&self,
|
||||||
params: &ExternalAgentConfigImportParams,
|
params: &ExternalAgentConfigImportParams,
|
||||||
) -> Result<Vec<PendingSessionImport>, JSONRPCErrorError> {
|
) -> Result<Vec<CoreSessionMigration>, JSONRPCErrorError> {
|
||||||
let sessions = params
|
let sessions = params
|
||||||
.migration_items
|
.migration_items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -163,18 +159,32 @@ impl ExternalAgentConfigApi {
|
|||||||
title: session.title,
|
title: session.title,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let detected_sessions = if sessions.is_empty() {
|
let mut selected_session_paths = HashSet::new();
|
||||||
Vec::new()
|
let mut selected_sessions = Vec::new();
|
||||||
} else {
|
for session in sessions {
|
||||||
self.detect_recent_sessions()?
|
let Some(canonical_path) = self
|
||||||
};
|
.migration_service
|
||||||
prepare_pending_session_imports(&self.codex_home, sessions, detected_sessions).map_err(
|
.external_agent_session_source_path(&session.path)
|
||||||
|err| match err {
|
.map_err(|err| internal_error(err.to_string()))?
|
||||||
PrepareSessionImportsError::SessionNotDetected(_) => {
|
else {
|
||||||
invalid_params(err.to_string())
|
return Err(session_not_detected_error(&session.path));
|
||||||
}
|
};
|
||||||
},
|
if selected_session_paths.insert(canonical_path) {
|
||||||
)
|
selected_sessions.push(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(selected_sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prepare_validated_session_imports(
|
||||||
|
&self,
|
||||||
|
sessions: Vec<CoreSessionMigration>,
|
||||||
|
) -> Vec<PendingSessionImport> {
|
||||||
|
prepare_validated_session_imports(&self.codex_home, sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn session_import_permits(&self) -> Arc<Semaphore> {
|
||||||
|
Arc::clone(&self.session_import_permits)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn record_imported_session(
|
pub(crate) fn record_imported_session(
|
||||||
@@ -301,3 +311,10 @@ impl ExternalAgentConfigApi {
|
|||||||
.map_err(|err| internal_error(err.to_string()))
|
.map_err(|err| internal_error(err.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn session_not_detected_error(path: &std::path::Path) -> JSONRPCErrorError {
|
||||||
|
invalid_params(format!(
|
||||||
|
"external agent session was not detected for import: {}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1224,30 +1224,28 @@ impl MessageProcessor {
|
|||||||
ExternalAgentConfigMigrationItemType::Plugins
|
ExternalAgentConfigMigrationItemType::Plugins
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
let has_session_imports = params.migration_items.iter().any(|item| {
|
||||||
|
matches!(
|
||||||
|
item.item_type,
|
||||||
|
ExternalAgentConfigMigrationItemType::Sessions
|
||||||
|
)
|
||||||
|
});
|
||||||
let pending_session_imports = self
|
let pending_session_imports = self
|
||||||
.external_agent_config_api
|
.external_agent_config_api
|
||||||
.prepare_pending_session_imports(¶ms)?;
|
.validate_pending_session_imports(¶ms)?;
|
||||||
let pending_plugin_imports = self.external_agent_config_api.import(params).await?;
|
let pending_plugin_imports = self.external_agent_config_api.import(params).await?;
|
||||||
if needs_runtime_refresh {
|
if needs_runtime_refresh {
|
||||||
self.handle_config_mutation().await;
|
self.handle_config_mutation().await;
|
||||||
}
|
}
|
||||||
for pending_session_import in pending_session_imports {
|
|
||||||
let imported_thread_id = self
|
|
||||||
.codex_message_processor
|
|
||||||
.import_external_agent_session(pending_session_import.session)
|
|
||||||
.await?;
|
|
||||||
self.external_agent_config_api
|
|
||||||
.record_imported_session(&pending_session_import.source_path, imported_thread_id);
|
|
||||||
}
|
|
||||||
self.outgoing
|
self.outgoing
|
||||||
.send_response(request_id, ExternalAgentConfigImportResponse {})
|
.send_response(request_id, ExternalAgentConfigImportResponse {})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if !has_plugin_imports {
|
if !has_plugin_imports && !has_session_imports {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if pending_plugin_imports.is_empty() {
|
if pending_plugin_imports.is_empty() && pending_session_imports.is_empty() {
|
||||||
self.outgoing
|
self.outgoing
|
||||||
.send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted(
|
.send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted(
|
||||||
ExternalAgentConfigImportCompletedNotification {},
|
ExternalAgentConfigImportCompletedNotification {},
|
||||||
@@ -1257,25 +1255,64 @@ impl MessageProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let external_agent_config_api = self.external_agent_config_api.clone();
|
let external_agent_config_api = self.external_agent_config_api.clone();
|
||||||
|
let session_import_permits = external_agent_config_api.session_import_permits();
|
||||||
|
let codex_message_processor = self.codex_message_processor.clone();
|
||||||
let outgoing = Arc::clone(&self.outgoing);
|
let outgoing = Arc::clone(&self.outgoing);
|
||||||
let thread_manager = Arc::clone(&self.thread_manager);
|
let thread_manager = Arc::clone(&self.thread_manager);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
for pending_plugin_import in pending_plugin_imports {
|
let session_external_agent_config_api = external_agent_config_api.clone();
|
||||||
match external_agent_config_api
|
let plugin_external_agent_config_api = external_agent_config_api;
|
||||||
.complete_pending_plugin_import(pending_plugin_import)
|
let session_imports = async move {
|
||||||
.await
|
if !pending_session_imports.is_empty() {
|
||||||
{
|
let Ok(_session_import_permit) = session_import_permits.acquire_owned().await
|
||||||
Ok(()) => {}
|
else {
|
||||||
Err(error) => {
|
return;
|
||||||
tracing::warn!(
|
};
|
||||||
error = %error.message,
|
let pending_session_imports = session_external_agent_config_api
|
||||||
"external agent config plugin import failed"
|
.prepare_validated_session_imports(pending_session_imports);
|
||||||
);
|
for pending_session_import in pending_session_imports {
|
||||||
|
match codex_message_processor
|
||||||
|
.import_external_agent_session(pending_session_import.session)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(imported_thread_id) => {
|
||||||
|
session_external_agent_config_api.record_imported_session(
|
||||||
|
&pending_session_import.source_path,
|
||||||
|
imported_thread_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %error.message,
|
||||||
|
path = %pending_session_import.source_path.display(),
|
||||||
|
"external agent session import failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
let plugin_imports = async move {
|
||||||
|
for pending_plugin_import in pending_plugin_imports {
|
||||||
|
match plugin_external_agent_config_api
|
||||||
|
.complete_pending_plugin_import(pending_plugin_import)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %error.message,
|
||||||
|
"external agent config plugin import failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::join!(session_imports, plugin_imports);
|
||||||
|
if has_plugin_imports {
|
||||||
|
thread_manager.plugins_manager().clear_cache();
|
||||||
|
thread_manager.skills_manager().clear_cache();
|
||||||
}
|
}
|
||||||
thread_manager.plugins_manager().clear_cache();
|
|
||||||
thread_manager.skills_manager().clear_cache();
|
|
||||||
outgoing
|
outgoing
|
||||||
.send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted(
|
.send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted(
|
||||||
ExternalAgentConfigImportCompletedNotification {},
|
ExternalAgentConfigImportCompletedNotification {},
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ use core_test_support::responses;
|
|||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
@@ -271,6 +273,12 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
|
|||||||
.await??;
|
.await??;
|
||||||
let response: ExternalAgentConfigImportResponse = to_response(response)?;
|
let response: ExternalAgentConfigImportResponse = to_response(response)?;
|
||||||
assert_eq!(response, ExternalAgentConfigImportResponse {});
|
assert_eq!(response, ExternalAgentConfigImportResponse {});
|
||||||
|
let notification = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
assert_eq!(notification.method, "externalAgentConfig/import/completed");
|
||||||
|
|
||||||
let request_id = mcp
|
let request_id = mcp
|
||||||
.send_thread_list_request(ThreadListParams {
|
.send_thread_list_request(ThreadListParams {
|
||||||
@@ -380,6 +388,92 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn external_agent_config_import_accepts_detected_session_payload_after_restart() -> Result<()>
|
||||||
|
{
|
||||||
|
let server = create_mock_responses_server_repeating_assistant("unused").await;
|
||||||
|
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 session_dir = codex_home.path().join(".claude/projects/repo");
|
||||||
|
let session_path = session_dir.join("session.jsonl");
|
||||||
|
std::fs::create_dir_all(&project_root)?;
|
||||||
|
std::fs::create_dir_all(&session_dir)?;
|
||||||
|
std::fs::write(
|
||||||
|
&session_path,
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "user",
|
||||||
|
"cwd": &project_root,
|
||||||
|
"timestamp": &recent_timestamp,
|
||||||
|
"message": { "content": "first request" },
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let home_dir = codex_home.path().display().to_string();
|
||||||
|
let mut mcp =
|
||||||
|
McpProcess::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/import",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"migrationItems": [{
|
||||||
|
"itemType": "SESSIONS",
|
||||||
|
"description": "Migrate recent sessions",
|
||||||
|
"cwd": null,
|
||||||
|
"details": {
|
||||||
|
"sessions": [{
|
||||||
|
"path": session_path,
|
||||||
|
"cwd": project_root,
|
||||||
|
"title": "first request"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let response: ExternalAgentConfigImportResponse = to_response(response)?;
|
||||||
|
assert_eq!(response, ExternalAgentConfigImportResponse {});
|
||||||
|
let notification = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
assert_eq!(notification.method, "externalAgentConfig/import/completed");
|
||||||
|
|
||||||
|
let request_id = mcp
|
||||||
|
.send_thread_list_request(ThreadListParams {
|
||||||
|
cursor: None,
|
||||||
|
limit: None,
|
||||||
|
sort_key: None,
|
||||||
|
sort_direction: None,
|
||||||
|
model_providers: None,
|
||||||
|
source_kinds: None,
|
||||||
|
archived: None,
|
||||||
|
cwd: None,
|
||||||
|
use_state_db_only: false,
|
||||||
|
search_term: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let response: ThreadListResponse = to_response(response)?;
|
||||||
|
assert_eq!(response.data.len(), 1);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn external_agent_config_import_skips_already_imported_session_versions() -> Result<()> {
|
async fn external_agent_config_import_skips_already_imported_session_versions() -> Result<()> {
|
||||||
let server = create_mock_responses_server_repeating_assistant("unused").await;
|
let server = create_mock_responses_server_repeating_assistant("unused").await;
|
||||||
@@ -433,6 +527,12 @@ async fn external_agent_config_import_skips_already_imported_session_versions()
|
|||||||
)
|
)
|
||||||
.await??;
|
.await??;
|
||||||
let _: ExternalAgentConfigImportResponse = to_response(response)?;
|
let _: ExternalAgentConfigImportResponse = to_response(response)?;
|
||||||
|
let notification = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
assert_eq!(notification.method, "externalAgentConfig/import/completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_id = mcp
|
let request_id = mcp
|
||||||
@@ -460,6 +560,144 @@ async fn external_agent_config_import_skips_already_imported_session_versions()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn external_agent_config_import_returns_before_background_session_import_finishes()
|
||||||
|
-> Result<()> {
|
||||||
|
let server = create_mock_responses_server_repeating_assistant("unused").await;
|
||||||
|
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 session_dir = codex_home.path().join(".claude/projects/repo");
|
||||||
|
let session_path = session_dir.join("session.jsonl");
|
||||||
|
std::fs::create_dir_all(&project_root)?;
|
||||||
|
std::fs::create_dir_all(&session_dir)?;
|
||||||
|
std::fs::write(
|
||||||
|
&session_path,
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "user",
|
||||||
|
"cwd": &project_root,
|
||||||
|
"timestamp": &recent_timestamp,
|
||||||
|
"message": { "content": "first request" },
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let project_config_dir = project_root.join(".codex");
|
||||||
|
std::fs::create_dir_all(&project_config_dir)?;
|
||||||
|
let project_config = project_config_dir.join("config.toml");
|
||||||
|
let status = std::process::Command::new("mkfifo")
|
||||||
|
.arg(&project_config)
|
||||||
|
.status()?;
|
||||||
|
assert!(status.success());
|
||||||
|
|
||||||
|
let home_dir = codex_home.path().display().to_string();
|
||||||
|
let mut mcp =
|
||||||
|
McpProcess::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": true })),
|
||||||
|
)
|
||||||
|
.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.len(), 1);
|
||||||
|
let detected_items = detected.items;
|
||||||
|
|
||||||
|
let request_id = mcp
|
||||||
|
.send_raw_request(
|
||||||
|
"externalAgentConfig/import",
|
||||||
|
Some(serde_json::json!({ "migrationItems": detected_items.clone() })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let response: ExternalAgentConfigImportResponse = to_response(response)?;
|
||||||
|
assert_eq!(response, ExternalAgentConfigImportResponse {});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
timeout(
|
||||||
|
Duration::from_millis(200),
|
||||||
|
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed")
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"session import completed before the blocked background import was unblocked"
|
||||||
|
);
|
||||||
|
|
||||||
|
let duplicate_request_id = mcp
|
||||||
|
.send_raw_request(
|
||||||
|
"externalAgentConfig/import",
|
||||||
|
Some(serde_json::json!({ "migrationItems": detected_items })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(duplicate_request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let response: ExternalAgentConfigImportResponse = to_response(response)?;
|
||||||
|
assert_eq!(response, ExternalAgentConfigImportResponse {});
|
||||||
|
|
||||||
|
let writer = tokio::spawn(async move {
|
||||||
|
let mut file = tokio::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open(&project_config)
|
||||||
|
.await?;
|
||||||
|
file.write_all(b"\n").await
|
||||||
|
});
|
||||||
|
timeout(DEFAULT_TIMEOUT, writer).await???;
|
||||||
|
|
||||||
|
let notification = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
assert_eq!(notification.method, "externalAgentConfig/import/completed");
|
||||||
|
|
||||||
|
let notification = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
assert_eq!(notification.method, "externalAgentConfig/import/completed");
|
||||||
|
|
||||||
|
let request_id = mcp
|
||||||
|
.send_thread_list_request(ThreadListParams {
|
||||||
|
cursor: None,
|
||||||
|
limit: None,
|
||||||
|
sort_key: None,
|
||||||
|
sort_direction: None,
|
||||||
|
model_providers: None,
|
||||||
|
source_kinds: None,
|
||||||
|
archived: None,
|
||||||
|
cwd: None,
|
||||||
|
use_state_db_only: false,
|
||||||
|
search_term: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let response: ThreadListResponse = to_response(response)?;
|
||||||
|
assert_eq!(response.data.len(), 1);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn external_agent_config_import_rejects_undetected_session_paths() -> Result<()> {
|
async fn external_agent_config_import_rejects_undetected_session_paths() -> Result<()> {
|
||||||
let server = create_mock_responses_server_repeating_assistant("unused").await;
|
let server = create_mock_responses_server_repeating_assistant("unused").await;
|
||||||
@@ -635,6 +873,12 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_
|
|||||||
)
|
)
|
||||||
.await??;
|
.await??;
|
||||||
let _: ExternalAgentConfigImportResponse = to_response(response)?;
|
let _: ExternalAgentConfigImportResponse = to_response(response)?;
|
||||||
|
let notification = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
assert_eq!(notification.method, "externalAgentConfig/import/completed");
|
||||||
|
|
||||||
let request_id = mcp
|
let request_id = mcp
|
||||||
.send_thread_list_request(ThreadListParams {
|
.send_thread_list_request(ThreadListParams {
|
||||||
|
|||||||
@@ -94,6 +94,37 @@ pub fn prepare_pending_session_imports(
|
|||||||
Ok(pending_session_imports)
|
Ok(pending_session_imports)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn prepare_validated_session_imports(
|
||||||
|
codex_home: &Path,
|
||||||
|
requested_sessions: Vec<ExternalAgentSessionMigration>,
|
||||||
|
) -> Vec<PendingSessionImport> {
|
||||||
|
requested_sessions
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|session| pending_session_import(codex_home, session))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_session_import(
|
||||||
|
codex_home: &Path,
|
||||||
|
session: ExternalAgentSessionMigration,
|
||||||
|
) -> Option<PendingSessionImport> {
|
||||||
|
let has_been_imported = match has_current_session_been_imported(codex_home, &session.path) {
|
||||||
|
Ok(has_been_imported) => has_been_imported,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
if has_been_imported {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let imported_session = match load_importable_session(&session.path) {
|
||||||
|
Ok(Some(imported_session)) => imported_session,
|
||||||
|
Ok(None) | Err(_) => return None,
|
||||||
|
};
|
||||||
|
Some(PendingSessionImport {
|
||||||
|
source_path: session.path,
|
||||||
|
session: imported_session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn load_importable_session(path: &Path) -> io::Result<Option<ImportedExternalAgentSession>> {
|
fn load_importable_session(path: &Path) -> io::Result<Option<ImportedExternalAgentSession>> {
|
||||||
let Some(imported_session) = load_session_for_import(path)? else {
|
let Some(imported_session) = load_session_for_import(path)? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
const NOTE_MAX_LEN: usize = 2_000;
|
const NOTE_MAX_LEN: usize = 2_000;
|
||||||
const TOOL_RESULT_MAX_LEN: usize = 4_000;
|
const TOOL_RESULT_MAX_LEN: usize = 4_000;
|
||||||
|
const EXTERNAL_AGENT_TOOL_CALL_TAG: &str = "external_agent_tool_call";
|
||||||
|
const EXTERNAL_AGENT_TOOL_RESULT_TAG: &str = "external_agent_tool_result";
|
||||||
|
|
||||||
pub struct SessionSummary {
|
pub struct SessionSummary {
|
||||||
pub latest_timestamp: i64,
|
pub latest_timestamp: i64,
|
||||||
@@ -252,7 +254,7 @@ fn tool_call_note(block: &JsonValue) -> String {
|
|||||||
.get("name")
|
.get("name")
|
||||||
.and_then(JsonValue::as_str)
|
.and_then(JsonValue::as_str)
|
||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown");
|
||||||
let mut lines = vec![format!("[external tool call: {name}]")];
|
let mut lines = vec![format!("[{EXTERNAL_AGENT_TOOL_CALL_TAG}: {name}]")];
|
||||||
if let Some(input) = block.get("input").and_then(JsonValue::as_object) {
|
if let Some(input) = block.get("input").and_then(JsonValue::as_object) {
|
||||||
if let Some(description) = input.get("description").and_then(JsonValue::as_str) {
|
if let Some(description) = input.get("description").and_then(JsonValue::as_str) {
|
||||||
lines.push(format!("description: {description}"));
|
lines.push(format!("description: {description}"));
|
||||||
@@ -279,20 +281,24 @@ fn tool_call_note(block: &JsonValue) -> String {
|
|||||||
truncate(&input.to_string(), NOTE_MAX_LEN)
|
truncate(&input.to_string(), NOTE_MAX_LEN)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
lines.push(format!("[/{EXTERNAL_AGENT_TOOL_CALL_TAG}]"));
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tool_result_note(block: &JsonValue) -> String {
|
fn tool_result_note(block: &JsonValue) -> String {
|
||||||
let label = if block.get("is_error").and_then(JsonValue::as_bool) == Some(true) {
|
let label = if block.get("is_error").and_then(JsonValue::as_bool) == Some(true) {
|
||||||
"[external tool result: error]"
|
format!("[{EXTERNAL_AGENT_TOOL_RESULT_TAG}: error]")
|
||||||
} else {
|
} else {
|
||||||
"[external tool result]"
|
format!("[{EXTERNAL_AGENT_TOOL_RESULT_TAG}]")
|
||||||
};
|
};
|
||||||
let text = tool_result_text(block.get("content"));
|
let text = tool_result_text(block.get("content"));
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
label.to_string()
|
format!("{label}\n[/{EXTERNAL_AGENT_TOOL_RESULT_TAG}]")
|
||||||
} else {
|
} else {
|
||||||
format!("{label}\n{}", truncate(&text, TOOL_RESULT_MAX_LEN))
|
format!(
|
||||||
|
"{label}\n{}\n[/{EXTERNAL_AGENT_TOOL_RESULT_TAG}]",
|
||||||
|
truncate(&text, TOOL_RESULT_MAX_LEN)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,3 +320,59 @@ fn parse_timestamp(timestamp: &str) -> Option<i64> {
|
|||||||
.ok()
|
.ok()
|
||||||
.map(|value| value.timestamp())
|
.map(|value| value.timestamp())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_tool_use_blocks_to_bounded_external_agent_tags() {
|
||||||
|
let block = serde_json::json!({
|
||||||
|
"type": "tool_use",
|
||||||
|
"name": "Bash",
|
||||||
|
"input": {
|
||||||
|
"description": "Check repo status",
|
||||||
|
"command": "git status --short"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tool_call_note(&block),
|
||||||
|
"[external_agent_tool_call: Bash]\n\
|
||||||
|
description: Check repo status\n\
|
||||||
|
command: git status --short\n\
|
||||||
|
[/external_agent_tool_call]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_tool_result_blocks_to_bounded_external_agent_tags() {
|
||||||
|
let block = serde_json::json!({
|
||||||
|
"type": "tool_result",
|
||||||
|
"content": "codex-rs/external-agent-sessions/src/records.rs"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tool_result_note(&block),
|
||||||
|
"[external_agent_tool_result]\n\
|
||||||
|
codex-rs/external-agent-sessions/src/records.rs\n\
|
||||||
|
[/external_agent_tool_result]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_error_tool_result_blocks_to_bounded_external_agent_tags() {
|
||||||
|
let block = serde_json::json!({
|
||||||
|
"type": "tool_result",
|
||||||
|
"is_error": true,
|
||||||
|
"content": "command failed"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tool_result_note(&block),
|
||||||
|
"[external_agent_tool_result: error]\n\
|
||||||
|
command failed\n\
|
||||||
|
[/external_agent_tool_result]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user