Fix compaction bug: use User role for summary to maintain alternation
The previous implementation added the summary as a System message, which caused "Conversation must start with a user message" errors because the first non-system message after compaction was Assistant (the preserved last assistant message). Fix: Change summary from System to User message, creating valid alternation: [System Prompt] -> [Summary as USER] -> [Last Assistant] -> [Latest User] This also prevents system message bloat across multiple compactions since the summary is now part of the conversation flow and gets replaced on each compaction. Added test_second_compaction_no_bloat to verify no accumulation.
This commit is contained in:
@@ -292,9 +292,11 @@ Format this as a detailed but concise summary that can be used to resume the con
|
||||
self.add_message(Message::new(MessageRole::System, stub_content));
|
||||
}
|
||||
|
||||
// Add the summary
|
||||
// Add the summary as a USER message (not System) to maintain proper alternation.
|
||||
// This allows: [Summary as User] -> [Last Assistant] -> [Latest User]
|
||||
// which is valid User/Assistant alternation.
|
||||
self.add_message(Message::new(
|
||||
MessageRole::System,
|
||||
MessageRole::User,
|
||||
format!("Previous conversation summary:\n\n{}", summary),
|
||||
));
|
||||
|
||||
|
||||
@@ -51,12 +51,12 @@ fn message_contains(history: &[Message], role: MessageRole, substring: &str) ->
|
||||
///
|
||||
/// This is the main feature test. After compaction:
|
||||
/// 1. System prompt is preserved
|
||||
/// 2. Summary is added
|
||||
/// 3. Last assistant message should be preserved (NEW)
|
||||
/// 2. Summary is added as a USER message
|
||||
/// 3. Last assistant message is preserved as ASSISTANT message
|
||||
/// 4. Latest user message is preserved
|
||||
///
|
||||
/// The order should be:
|
||||
/// [System] -> [Summary] -> [Last Assistant] -> [Latest User]
|
||||
/// [System] -> [Summary as User] -> [Last Assistant] -> [Latest User]
|
||||
#[tokio::test]
|
||||
async fn test_compaction_preserves_last_assistant_message() {
|
||||
// Create a provider that will:
|
||||
@@ -129,14 +129,13 @@ async fn test_compaction_preserves_last_assistant_message() {
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Should have a summary message
|
||||
// 1. Should have a summary message as USER role
|
||||
assert!(
|
||||
message_contains(history_after, MessageRole::System, "Summary:"),
|
||||
"Should have summary message after compaction"
|
||||
message_contains(history_after, MessageRole::User, "Summary:"),
|
||||
"Should have summary message as User role after compaction"
|
||||
);
|
||||
|
||||
// 2. Should preserve the last assistant message content
|
||||
// This is the key assertion - the last assistant message should be preserved
|
||||
// 2. Should preserve the last assistant message as ASSISTANT role
|
||||
assert!(
|
||||
message_contains(history_after, MessageRole::Assistant, "implementation plan"),
|
||||
"Should preserve last assistant message with 'implementation plan' after compaction.\n\
|
||||
@@ -216,7 +215,7 @@ async fn test_compaction_preserves_tool_call_only_message() {
|
||||
let result = agent.force_compact().await;
|
||||
assert!(result.is_ok(), "Compaction should succeed");
|
||||
|
||||
// The assistant message should be preserved (even if it's tool-call-only)
|
||||
// The assistant message should be preserved as a separate Assistant message
|
||||
let history_after = &agent.get_context_window().conversation_history;
|
||||
let has_assistant = history_after
|
||||
.iter()
|
||||
@@ -270,7 +269,7 @@ async fn test_compaction_preserves_only_last_assistant() {
|
||||
);
|
||||
}
|
||||
|
||||
// Should have THIRD_RESPONSE (the last one)
|
||||
// Should have THIRD_RESPONSE (the last one) as an Assistant message
|
||||
assert!(
|
||||
message_contains(history_after, MessageRole::Assistant, "THIRD_RESPONSE"),
|
||||
"Should preserve the LAST assistant message (THIRD_RESPONSE)"
|
||||
@@ -334,7 +333,7 @@ async fn test_compaction_no_trailing_user_message() {
|
||||
/// Test: Message order after compaction is correct
|
||||
///
|
||||
/// The order should be:
|
||||
/// [System Prompt] -> [README if present] -> [Summary] -> [Last Assistant] -> [Latest User if present]
|
||||
/// [System Prompt] -> [README if present] -> [Summary as User] -> [Last Assistant] -> [Latest User]
|
||||
#[tokio::test]
|
||||
async fn test_compaction_message_order() {
|
||||
let provider = MockProvider::new()
|
||||
@@ -367,6 +366,13 @@ async fn test_compaction_message_order() {
|
||||
.position(|m| m.content.contains("SUMMARY_CONTENT"))
|
||||
.expect("Should have summary");
|
||||
|
||||
// Summary should be a User message
|
||||
assert!(
|
||||
matches!(history[summary_idx].role, MessageRole::User),
|
||||
"Summary should be a User message, got {:?}",
|
||||
history[summary_idx].role
|
||||
);
|
||||
|
||||
let assistant_idx = history
|
||||
.iter()
|
||||
.position(|m| matches!(m.role, MessageRole::Assistant) && m.content.contains("ASSISTANT_TO_PRESERVE"))
|
||||
@@ -384,13 +390,178 @@ async fn test_compaction_message_order() {
|
||||
assistant_idx
|
||||
);
|
||||
|
||||
// If there's a user message, it should come after assistant message
|
||||
// If there's a latest user message, it should come after assistant message
|
||||
if let Some(user_idx) = user_idx {
|
||||
assert!(
|
||||
assistant_idx < user_idx,
|
||||
"Assistant message (idx {}) should come before user message (idx {})",
|
||||
"Assistant message (idx {}) should come before latest user message (idx {})",
|
||||
assistant_idx,
|
||||
user_idx
|
||||
);
|
||||
}
|
||||
|
||||
// The last message should be the latest user message
|
||||
let last_msg = history.last().expect("Should have messages");
|
||||
assert!(
|
||||
matches!(last_msg.role, MessageRole::User),
|
||||
"Last message should be User (the latest user message), got {:?}",
|
||||
last_msg.role
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: Second compaction doesn't bloat system messages
|
||||
///
|
||||
/// After multiple compactions, we should always have the same clean structure:
|
||||
/// [System Prompt] -> [Summary as User] -> [Last Assistant] -> [Latest User]
|
||||
///
|
||||
/// The summary should be replaced, not accumulated.
|
||||
#[tokio::test]
|
||||
async fn test_second_compaction_no_bloat() {
|
||||
let provider = MockProvider::new()
|
||||
// First conversation
|
||||
.with_response(MockResponse::text("FIRST_ASSISTANT: I'll help with task 1."))
|
||||
.with_response(MockResponse::text("SECOND_ASSISTANT: Done with task 1."))
|
||||
// First compaction summary
|
||||
.with_response(MockResponse::text("FIRST_SUMMARY: Completed task 1."))
|
||||
// Second conversation (after first compaction)
|
||||
.with_response(MockResponse::text("THIRD_ASSISTANT: Starting task 2."))
|
||||
.with_response(MockResponse::text("FOURTH_ASSISTANT: Done with task 2."))
|
||||
// Second compaction summary
|
||||
.with_response(MockResponse::text("SECOND_SUMMARY: Completed tasks 1 and 2."));
|
||||
|
||||
let (mut agent, _temp_dir) = create_agent_with_mock(provider).await;
|
||||
|
||||
// === First conversation ===
|
||||
agent.execute_task("Start task 1", None, false).await.unwrap();
|
||||
agent.execute_task("Finish task 1", None, false).await.unwrap();
|
||||
|
||||
// Count system messages before first compaction
|
||||
let system_count_before_first = agent
|
||||
.get_context_window()
|
||||
.conversation_history
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::System))
|
||||
.count();
|
||||
eprintln!("System messages before first compaction: {}", system_count_before_first);
|
||||
|
||||
// === First compaction ===
|
||||
agent.force_compact().await.unwrap();
|
||||
|
||||
let history_after_first = &agent.get_context_window().conversation_history;
|
||||
let system_count_after_first = history_after_first
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::System))
|
||||
.count();
|
||||
|
||||
eprintln!("\n=== After FIRST compaction ===");
|
||||
for (i, msg) in history_after_first.iter().enumerate() {
|
||||
eprintln!(
|
||||
" {}: {:?} - {}",
|
||||
i,
|
||||
msg.role,
|
||||
msg.content.chars().take(60).collect::<String>()
|
||||
);
|
||||
}
|
||||
eprintln!("System messages after first compaction: {}", system_count_after_first);
|
||||
|
||||
// Verify first compaction structure
|
||||
assert!(
|
||||
message_contains(history_after_first, MessageRole::User, "FIRST_SUMMARY"),
|
||||
"Should have first summary as User message"
|
||||
);
|
||||
assert!(
|
||||
message_contains(history_after_first, MessageRole::Assistant, "SECOND_ASSISTANT"),
|
||||
"Should have last assistant from first conversation"
|
||||
);
|
||||
|
||||
// === Second conversation (after first compaction) ===
|
||||
agent.execute_task("Start task 2", None, false).await.unwrap();
|
||||
agent.execute_task("Finish task 2", None, false).await.unwrap();
|
||||
|
||||
// === Second compaction ===
|
||||
agent.force_compact().await.unwrap();
|
||||
|
||||
let history_after_second = &agent.get_context_window().conversation_history;
|
||||
let system_count_after_second = history_after_second
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::System))
|
||||
.count();
|
||||
|
||||
eprintln!("\n=== After SECOND compaction ===");
|
||||
for (i, msg) in history_after_second.iter().enumerate() {
|
||||
eprintln!(
|
||||
" {}: {:?} - {}",
|
||||
i,
|
||||
msg.role,
|
||||
msg.content.chars().take(60).collect::<String>()
|
||||
);
|
||||
}
|
||||
eprintln!("System messages after second compaction: {}", system_count_after_second);
|
||||
|
||||
// === KEY ASSERTIONS ===
|
||||
|
||||
// 1. System message count should NOT increase after second compaction
|
||||
assert_eq!(
|
||||
system_count_after_first, system_count_after_second,
|
||||
"System message count should stay the same after second compaction (no bloat). First: {}, Second: {}",
|
||||
system_count_after_first, system_count_after_second
|
||||
);
|
||||
|
||||
// 2. Should have the NEW summary (SECOND_SUMMARY), not the old one
|
||||
assert!(
|
||||
message_contains(history_after_second, MessageRole::User, "SECOND_SUMMARY"),
|
||||
"Should have second summary as User message"
|
||||
);
|
||||
|
||||
// 3. Should NOT have the old summary anymore
|
||||
assert!(
|
||||
!message_contains(history_after_second, MessageRole::User, "FIRST_SUMMARY"),
|
||||
"Should NOT have first summary anymore - it should be replaced"
|
||||
);
|
||||
|
||||
// 4. Should have the last assistant from second conversation
|
||||
assert!(
|
||||
message_contains(history_after_second, MessageRole::Assistant, "FOURTH_ASSISTANT"),
|
||||
"Should have last assistant from second conversation"
|
||||
);
|
||||
|
||||
// 5. Should NOT have old assistant messages
|
||||
assert!(
|
||||
!message_contains(history_after_second, MessageRole::Assistant, "SECOND_ASSISTANT"),
|
||||
"Should NOT have assistant from first conversation"
|
||||
);
|
||||
|
||||
// 6. Verify the clean structure: System... -> User (summary) -> Assistant -> User
|
||||
let non_system_messages: Vec<_> = history_after_second
|
||||
.iter()
|
||||
.filter(|m| !matches!(m.role, MessageRole::System))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
non_system_messages.len() >= 3,
|
||||
"Should have at least 3 non-system messages (summary, assistant, user)"
|
||||
);
|
||||
|
||||
// First non-system should be User (summary)
|
||||
assert!(
|
||||
matches!(non_system_messages[0].role, MessageRole::User),
|
||||
"First non-system message should be User (summary), got {:?}",
|
||||
non_system_messages[0].role
|
||||
);
|
||||
|
||||
// Second non-system should be Assistant
|
||||
assert!(
|
||||
matches!(non_system_messages[1].role, MessageRole::Assistant),
|
||||
"Second non-system message should be Assistant, got {:?}",
|
||||
non_system_messages[1].role
|
||||
);
|
||||
|
||||
// Third non-system should be User (latest)
|
||||
assert!(
|
||||
matches!(non_system_messages[2].role, MessageRole::User),
|
||||
"Third non-system message should be User (latest), got {:?}",
|
||||
non_system_messages[2].role
|
||||
);
|
||||
|
||||
eprintln!("\n✅ Second compaction maintains clean structure without bloat!");
|
||||
}
|
||||
|
||||
@@ -41,9 +41,9 @@ fn test_reset_with_summary_preserves_system_prompt() {
|
||||
&first_message.content[..first_message.content.len().min(100)]
|
||||
);
|
||||
|
||||
// Verify the summary was added as a separate system message
|
||||
// Verify the summary was added as a User message (for proper alternation)
|
||||
let has_summary = context.conversation_history.iter().any(|m| {
|
||||
matches!(m.role, MessageRole::System) && m.content.contains("Previous conversation summary")
|
||||
matches!(m.role, MessageRole::User) && m.content.contains("Previous conversation summary")
|
||||
});
|
||||
assert!(has_summary, "Should have a summary message");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user