Import only the continuation of backgrounded Claude sessions (#55365)

## Why

Backgrounding a Claude session creates a continuation that includes its history. Offering both sessions for import duplicates that history.

## What changed

Skip sessions containing a `continued-in` record with a nonblank `continuedInSessionId` during import detection, leaving the continuation available for import.

## Testing

Add detection and app-server regression tests covering continuation-only detection, importing the full history into one thread, and no remaining import candidates afterward.

GitOrigin-RevId: bf8383faadce984ae3c41a0b8e8b3072680fd4b2
This commit is contained in:
jgrodman-arch
2026-10-19 19:53:58 +00:00
committed by copyberry
parent 18d09e1eda
commit ee8b4ea181
3 changed files with 74 additions and 0 deletions
@@ -246,6 +246,31 @@ fn assert_history(response: &ThreadReadResponse, expected: &[(&str, &str)]) {
assert_eq!(markers, 1, "the import marker must appear exactly once");
}
#[tokio::test]
async fn backgrounded_session_imports_only_its_continuation() -> Result<()> {
let mut fixture = ImportFixture::new().await?;
let continuation = fixture.session_path.with_file_name("background.jsonl");
std::fs::copy(&fixture.session_path, &continuation)?;
fixture.append_to(&continuation, &[("assistant", LATE_ASSISTANT)])?;
fixture.append_raw(&format!(
"{}\n",
json!({
"type": "continued-in",
"sessionId": "session",
"continuedInSessionId": "background",
})
))?;
let imported = fixture.import_one().await?;
assert_eq!(fixture.thread_count().await?, 1);
assert_history(
&fixture.read(&imported).await?,
&[("user", FIRST_USER), ("assistant", LATE_ASSISTANT)],
);
assert!(fixture.detect().await?.items.is_empty());
Ok(())
}
#[tokio::test]
async fn cold_exact_prefix_appends_suffix_to_same_task_and_checkpoints() -> Result<()> {
let mut fixture = ImportFixture::new().await?;
@@ -65,6 +65,7 @@ mod tests {
use super::*;
use crate::sessions::ledger::record_imported_session;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::Value as JsonValue;
use std::fs::FileTimes;
use std::fs::OpenOptions;
@@ -101,6 +102,45 @@ mod tests {
);
}
#[test]
fn detects_only_the_continuation_of_a_backgrounded_session() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let project_root = root.path().join("repo");
let original = record("user", "plan a vacation", &project_root);
write_session(
&external_agent_home,
&project_root,
"original.jsonl",
&[
original.clone(),
serde_json::json!({
"type": "continued-in",
"sessionId": "original",
"continuedInSessionId": "background",
}),
],
);
let continuation = write_session(
&external_agent_home,
&project_root,
"background.jsonl",
&[
original,
record("assistant", "Here is your itinerary", &project_root),
],
);
assert_eq!(
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect"),
vec![ExternalAgentSessionMigration {
path: continuation,
cwd: project_root,
title: Some("plan a vacation".to_string()),
}]
);
}
#[test]
fn prefers_latest_custom_title_over_first_user_message() {
let root = TempDir::new().expect("tempdir");
@@ -40,6 +40,15 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
let Ok(mut record) = serde_json::from_str::<JsonValue>(trimmed) else {
continue;
};
// Backgrounding replaces this session with a continuation that includes its history.
if record.get("type").and_then(JsonValue::as_str) == Some("continued-in")
&& record
.get("continuedInSessionId")
.and_then(JsonValue::as_str)
.is_some_and(|id| !id.trim().is_empty())
{
return Ok(None);
}
if cwd.is_none() {
cwd = record
.get("cwd")