diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 5c5f8b0ee8..6d0c366a80 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -2627,6 +2627,23 @@ fn last_chars(value: &str, count: usize) -> String { chars.into_iter().collect() } +fn merge_adjacent_user_content(previous: Value, current: Value) -> Value { + match (previous, current) { + (Value::String(left), Value::String(right)) => json!(format!("{left}\n\n{right}")), + (left, right) => { + let mut parts = Vec::new(); + for content in [left, right] { + match content { + Value::Array(items) => parts.extend(items), + Value::String(text) => parts.push(json!({"type": "text", "text": text})), + other => parts.push(other), + } + } + Value::Array(parts) + } + } +} + fn build_chat_messages_with_reasoning( system: Option<&SystemPrompt>, messages: &[Message], @@ -2652,7 +2669,53 @@ fn build_chat_messages_with_reasoning( })); } - for (message_index, message) in messages.iter().enumerate() { + // Persisted compaction keeps its summary after the bounded last round. + // On strict paired chat templates a user message after a tool result is + // invalid. Reorder only a generated summary immediately after a tool + // result; its independent provenance block rules out quoted user text. + // The session log retains every original message and tool ID. + // Limitation: this normalization applies to Chat Completions only. + let summary_index = messages + .iter() + .enumerate() + .rev() + .find_map(|(index, message)| { + (index > 0 + && crate::compaction::is_wire_compaction_checkpoint_message(message) + && messages[index - 1] + .content + .iter() + .any(|block| matches!(block, ContentBlock::ToolResult { .. }))) + .then_some(index) + }); + let summary_target = summary_index.and_then(|summary_index| { + messages[..summary_index] + .iter() + .rposition(|message| { + crate::runtime_handoff::classify_user_turn_prompt(message) + != crate::runtime_handoff::UserTurnPromptKind::NotPrompt + }) + .or_else(|| { + messages[..summary_index] + .iter() + .position(|message| message.role.is_assistant_like()) + }) + }); + let wire_messages = (0..messages.len()) + .filter(|index| Some(*index) != summary_index || summary_target.is_none()) + .flat_map(|index| { + if Some(index) == summary_target { + [summary_index, Some(index)] + .into_iter() + .flatten() + .collect::>() + } else { + vec![index] + } + }); + + for message_index in wire_messages { + let message = &messages[message_index]; // Which wire channel this message belongs in is decided by the shared // placement table, not by an `if` chain local to this adapter. let placement = role_placement(&message.role, WireDialect::ChatCompletions); @@ -2852,7 +2915,26 @@ fn build_chat_messages_with_reasoning( if include_tool_budget_metadata && let Some(turn_meta) = &turn_meta_budget { msg["_turn_meta_budget"] = turn_meta_budget_json(turn_meta); } - out.push(msg); + if (Some(message_index) == summary_index + || Some(message_index) == summary_target + || crate::compaction::is_wire_compaction_checkpoint_message(message) + || crate::runtime_handoff::is_agent_topology_checkpoint(message) + || crate::runtime_handoff::is_restored_agent_topology_checkpoint(message)) + && let Some(previous) = out.last_mut() + && previous.get("role").and_then(Value::as_str) == Some("user") + { + let previous_content = previous["content"].take(); + let current_content = msg["content"].take(); + previous["content"] = + merge_adjacent_user_content(previous_content, current_content); + if previous.get("_turn_meta_budget").is_none() + && let Some(meta) = msg.get("_turn_meta_budget") + { + previous["_turn_meta_budget"] = meta.clone(); + } + } else { + out.push(msg); + } } } @@ -6430,6 +6512,225 @@ mod image_block_wire_tests { const DATA_URL: &str = "data:image/png;base64,QUJD"; + #[test] + fn compaction_checkpoint_keeps_complete_tool_round_on_wire() { + let mut messages: Vec = serde_json::from_value(serde_json::json!([ + {"role":"user","content":[{"type":"text","text":"Analyze the data"}]}, + {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"ready"}]}, + {"role":"assistant","content":[{"type":"tool_use","id":"call_2","name":"read","input":{"path":"b.txt"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_2","content":"done"}]} + ])).unwrap(); + let summary = codewhale_models::SystemPrompt::Text( + crate::compaction::build_compaction_summary_block_text("Compacted summary", ""), + ); + messages.push(crate::compaction::compaction_checkpoint_message(&summary)); + crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]); + let stored = messages.clone(); + let wire = build_chat_messages(None, &messages, "gpt-4o"); + assert_eq!( + messages, stored, + "request construction must not alter saved history" + ); + let roles: Vec<&str> = wire + .iter() + .map(|message| message["role"].as_str().unwrap()) + .collect(); + assert_eq!(roles, ["user", "assistant", "tool", "assistant", "tool"]); + let prompt = wire[0]["content"].as_str().unwrap(); + assert!(prompt.contains("Compacted summary")); + assert!(prompt.contains("Analyze the data")); + assert!(prompt.contains("codewhale.agent_topology.v1")); + assert_eq!(wire[1]["tool_calls"][0]["id"], "call_1"); + assert_eq!(wire[2]["tool_call_id"], "call_1"); + assert_eq!(wire[3]["tool_calls"][0]["id"], "call_2"); + assert_eq!(wire[4]["tool_call_id"], "call_2"); + + messages.push( + serde_json::from_value(serde_json::json!({ + "role":"assistant","content":[{"type":"text","text":"Analysis complete"}] + })) + .unwrap(), + ); + messages.push( + serde_json::from_value(serde_json::json!({ + "role":"user","content":[{"type":"text","text":"What happened next?"}] + })) + .unwrap(), + ); + let later_wire = build_chat_messages(None, &messages, "gpt-4o"); + let later_roles: Vec<&str> = later_wire + .iter() + .map(|message| message["role"].as_str().unwrap()) + .collect(); + assert_eq!( + later_roles, + [ + "user", + "assistant", + "tool", + "assistant", + "tool", + "assistant", + "user" + ] + ); + assert_eq!(later_wire[6]["content"], "What happened next?"); + + let restored = crate::compaction::restore_compaction_checkpoint( + crate::runtime_handoff::project_messages_for_restore(&messages), + Some(&summary), + ); + let restored_wire = build_chat_messages(None, &restored, "gpt-4o"); + let restored_roles: Vec<&str> = restored_wire + .iter() + .map(|message| message["role"].as_str().unwrap()) + .collect(); + assert_eq!(restored_roles, later_roles); + assert!( + restored_wire[0]["content"] + .as_str() + .unwrap() + .contains("restored Agent topology checkpoint") + ); + assert_eq!(restored_wire[6]["content"], "What happened next?"); + } + + #[test] + fn quoted_compaction_marker_does_not_reorder_user_wire_messages() { + let messages: Vec = serde_json::from_value(serde_json::json!([ + {"role":"user","content":[{"type":"text","text":"First question"}]}, + {"role":"assistant","content":[{"type":"text","text":"First answer"}]}, + {"role":"user","content":[{"type":"text","text":"Please explain: Another language model started to solve this problem"}]}, + {"role":"assistant","content":[{"type":"text","text":"It introduces a summary."}]}, + {"role":"user","content":[{"type":"text","text":"Follow-up question"}]} + ])).unwrap(); + let wire = build_chat_messages(None, &messages, "gpt-4o"); + let roles: Vec<&str> = wire + .iter() + .map(|message| message["role"].as_str().unwrap()) + .collect(); + assert_eq!(roles, ["user", "assistant", "user", "assistant", "user"]); + assert_eq!(wire[0]["content"], "First question"); + assert_eq!( + wire[2]["content"], + "Please explain: Another language model started to solve this problem" + ); + assert_eq!(wire[4]["content"], "Follow-up question"); + + let quoted_exact_header = crate::compaction::build_compaction_summary_block_text( + "This text was pasted by a user", + "", + ); + let mut with_exact_quote = messages.clone(); + with_exact_quote[2] = Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: quoted_exact_header.clone(), + cache_control: None, + }], + }; + let exact_wire = build_chat_messages(None, &with_exact_quote, "gpt-4o"); + let exact_roles: Vec<&str> = exact_wire + .iter() + .map(|message| message["role"].as_str().unwrap()) + .collect(); + assert_eq!(exact_roles, roles); + assert_eq!(exact_wire[2]["content"], quoted_exact_header); + + let mut after_tool: Vec = serde_json::from_value(serde_json::json!([ + {"role":"user","content":[{"type":"text","text":"Read first"}]}, + {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"contents"}]}, + {"role":"user","content":[{"type":"text","text":"placeholder"}]}, + {"role":"assistant","content":[{"type":"text","text":"Answer"}]} + ])).unwrap(); + after_tool[3] = Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: quoted_exact_header.clone(), + cache_control: None, + }], + }; + let after_tool_wire = build_chat_messages(None, &after_tool, "gpt-4o"); + let after_tool_roles: Vec<&str> = after_tool_wire + .iter() + .map(|message| message["role"].as_str().unwrap()) + .collect(); + assert_eq!( + after_tool_roles, + ["user", "assistant", "tool", "user", "assistant"] + ); + assert_eq!(after_tool_wire[3]["content"], quoted_exact_header); + } + + #[test] + fn topology_checkpoint_after_tool_result_keeps_wire_tool_pair() { + let mut messages: Vec = serde_json::from_value(serde_json::json!([ + {"role":"user","content":[{"type":"text","text":"Read the file"}]}, + {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"contents"}]} + ])).unwrap(); + crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]); + let wire = build_chat_messages(None, &messages, "gpt-4o"); + let roles: Vec<&str> = wire + .iter() + .map(|message| message["role"].as_str().unwrap()) + .collect(); + assert_eq!(roles, ["user", "assistant", "tool"]); + assert!( + wire[0]["content"] + .as_str() + .unwrap() + .contains("agent_topology_v1") + ); + assert_eq!(wire[1]["tool_calls"][0]["id"], "call_1"); + assert_eq!(wire[2]["tool_call_id"], "call_1"); + } + + #[test] + fn compaction_without_retained_user_has_one_wire_user_before_tools() { + let mut messages: Vec = serde_json::from_value(serde_json::json!([ + {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"contents"}]} + ])).unwrap(); + let summary = codewhale_models::SystemPrompt::Text( + crate::compaction::build_compaction_summary_block_text("Summary", ""), + ); + messages.push(crate::compaction::compaction_checkpoint_message(&summary)); + crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]); + let wire = build_chat_messages(None, &messages, "gpt-4o"); + let roles: Vec<&str> = wire + .iter() + .map(|message| message["role"].as_str().unwrap()) + .collect(); + assert_eq!(roles, ["user", "assistant", "tool"]); + let prompt = wire[0]["content"].as_str().unwrap(); + assert!(prompt.contains("Summary")); + assert!(prompt.contains("agent_topology_v1")); + assert_eq!(wire[2]["tool_call_id"], "call_1"); + } + + #[test] + fn compaction_after_unanswered_user_prompt_has_one_wire_user() { + let mut messages: Vec = serde_json::from_value(serde_json::json!([ + {"role":"user","content":[{"type":"text","text":"Please continue"}]} + ])) + .unwrap(); + let summary = codewhale_models::SystemPrompt::Text( + crate::compaction::build_compaction_summary_block_text("Earlier work", ""), + ); + messages.push(crate::compaction::compaction_checkpoint_message(&summary)); + crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]); + let wire = build_chat_messages(None, &messages, "gpt-4o"); + assert_eq!(wire.len(), 1); + assert_eq!(wire[0]["role"], "user"); + let prompt = wire[0]["content"].as_str().unwrap(); + assert!(prompt.contains("Please continue")); + assert!(prompt.contains("Earlier work")); + assert!(prompt.contains("agent_topology_v1")); + } + fn fixture_tool_use(id: &str) -> ContentBlock { ContentBlock::ToolUse { id: id.to_string(), diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 94fd671102..3bc600b3f7 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -191,6 +191,7 @@ pub const COMPACTION_SUMMARY_MARKER: &str = "Another language model started to s /// Marker written by pre-v0.9.6 compaction; sessions saved under the old /// format must still be recognized so their summary is replaced, not stacked. pub const LEGACY_COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)"; +const COMPACTION_CHECKPOINT_PROVENANCE: &str = ""; const COMPACTION_SUMMARY_BEGIN: &str = ""; const COMPACTION_SUMMARY_END: &str = ""; @@ -306,10 +307,16 @@ pub fn summary_prompt_text(prompt: &SystemPrompt) -> String { pub(crate) fn compaction_checkpoint_message(prompt: &SystemPrompt) -> Message { Message { role: Role::User, - content: vec![ContentBlock::Text { - text: summary_prompt_text(prompt), - cache_control: None, - }], + content: vec![ + ContentBlock::Text { + text: summary_prompt_text(prompt), + cache_control: None, + }, + ContentBlock::Text { + text: COMPACTION_CHECKPOINT_PROVENANCE.to_string(), + cache_control: None, + }, + ], } } @@ -318,6 +325,54 @@ pub(crate) fn is_compaction_checkpoint_message(message: &Message) -> bool { user_text_of(message).is_some_and(|text| is_compaction_summary_text(&text)) } +/// Request-time recognition is narrower than legacy summary replacement: +/// user text merely quoting the marker must keep its original wire position. +pub(crate) fn is_wire_compaction_checkpoint_message(message: &Message) -> bool { + let [ + ContentBlock::Text { + text, + cache_control: None, + }, + ContentBlock::Text { + text: provenance, + cache_control: None, + }, + ] = message.content.as_slice() + else { + return false; + }; + message.role == Role::User + && text.starts_with(SUMMARY_HEADER) + && provenance == COMPACTION_CHECKPOINT_PROVENANCE +} + +/// Keep the checkpoint at its original historical boundary on session load. +/// Later user turns must remain after the saved compaction boundary. +pub(crate) fn restore_compaction_checkpoint( + mut messages: Vec, + checkpoint: Option<&SystemPrompt>, +) -> Vec { + let typed_position = messages + .iter() + .position(is_wire_compaction_checkpoint_message); + let checkpoint_index = if let Some(index) = typed_position { + messages.retain(|message| !is_wire_compaction_checkpoint_message(message)); + index + } else { + // Legacy sessions have no independent provenance. Preserve their + // existing broad cleanup behavior; identical user text is ambiguous. + messages.retain(|message| !is_compaction_checkpoint_message(message)); + messages.len() + }; + if let Some(checkpoint) = checkpoint { + messages.insert( + checkpoint_index.min(messages.len()), + compaction_checkpoint_message(checkpoint), + ); + } + messages +} + pub(crate) fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize { message .content @@ -1211,7 +1266,7 @@ pub async fn compact_messages_safe( .unwrap_or_else(|| anyhow::anyhow!("Compaction failed after {MAX_RETRIES} retries"))) } -fn build_compaction_summary_block_text(summary: &str, anchors: &str) -> String { +pub(crate) fn build_compaction_summary_block_text(summary: &str, anchors: &str) -> String { let summary = summary.trim(); let summary = if summary.is_empty() { "(no summary available)" @@ -1249,6 +1304,7 @@ pub(crate) fn retained_user_messages(messages: &[Message], max_tokens: usize) -> break; } if msg.role != Role::User + || crate::runtime_handoff::is_runtime_owned_user_message(msg) || (user_text_of(msg).is_none() && !msg .content @@ -1705,6 +1761,40 @@ mod quota_tests; mod tests { use codewhale_models::{ImageUrlContent, Message}; + #[test] + fn restore_replaces_duplicate_generated_checkpoints_without_deleting_user_quote() { + let summary = SystemPrompt::Text(build_compaction_summary_block_text("Summary", "")); + let generated = compaction_checkpoint_message(&summary); + let user_quote = Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: summary_prompt_text(&summary), + cache_control: None, + }], + }; + assert!(!is_wire_compaction_checkpoint_message(&user_quote)); + let restored = restore_compaction_checkpoint( + vec![generated.clone(), user_quote.clone(), generated], + Some(&summary), + ); + assert_eq!(restored.len(), 2); + assert!(is_wire_compaction_checkpoint_message(&restored[0])); + assert_eq!(restored[1], user_quote); + + // No provenance means the historical broad cleanup remains in force. + let legacy = Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: format!("{COMPACTION_SUMMARY_MARKER}\nold summary"), + cache_control: None, + }], + }; + let legacy_restored = + restore_compaction_checkpoint(vec![legacy.clone(), legacy], Some(&summary)); + assert_eq!(legacy_restored.len(), 1); + assert!(is_wire_compaction_checkpoint_message(&legacy_restored[0])); + } + #[test] fn inline_image_estimates_nonzero_tokens() { let msg = Message { @@ -2275,10 +2365,13 @@ mod tests { }) })); assert!(is_compaction_checkpoint_message(retained.last().unwrap())); - assert_eq!( - user_text_of(retained.last().unwrap()).as_deref(), - Some(text.as_str()) - ); + assert!(is_wire_compaction_checkpoint_message( + retained.last().unwrap() + )); + assert!(matches!( + &retained.last().unwrap().content[0], + ContentBlock::Text { text: checkpoint, .. } if checkpoint == text + )); last_round::validate_last_round_coverage(&messages, &retained[..retained.len() - 1]) .unwrap(); } diff --git a/crates/tui/src/compaction/last_round.rs b/crates/tui/src/compaction/last_round.rs index afbebb85e1..32db8ad897 100644 --- a/crates/tui/src/compaction/last_round.rs +++ b/crates/tui/src/compaction/last_round.rs @@ -120,7 +120,15 @@ pub fn pinned_anchors_text(workspace: Option<&std::path::Path>) -> Option bool { - !is_compaction_checkpoint_message(message) && user_text_of(message).is_some() + !is_compaction_checkpoint_message(message) + && !crate::runtime_handoff::is_runtime_owned_user_message(message) + && user_text_of(message).is_some() +} + +fn user_prompt_text_of(message: &Message) -> Option { + is_plain_user_text(message) + .then(|| user_text_of(message)) + .flatten() } fn last_plain_user_index(messages: &[Message], end: usize) -> Option { @@ -242,6 +250,22 @@ pub(super) fn replacement_messages( .cloned() .collect::>(); retained.extend(bound_last_round(&round)); + // The Operate contract applies to the current tool loop as well as later + // turns. It must survive compaction even when old-user retention is full. + let current_contract = messages + .iter() + .rev() + .find(|message| crate::runtime_handoff::is_current_operate_contract_message(message)); + let contract = current_contract.or_else(|| { + messages + .iter() + .rev() + .find(|message| crate::runtime_handoff::is_operate_contract_message(message)) + }); + if let Some(contract) = contract { + retained.retain(|message| !crate::runtime_handoff::is_operate_contract_message(message)); + retained.insert(0, contract.clone()); + } retained } @@ -342,8 +366,8 @@ pub(crate) fn validate_last_round_coverage( // tool-bearing turn, so the round routinely spans two user messages -- and // checking only the earliest let a rewrite drop the *latest* one, which is // the turn this whole contract exists to keep. - for text in last_round.iter().copied().filter_map(user_text_of) { - if !survives(&text, replacement, user_text_of) { + for text in last_round.iter().copied().filter_map(user_prompt_text_of) { + if !survives(&text, replacement, user_prompt_text_of) { anyhow::bail!( "Compaction coverage floor: a last-round user message was dropped; history was not replaced." ); @@ -671,6 +695,143 @@ mod tests { })); } + #[test] + fn second_compaction_keeps_long_user_question_and_tool_pair_past_retention_budget() { + const PRODUCTION_MIN_RETAINED_TOKENS: usize = 2_000; + let long_question = format!( + "{}?", + "Analyze every step of this case carefully. ".repeat(400) + ); + assert!(long_question.len() > PRODUCTION_MIN_RETAINED_TOKENS * 3); + let original = vec![ + msg("user", &long_question), + tool_use("call_1", "Bash", json!({"command": "echo ready"})), + tool_result("call_1", "ready"), + ]; + let first_summary = + crate::compaction::build_compaction_summary_block_text("First pass complete", ""); + let mut first = build_replacement_history( + &original, + &first_summary, + None, + PRODUCTION_MIN_RETAINED_TOKENS, + ) + .expect("first compaction"); + crate::runtime_handoff::replace_agent_topology_checkpoint(&mut first, &[]); + assert_eq!(last_round_start(&first), 0); + let topology = first + .iter() + .find(|message| crate::runtime_handoff::is_agent_topology_checkpoint(message)) + .expect("first compaction topology checkpoint") + .clone(); + assert!( + crate::compaction::retained_user_messages( + std::slice::from_ref(&topology), + PRODUCTION_MIN_RETAINED_TOKENS, + ) + .is_empty(), + "runtime topology must not consume the older-user retention budget" + ); + + let second_summary = + crate::compaction::build_compaction_summary_block_text("Second pass complete", ""); + let second = build_replacement_history( + &first, + &second_summary, + None, + PRODUCTION_MIN_RETAINED_TOKENS, + ) + .expect("second compaction"); + assert!( + second.iter().any(|message| { + user_text_of(message).as_deref() == Some(long_question.as_str()) + }) + ); + assert!( + second + .iter() + .any(|message| has_tool_use_id(message, "call_1")) + ); + assert!( + second + .iter() + .any(|message| has_tool_result_id(message, "call_1")) + ); + let without_question = second + .iter() + .filter(|message| user_text_of(message).as_deref() != Some(long_question.as_str())) + .cloned() + .collect::>(); + assert!(validate_last_round_coverage(&first, &without_question).is_err()); + } + + #[test] + fn runtime_text_cannot_satisfy_real_user_coverage() { + let runtime = crate::runtime_handoff::operate_contract_runtime_message(); + let copied_text = user_text_of(&runtime).expect("runtime text"); + let original = vec![msg("user", &copied_text), msg("assistant", "Acknowledged")]; + let replacement = vec![runtime, msg("assistant", "Acknowledged")]; + assert!( + validate_last_round_coverage(&original, &replacement).is_err(), + "runtime-owned text must not stand in for the user's actual prompt" + ); + } + + #[test] + fn operate_contract_survives_compaction_without_spending_user_budget() { + let contract = crate::runtime_handoff::operate_contract_runtime_message(); + let original = vec![ + contract.clone(), + msg("user", "First task"), + msg("assistant", "Working"), + msg("user", "Continue the same task"), + msg("assistant", "Continuing"), + ]; + let replaced = build_replacement_history( + &original, + &format!("{COMPACTION_SUMMARY_MARKER}: work continues"), + None, + 1, + ) + .expect("compaction must retain the active Operate contract"); + assert_eq!(replaced.first(), Some(&contract)); + assert_eq!( + replaced + .iter() + .filter(|message| **message == contract) + .count(), + 1 + ); + } + + #[test] + fn compaction_prefers_current_operate_contract_over_legacy() { + let legacy = crate::runtime_handoff::legacy_operate_contract_runtime_message(); + let current = crate::runtime_handoff::operate_contract_runtime_message(); + let original = vec![ + legacy.clone(), + current.clone(), + msg("user", "Continue"), + msg("assistant", "Working"), + ]; + let replaced = build_replacement_history( + &original, + &format!("{COMPACTION_SUMMARY_MARKER}: work continues"), + None, + 1, + ) + .expect("current contract must survive compaction"); + assert_eq!(replaced.first(), Some(¤t)); + assert!(!replaced.contains(&legacy)); + assert_eq!( + replaced + .iter() + .filter(|message| crate::runtime_handoff::is_operate_contract_message(message)) + .count(), + 1 + ); + } + #[test] fn last_round_walks_back_through_toolless_tails_to_the_tool_round() { let original = vec![ diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index cd5254e8a0..49e94c0980 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -3096,18 +3096,14 @@ impl Engine { } let compaction_checkpoint = extract_compaction_summary_prompt(system_prompt.clone()); - let mut restored_messages = + let restored_messages = crate::runtime_handoff::project_messages_for_restore(&messages); - // The persisted carrier is authoritative for the one - // history checkpoint. Drop stale projected copies so - // repeated reloads cannot stack or retain an older one. - restored_messages.retain(|message| { - !crate::compaction::is_compaction_checkpoint_message(message) - }); - if let Some(checkpoint) = compaction_checkpoint.as_ref() { - restored_messages - .push(crate::compaction::compaction_checkpoint_message(checkpoint)); - } + // Replace the checkpoint in place so turns after the + // compaction boundary keep their chronology. + let restored_messages = crate::compaction::restore_compaction_checkpoint( + restored_messages, + compaction_checkpoint.as_ref(), + ); self.session.messages = restored_messages.into(); // Direct field assignment bypasses `add_message` / // `replace_messages`, which own the messages-revision diff --git a/crates/tui/src/runtime_handoff.rs b/crates/tui/src/runtime_handoff.rs index 523d70c31c..16f03dbaa5 100644 --- a/crates/tui/src/runtime_handoff.rs +++ b/crates/tui/src/runtime_handoff.rs @@ -145,6 +145,11 @@ pub(crate) fn operate_contract_runtime_message() -> Message { runtime_handoff_message_with_meta(OPERATE_CONTRACT_EVENT.to_string(), RUNTIME_TURN_META) } +#[cfg(test)] +pub(crate) fn legacy_operate_contract_runtime_message() -> Message { + runtime_handoff_message_with_meta(LEGACY_OPERATE_CONTRACT_EVENT.to_string(), RUNTIME_TURN_META) +} + /// True when `message` is the runtime-owned Operate contract. Recognition is /// structural (exact envelope text plus the runtime provenance line) so a /// person quoting the envelope is never matched. @@ -460,7 +465,7 @@ fn render_restored_agent_topology(checkpoint: &SavedAgentTopologyCheckpoint) -> display } -fn is_agent_topology_checkpoint(message: &Message) -> bool { +pub(crate) fn is_agent_topology_checkpoint(message: &Message) -> bool { let [ ContentBlock::Text { text, @@ -486,13 +491,47 @@ fn is_agent_topology_checkpoint(message: &Message) -> bool { /// compaction. A current empty topology is still meaningful: it overrides a /// narrative summary or old runtime event that says an Agent remains live. /// Replays are idempotent because the previous sidecar is structurally removed -/// before the replacement is appended. +/// before the replacement is inserted. A trailing compaction summary is not +/// a real user boundary, and a checkpoint after a tool result would split a +/// strict chat template's assistant/tool round. pub(crate) fn replace_agent_topology_checkpoint( messages: &mut Vec, snapshots: &[SubAgentResult], ) { messages.retain(|message| !is_agent_topology_checkpoint(message)); - messages.push(agent_topology_checkpoint_message(snapshots)); + let ends_with_tool_result = messages.last().is_some_and(|message| { + message.content.iter().any(|block| { + matches!( + block, + ContentBlock::ToolResult { .. } + | ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } + ) + }) + }); + let ends_with_summary = messages + .last() + .is_some_and(crate::compaction::is_wire_compaction_checkpoint_message); + let position = if ends_with_tool_result || ends_with_summary { + messages + .iter() + .rposition(|message| { + !crate::compaction::is_wire_compaction_checkpoint_message(message) + && classify_user_turn_prompt(message) != UserTurnPromptKind::NotPrompt + }) + .map_or_else( + || { + messages + .iter() + .position(|message| message.role.is_assistant_like()) + .unwrap_or(0) + }, + |index| index + 1, + ) + } else { + messages.len() + }; + messages.insert(position, agent_topology_checkpoint_message(snapshots)); } #[cfg(test)] @@ -985,6 +1024,13 @@ pub(crate) fn restored_subagent_checkpoint_display(message: &Message) -> Option< Some(text) } +/// Only the restored topology sidecar belongs to the compaction prompt +/// cluster. Other restored Agent events retain their own wire boundaries. +pub(crate) fn is_restored_agent_topology_checkpoint(message: &Message) -> bool { + restored_subagent_checkpoint_display(message) + .is_some_and(|display| display.starts_with(RESTORED_TOPOLOGY_HEADER)) +} + /// Classification used when locating a user-authored turn in the session log. /// /// Runtime and tool messages are skipped because their provider-compatible @@ -1062,7 +1108,7 @@ pub(crate) fn edit_last_turn_target(messages: &[Message]) -> EditLastTurnTarget /// user-authored. Runtime authority is accepted only from the engine-owned /// structural `` block, never from arbitrary user text that happens /// to resemble a runtime envelope or metadata marker. -fn is_runtime_owned_user_message(message: &Message) -> bool { +pub(crate) fn is_runtime_owned_user_message(message: &Message) -> bool { restored_subagent_checkpoint_display(message).is_some() || has_non_authoritative_turn_provenance(message) }