Preserve timestamps when importing external agent sessions (#34989)

## Why

Imported sessions previously used the import time for their thread metadata,
discarding the chronology recorded by the source session.

## What changed

- Set `created_at` and `updated_at` from the earliest turn start and latest turn
  completion timestamps in the imported rollout.
- Use the latest source timestamp for thread recency, while retaining the current
  time as a fallback when the rollout has no valid turn timestamps.

## Testing

- Update the external agent import integration test to verify the persisted
  creation, update, and recency timestamps.

GitOrigin-RevId: 396d5b7e7463a305a5a828ba3a432b4a97e5d2b9
This commit is contained in:
stefanstokic-oai
2026-07-23 18:34:51 +00:00
committed by copyberry
parent 5c94796dc9
commit 0d4910331d
2 changed files with 41 additions and 7 deletions
@@ -1,6 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use chrono::DateTime;
use chrono::Utc;
use codex_arg0::Arg0DispatchPaths;
use codex_core::ThreadManager;
@@ -19,7 +20,9 @@ use codex_external_agent_migration::sessions::record_completed_session_imports;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_rollout::is_persisted_rollout_item;
@@ -330,6 +333,28 @@ impl ExternalAgentSessionImporter {
},
};
rollout_items.retain(|item| is_persisted_rollout_item(item, ThreadHistoryMode::Legacy));
let (created_at, updated_at) = rollout_items
.iter()
.filter_map(|item| match item {
RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => event.started_at,
RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => event.completed_at,
_ => None,
})
.fold(None, |chronology: Option<(i64, i64)>, timestamp| {
Some(match chronology {
Some((created_at, updated_at)) => {
(created_at.min(timestamp), updated_at.max(timestamp))
}
None => (timestamp, timestamp),
})
})
.and_then(|(created_at, updated_at)| {
Some((
DateTime::from_timestamp(created_at, /*nsecs*/ 0)?,
DateTime::from_timestamp(updated_at, /*nsecs*/ 0)?,
))
})
.unwrap_or((now, now));
let title = title
.as_deref()
.and_then(codex_core::util::normalize_thread_name);
@@ -337,8 +362,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),
advance_recency_at: Some(updated_at),
source: Some(source.clone()),
thread_source: Some(None),
agent_nickname: Some(source.get_nickname()),
@@ -1513,7 +1513,12 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
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_text = "2024-01-02T03:04:05Z";
let source_updated_at_text = "2024-03-01T04:05:06Z";
let source_created_at =
chrono::DateTime::parse_from_rfc3339(source_created_at_text)?.timestamp();
let source_updated_at =
chrono::DateTime::parse_from_rfc3339(source_updated_at_text)?.timestamp();
let session_dir = external_agent_home(codex_home.path()).join("projects/repo");
let session_path = session_dir.join("session.jsonl");
let manifest_dir = connector_metadata_root(codex_home.path())
@@ -1540,21 +1545,21 @@ 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_text,
"message": { "content": control_request },
})
.to_string(),
serde_json::json!({
"type": "user",
"cwd": &project_root,
"timestamp": &recent_timestamp,
"timestamp": "2024-01-03T00:00:00Z",
"message": { "content": first_request },
})
.to_string(),
serde_json::json!({
"type": "assistant",
"cwd": &project_root,
"timestamp": &recent_timestamp,
"timestamp": source_updated_at_text,
"attributionMcpServer": "gmail-server",
"message": { "content": "first answer" },
})
@@ -1659,7 +1664,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
archived: None,
is_pinned: None,
cwd: None,
use_state_db_only: false,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
@@ -1675,6 +1680,9 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
assert_eq!(imported_thread_id, thread.id.to_string());
assert_eq!(thread.preview, control_request);
assert_eq!(thread.name.as_deref(), Some("Fix auth flow"));
assert_eq!(thread.created_at, source_created_at);
assert_eq!(thread.updated_at, source_updated_at);
assert_eq!(thread.recency_at, Some(source_updated_at));
let request_id = mcp
.send_thread_read_request(ThreadReadParams {