From f3bc804d3aa73e8fd1ad7d7509b7d5418ecc0020 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Sat, 19 Sep 2026 21:54:57 -0400 Subject: [PATCH] fix(session): synchronize the engine after foreign session import /resume and inline-JSON imports install a new conversation without a SyncSession action, leaving the engine on the previous history; the next turn then runs against the wrong transcript. Carry the existing sync payload on ResumeImportReceipt (same shape as the /branch outcome) and map it to the SyncSession action in both import arms. Also adds the missing unit test for rebranch_active_messages_stamped: the shared prefix keeps ids and stamps, the new suffix keeps live stamps, and the replaced tail survives as a sibling branch. Follow-up to #6367 (branch side closed by ffca963f9). --- crates/command-contract/src/facets.rs | 4 +- crates/command-contract/src/tests.rs | 1 + crates/tui/src/commands/contract.rs | 8 ++++ .../tui/src/commands/groups/session/resume.rs | 42 +++++++++++++++---- crates/tui/src/session_tree.rs | 38 +++++++++++++++++ 5 files changed, 84 insertions(+), 9 deletions(-) diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index 70d3e43ce8..1f46f511a4 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -1232,12 +1232,14 @@ pub enum ResumeSource { /// Portable `/resume` import receipt. The handler renders /// `Imported foreign session as {truncated_id} ({entry_count} entries, leaf -/// {leaf_display})`. +/// {leaf_display})`, and carries `sync` so the engine adopts the imported +/// conversation instead of staying on the previous one. #[derive(Clone, Debug, PartialEq)] pub struct ResumeImportReceipt { pub truncated_id: String, pub entry_count: usize, pub leaf_display: String, + pub sync: SessionSyncPayload, } /// `/rename` success receipt; the title is the sanitized persisted value so diff --git a/crates/command-contract/src/tests.rs b/crates/command-contract/src/tests.rs index 66cb4c279b..3f1b3bb8b9 100644 --- a/crates/command-contract/src/tests.rs +++ b/crates/command-contract/src/tests.rs @@ -2297,6 +2297,7 @@ fn control_facet_is_object_safe_and_transports_every_outcome() { truncated_id: "imp-9".to_string(), entry_count: 12, leaf_display: "leaf-3".to_string(), + sync: lifecycle_sync_payload(Some("imp-9")), })), sanitized_title: Some("Renamed".to_string()), rename: Some(Ok(SessionTitleReceipt { diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index 460c183b52..d30478163f 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -1443,6 +1443,14 @@ fn import_session_container( .map(|journal| journal.entries.len()) .unwrap_or(0), leaf_display: imported.leaf_id.as_deref().unwrap_or("(none)").to_string(), + sync: SessionSyncPayload { + session_id: Some(new_id.clone()), + messages: app.api_messages.as_ref().clone(), + system_prompt: app.system_prompt.clone(), + model: app.model.clone(), + workspace: app.workspace.clone(), + mode: to_command_mode(app.mode), + }, }) } diff --git a/crates/tui/src/commands/groups/session/resume.rs b/crates/tui/src/commands/groups/session/resume.rs index 0e039cd061..9d00f65f7c 100644 --- a/crates/tui/src/commands/groups/session/resume.rs +++ b/crates/tui/src/commands/groups/session/resume.rs @@ -59,16 +59,22 @@ pub(in crate::commands) fn resume_portable( }; match control.resolve_resume_source(raw) { Ok(ResumeSource::File(path)) => match control.import_session_file(path) { - Ok(receipt) => CommandResult::message(format!( - "Imported foreign session as {} ({} entries, leaf {})", - receipt.truncated_id, receipt.entry_count, receipt.leaf_display - )), + Ok(receipt) => CommandResult::with_message_and_action( + format!( + "Imported foreign session as {} ({} entries, leaf {})", + receipt.truncated_id, receipt.entry_count, receipt.leaf_display + ), + super::sync_session_action(receipt.sync), + ), Err(error) => CommandResult::error(error), }, - Ok(ResumeSource::Imported(receipt)) => CommandResult::message(format!( - "Imported foreign session as {} ({} entries, leaf {})", - receipt.truncated_id, receipt.entry_count, receipt.leaf_display - )), + Ok(ResumeSource::Imported(receipt)) => CommandResult::with_message_and_action( + format!( + "Imported foreign session as {} ({} entries, leaf {})", + receipt.truncated_id, receipt.entry_count, receipt.leaf_display + ), + super::sync_session_action(receipt.sync), + ), Ok(ResumeSource::Session { load_path, truncated_id, @@ -133,6 +139,7 @@ mod tests { truncated_id: "imp-9".to_string(), entry_count: 12, leaf_display: "leaf-3".to_string(), + sync: super::super::lifecycle_test_support::sync_payload("imp-9"), })); let result = resume_portable(&mut fake, Some("/tmp/import.json")); assert!(!result.is_error); @@ -140,6 +147,16 @@ mod tests { message(&result), "Imported foreign session as imp-9 (12 entries, leaf leaf-3)" ); + // The engine must adopt the imported conversation; a message-only + // result would leave it on the previous session. + assert!( + matches!( + result.action, + Some(crate::tui::app::AppAction::SyncSession { ref session_id, .. }) + if session_id.as_deref() == Some("imp-9") + ), + "{result:?}" + ); let mut fake = control_fake(); fake.resume = Some(Ok(ResumeSource::File(PathBuf::from("/tmp/x.json")))); @@ -158,12 +175,21 @@ mod tests { truncated_id: "c-1".to_string(), entry_count: 0, leaf_display: "(none)".to_string(), + sync: super::super::lifecycle_test_support::sync_payload("c-1"), }))); let result = resume_portable(&mut fake, Some("inline-json")); assert_eq!( message(&result), "Imported foreign session as c-1 (0 entries, leaf (none))" ); + assert!( + matches!( + result.action, + Some(crate::tui::app::AppAction::SyncSession { ref session_id, .. }) + if session_id.as_deref() == Some("c-1") + ), + "{result:?}" + ); } #[test] diff --git a/crates/tui/src/session_tree.rs b/crates/tui/src/session_tree.rs index 32c5564675..962a0ba15c 100644 --- a/crates/tui/src/session_tree.rs +++ b/crates/tui/src/session_tree.rs @@ -586,6 +586,44 @@ mod tests { ); } #[test] + fn stamped_rebranch_keeps_prefix_identity_and_suffix_stamps() { + let stamp = |secs: i64| DateTime::from_timestamp(secs, 0).expect("stamp"); + let mut j = SessionJournal::new(); + j.append_stamped( + SessionEntryKind::Message { + message: msg("user", "a"), + }, + stamp(100), + ); + j.append_stamped( + SessionEntryKind::Message { + message: msg("assistant", "b"), + }, + stamp(200), + ); + let a_id = j.entries[0].id.clone(); + j.rebranch_active_messages_stamped( + &[msg("user", "a"), msg("assistant", "b2")], + &[stamp(100), stamp(300)], + ); + assert_eq!(j.entries.len(), 3); + assert_eq!(j.entries[0].id, a_id, "shared prefix keeps its id"); + assert_eq!(j.entries[0].created_at, stamp(100)); + let path = j.root_to_leaf(); + assert_eq!(path.len(), 2); + assert_eq!( + path[1].created_at, + stamp(300), + "suffix keeps the live stamp" + ); + assert!( + j.entries + .iter() + .any(|e| e.kind.as_message().as_ref() == Some(&msg("assistant", "b"))), + "replaced suffix survives as a sibling" + ); + } + #[test] fn compaction_fits() { let mut j = SessionJournal::new(); let id = j.append_compaction("summary".into(), Some(1000), Some(100), None);