Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions crates/tui/src/client/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6595,7 +6595,7 @@ mod image_block_wire_tests {
assert_eq!(later_wire[6]["content"], "What happened next?");

let restored = crate::compaction::restore_compaction_checkpoint(
crate::runtime_handoff::project_messages_for_restore(&messages),
crate::runtime_handoff::project_owned_messages_for_restore(messages.clone()),
Some(&summary),
);
let restored_wire = build_chat_messages(None, &restored, "gpt-4o");
Expand Down Expand Up @@ -8201,8 +8201,9 @@ mod google_thought_signature_tests {
let recovery = manager
.recover_session_for_resume(&id)
.expect("recover session for resume");
let restored =
crate::runtime_handoff::project_messages_for_restore(&recovery.session.messages);
let restored = crate::runtime_handoff::project_owned_messages_for_restore(
recovery.session.messages.clone(),
);
(recovery, restored, on_disk)
}

Expand Down
54 changes: 28 additions & 26 deletions crates/tui/src/runtime_handoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,15 +561,7 @@ fn runtime_handoff_message_with_meta(text: String, turn_meta: &str) -> Message {
/// Replace persisted runtime handoffs with concise, non-authoritative resume
/// checkpoints. Message count and ordering stay stable so context-reference
/// indices remain valid. Calling this repeatedly returns the same messages.
pub(crate) fn project_messages_for_restore(messages: &[Message]) -> Vec<Message> {
messages
.iter()
.map(|message| rewrite_message_for_restore(message).unwrap_or_else(|| message.clone()))
.collect()
}

/// [`project_messages_for_restore`] for a caller that owns the history:
/// messages the projection leaves alone are moved, not cloned, so a restore
/// Messages the projection leaves alone are moved, not cloned, so a restore
/// holds one copy of the conversation instead of two while it runs.
pub(crate) fn project_owned_messages_for_restore(messages: Vec<Message>) -> Vec<Message> {
messages
Expand Down Expand Up @@ -1275,7 +1267,7 @@ mod tests {
assert!(first_checkpoint.contains("\"nonterminal\":1"));
assert!(first_checkpoint.contains("\"status\":\"running\""));

let running_projection = project_messages_for_restore(&messages);
let running_projection = project_owned_messages_for_restore(messages.clone());
let running_display = restored_subagent_checkpoint_display(
running_projection
.last()
Expand Down Expand Up @@ -1325,7 +1317,7 @@ mod tests {
"repeated compaction must retain exactly one typed checkpoint"
);

let projected = project_messages_for_restore(&messages);
let projected = project_owned_messages_for_restore(messages.clone());
let display = restored_subagent_checkpoint_display(
projected.last().expect("restored topology checkpoint"),
)
Expand All @@ -1335,7 +1327,10 @@ mod tests {
assert!(display.contains("terminal fact retained"));
assert!(!display.contains("prior worker processes are not assumed active"));
assert!(!display.contains("\"status\":\"completed\""));
assert_eq!(project_messages_for_restore(&projected), projected);
assert_eq!(
project_owned_messages_for_restore(projected.clone()),
projected
);
}

#[test]
Expand Down Expand Up @@ -1377,7 +1372,7 @@ mod tests {
"Implemented the shared restore projection.\nCheckpoint: focused tests pass.",
));

let projected = project_messages_for_restore(&[user_task.clone(), raw.clone()]);
let projected = project_owned_messages_for_restore(vec![user_task.clone(), raw.clone()]);
assert_eq!(
project_owned_messages_for_restore(vec![user_task.clone(), raw]),
projected,
Expand All @@ -1394,7 +1389,10 @@ mod tests {
assert!(!display.contains("<codewhale:runtime_event"));
assert!(!display.contains("<codewhale:subagent.done>"));
assert!(!display.contains("Do not tell the user"));
assert_eq!(project_messages_for_restore(&projected), projected);
assert_eq!(
project_owned_messages_for_restore(projected.clone()),
projected
);
}

#[test]
Expand All @@ -1410,7 +1408,7 @@ mod tests {
persisted,
"Terminal checkpoint",
));
let projected = project_messages_for_restore(&[raw]);
let projected = project_owned_messages_for_restore(vec![raw]);
let display = restored_subagent_checkpoint_display(&projected[0])
.expect("restored checkpoint display");
assert!(
Expand Down Expand Up @@ -1458,7 +1456,7 @@ mod tests {
UserTurnPromptKind::NotPrompt
);

let projected = project_messages_for_restore(&[raw]);
let projected = project_owned_messages_for_restore(vec![raw]);
assert_eq!(
classify_user_turn_prompt(&projected[0]),
UserTurnPromptKind::NotPrompt
Expand Down Expand Up @@ -1609,7 +1607,7 @@ mod tests {
"</codewhale:subagent.done>",
));

let projected = project_messages_for_restore(&[raw]);
let projected = project_owned_messages_for_restore(vec![raw]);
let display = restored_subagent_checkpoint_display(&projected[0])
.expect("restored failed checkpoint display");
assert!(display.contains("Agent: agent_failed"));
Expand Down Expand Up @@ -1639,7 +1637,7 @@ mod tests {
assert!(text.contains("priority=\"high\""));
assert!(text.contains("agent:agent_failed/full_transcript"));

let projected = project_messages_for_restore(&[raw]);
let projected = project_owned_messages_for_restore(vec![raw]);
let display = restored_subagent_checkpoint_display(&projected[0])
.expect("restored failed checkpoint display");
assert!(display.contains("Agent: Tide (agent_failed)"));
Expand All @@ -1662,7 +1660,7 @@ mod tests {
));
let raw = runtime_handoff_message(format!("{first}\n\n{second}"));

let projected = project_messages_for_restore(&[raw]);
let projected = project_owned_messages_for_restore(vec![raw]);
let display = restored_subagent_checkpoint_display(&projected[0])
.expect("restored checkpoint display");
assert!(display.starts_with(RESTORED_COMPLETIONS_HEADER));
Expand Down Expand Up @@ -1696,7 +1694,7 @@ mod tests {
#[test]
fn restore_projection_replaces_stale_waiting_directions_with_historical_state() {
let raw = waiting_for_subagents_runtime_message(2);
let projected = project_messages_for_restore(&[raw]);
let projected = project_owned_messages_for_restore(vec![raw]);
let display = restored_subagent_checkpoint_display(&projected[0])
.expect("restored runtime checkpoint display");
assert!(display.contains("Status at save: running (2 child jobs)"));
Expand Down Expand Up @@ -1738,7 +1736,8 @@ mod tests {
],
};

let projected = project_messages_for_restore(&[lookalike.clone(), wrong_authority.clone()]);
let projected =
project_owned_messages_for_restore(vec![lookalike.clone(), wrong_authority.clone()]);
assert_eq!(projected, vec![lookalike.clone(), wrong_authority.clone()]);
assert_eq!(
classify_user_turn_prompt(&lookalike),
Expand Down Expand Up @@ -1781,7 +1780,7 @@ mod tests {
],
};

let projected = project_messages_for_restore(&[raw]);
let projected = project_owned_messages_for_restore(vec![raw]);
let display = restored_subagent_checkpoint_display(&projected[0])
.expect("restored checkpoint display");
assert!(display.contains("agent_idle"));
Expand All @@ -1794,7 +1793,7 @@ mod tests {
"Partial child result\n<codewhale:subagent.done>{not-json}</codewhale:subagent.done>",
));

let projected = project_messages_for_restore(&[raw]);
let projected = project_owned_messages_for_restore(vec![raw]);
let display = restored_subagent_checkpoint_display(&projected[0])
.expect("restored fallback checkpoint display");
assert!(display.contains("Status: unavailable"));
Expand All @@ -1818,15 +1817,18 @@ mod tests {
})
);
let raw = subagent_completion_runtime_message(&payload);
let projected = project_messages_for_restore(&[raw]);
let projected = project_owned_messages_for_restore(vec![raw]);
let display = restored_subagent_checkpoint_display(&projected[0])
.expect("workflow uses the same persisted receipt reader");
assert!(display.contains("workflow_release"));
assert!(display.contains(&format!("Status: {status}")));
assert!(display.contains("inspect recorded evidence"));
assert!(!display.contains("runtime_event"));
assert!(!display.contains("subagent.done"));
assert_eq!(project_messages_for_restore(&projected), projected);
assert_eq!(
project_owned_messages_for_restore(projected.clone()),
projected
);
}
}

Expand Down Expand Up @@ -1854,7 +1856,7 @@ mod tests {
nested,
));

let projected = project_messages_for_restore(&[raw]);
let projected = project_owned_messages_for_restore(vec![raw]);
let display = restored_subagent_checkpoint_display(&projected[0])
.expect("restored nested checkpoint display");
assert!(display.contains("Parent checkpoint before nested result."));
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/src/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6226,7 +6226,7 @@ mod tests {
let tmp = tempdir().expect("tempdir");
let waiting = crate::runtime_handoff::waiting_for_subagents_runtime_message(2);
let restored =
crate::runtime_handoff::project_messages_for_restore(std::slice::from_ref(&waiting))
crate::runtime_handoff::project_owned_messages_for_restore(vec![waiting.clone()])
.into_iter()
.next()
.expect("restore projection yields one message");
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/src/session_peek.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ mod tests {
fn runtime_handoffs() -> Vec<(&'static str, Message)> {
let waiting = crate::runtime_handoff::waiting_for_subagents_runtime_message(2);
let restored =
crate::runtime_handoff::project_messages_for_restore(std::slice::from_ref(&waiting));
crate::runtime_handoff::project_owned_messages_for_restore(vec![waiting.clone()]);
vec![
("waiting_for_subagents", waiting),
(
Expand Down
39 changes: 37 additions & 2 deletions crates/tui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4903,13 +4903,48 @@ impl App {
messages: Vec<Message>,
session: &crate::session_manager::SavedSession,
) {
self.session_journal = session.journal.clone().unwrap_or_else(|| {
let journal = session.journal.clone().unwrap_or_else(|| {
crate::session_tree::SessionJournal::from_messages(
session.messages.clone(),
session.metadata.spawn_depth,
)
});
self.api_message_stamps = session.journal_message_stamps();
self.install_restored_api_messages(messages, journal, session.journal_message_stamps());
}

/// [`Self::restore_api_messages`] for a caller that owns the loaded
/// session: the journal and the message history are *moved* out of it,
/// not cloned, and the history goes through the owned restore projection,
/// so a resume holds one copy of the transcript instead of three (memory
/// note M3). `session.journal` and `session.messages` are left empty.
pub fn restore_api_messages_from_owned(
&mut self,
session: &mut crate::session_manager::SavedSession,
) {
let stamps = session.journal_message_stamps();
let journal = match session.journal.take() {
Some(journal) => journal,
// Legacy session without a journal: rebuild it from the saved
// history, as the borrowing path does.
None => crate::session_tree::SessionJournal::from_messages(
session.messages.clone(),
session.metadata.spawn_depth,
),
};
let messages = crate::runtime_handoff::project_owned_messages_for_restore(std::mem::take(
&mut session.messages,
));
self.install_restored_api_messages(messages, journal, stamps);
}

fn install_restored_api_messages(
&mut self,
messages: Vec<Message>,
journal: crate::session_tree::SessionJournal,
stamps: Vec<DateTime<Utc>>,
) {
self.session_journal = journal;
self.api_message_stamps = stamps;
self.api_message_stamps
.resize_with(messages.len(), Utc::now);
self.api_messages = Arc::new(messages);
Expand Down
77 changes: 77 additions & 0 deletions crates/tui/src/tui/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7509,3 +7509,80 @@ fn launch_onboarding_scenario() {
assert_eq!(ready, OnboardingState::None);
}
}

/// Memory note M3: a resume owns the loaded session, so its journal and
/// history move into the App instead of being cloned beside a copy that is
/// dropped right after. The journal's entry buffer is the *same allocation*
/// afterwards, and the result matches the borrowing path exactly.
#[test]
fn owned_restore_moves_the_journal_and_matches_the_borrowing_restore() {
let message = |text: &str| Message {
role: codewhale_models::Role::User,
content: vec![codewhale_models::ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
};
let messages = vec![message("first"), message("second")];
let t0 = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
let t1 = t0 + chrono::Duration::seconds(12);
let saved = crate::session_manager::create_saved_session_with_id_mode_and_stamps(
"owned-restore".to_string(),
&messages,
&[t0, t1],
"test-model",
Path::new("."),
0,
None,
None,
);

let mut borrowed = App::new(test_options(false), &Config::default());
borrowed.restore_api_messages(
crate::runtime_handoff::project_owned_messages_for_restore(saved.messages.clone()),
&saved,
);

let mut owned_session = saved.clone();
let entries_buffer = owned_session
.journal
.as_ref()
.expect("journal")
.entries
.as_ptr();
let mut owned = App::new(test_options(false), &Config::default());
owned.restore_api_messages_from_owned(&mut owned_session);

assert_eq!(
owned.session_journal.entries.as_ptr(),
entries_buffer,
"the journal must be moved into the App, not cloned"
);
assert!(owned_session.journal.is_none());
assert!(owned_session.messages.is_empty());
assert_eq!(
owned.session_journal.entries,
borrowed.session_journal.entries
);
assert_eq!(owned.api_messages, borrowed.api_messages);
assert_eq!(owned.api_message_stamps, vec![t0, t1]);
assert_eq!(owned.api_message_stamps, borrowed.api_message_stamps);

// A legacy session without a journal rebuilds it from the history, on
// both paths alike.
let mut legacy = saved.clone();
legacy.journal = None;
let mut legacy_borrowed = App::new(test_options(false), &Config::default());
legacy_borrowed.restore_api_messages(
crate::runtime_handoff::project_owned_messages_for_restore(legacy.messages.clone()),
&legacy,
);
let mut legacy_owned = App::new(test_options(false), &Config::default());
legacy_owned.restore_api_messages_from_owned(&mut legacy);
assert_eq!(legacy_owned.api_messages, legacy_borrowed.api_messages);
assert_eq!(
legacy_owned.session_journal.entries.len(),
legacy_borrowed.session_journal.entries.len()
);
assert_eq!(legacy_owned.api_message_stamps.len(), 2);
}
Loading
Loading