use forge_domain::{ Compact, CompactionStrategy, Context, ContextMessage, ContextSummary, Environment, MessageEntry, Transformer, }; use tracing::info; use crate::TemplateEngine; use crate::transformers::SummaryTransformer; /// A service dedicated to handling context compaction. pub struct Compactor { compact: Compact, environment: Environment, } impl Compactor { pub fn new(compact: Compact, environment: Environment) -> Self { Self { compact, environment } } /// Applies the standard compaction transformer pipeline to a context /// summary. /// /// This pipeline uses the `Compaction` transformer which: /// 1. Drops system role messages /// 2. Deduplicates consecutive user messages /// 3. Trims context by keeping only the last operation per file path /// 4. Deduplicates consecutive assistant content blocks /// 5. Strips working directory prefix from file paths /// /// # Arguments /// /// * `context_summary` - The context summary to transform fn transform(&self, context_summary: ContextSummary) -> ContextSummary { SummaryTransformer::new(&self.environment.cwd).transform(context_summary) } } impl Compactor { /// Apply compaction to the context if requested. pub fn compact(&self, context: Context, max: bool) -> anyhow::Result { let eviction = CompactionStrategy::evict(self.compact.eviction_window); let retention = CompactionStrategy::retain(self.compact.retention_window); let strategy = if max { // TODO: Consider using `eviction.max(retention)` retention } else { eviction.min(retention) }; match strategy.eviction_range(&context) { Some(sequence) => self.compress_single_sequence(context, sequence), None => Ok(context), } } /// Compress a single identified sequence of assistant messages. fn compress_single_sequence( &self, mut context: Context, sequence: (usize, usize), ) -> anyhow::Result { let (start, end) = sequence; // The sequence from the original message that needs to be compacted // Filter out droppable messages (e.g., attachments) from compaction let compaction_sequence = context .messages .get(start..=end) .map(|slice| { slice .iter() .filter(|msg| !msg.is_droppable()) .cloned() .collect::>() }) .unwrap_or_else(|| { tracing::error!( "Compaction range [{}..={}] out of bounds for {} messages", start, end, context.messages.len() ); Vec::new() }); // Create a temporary context for the sequence to generate summary let sequence_context = Context::default().messages(compaction_sequence.clone()); // Generate context summary with tool call information let context_summary = ContextSummary::from(&sequence_context); // Apply transformers to reduce redundant operations and clean up let context_summary = self.transform(context_summary); info!( sequence_start = sequence.0, sequence_end = sequence.1, sequence_length = compaction_sequence.len(), "Created context compaction summary" ); let summary = TemplateEngine::default().render( "forge-partial-summary-frame.md", &serde_json::json!({"messages": context_summary.messages}), )?; // Extended thinking reasoning chain preservation // // Extended thinking requires the first assistant message to have // reasoning_details for subsequent messages to maintain reasoning // chains. After compaction, this consistency can break if the first // remaining assistant lacks reasoning. // // Solution: Extract the LAST reasoning from compacted messages and inject it // into the first assistant message after compaction. This preserves // chain continuity while preventing exponential accumulation across // multiple compactions. // // Example: [U, A+r, U, A+r, U, A] → compact → [U-summary, A+r, U, A] // └─from last // compacted let reasoning_details = compaction_sequence .iter() .rev() // Get LAST reasoning (most recent) .find_map(|msg| match &**msg { ContextMessage::Text(text) => text .reasoning_details .as_ref() .filter(|rd| !rd.is_empty()) .cloned(), _ => None, }); // Accumulate usage from all messages in the compaction range before they are // destroyed let compacted_usage = context.messages.get(start..=end).and_then(|slice| { slice .iter() .filter_map(|entry| entry.usage.as_ref()) .cloned() .reduce(|a, b| a.accumulate(&b)) }); // Replace the range with the summary, transferring the accumulated usage let mut summary_entry = MessageEntry::from(ContextMessage::user(summary, None)); summary_entry.usage = compacted_usage; context .messages .splice(start..=end, std::iter::once(summary_entry)); // Remove all droppable messages from the context context.messages.retain(|msg| !msg.is_droppable()); // Inject preserved reasoning into first assistant message (if empty) if let Some(reasoning) = reasoning_details && let Some(ContextMessage::Text(msg)) = context .messages .iter_mut() .find(|msg| msg.has_role(forge_domain::Role::Assistant)) .map(|msg| &mut **msg) && msg .reasoning_details .as_ref() .is_none_or(|rd| rd.is_empty()) { msg.reasoning_details = Some(reasoning); } Ok(context) } } #[cfg(test)] mod tests { use std::path::PathBuf; use forge_domain::MessageEntry; use pretty_assertions::assert_eq; use super::*; fn test_environment() -> Environment { use fake::{Fake, Faker}; let env: Environment = Faker.fake(); env.cwd(std::path::PathBuf::from("/test/working/dir")) } #[test] fn test_compress_single_sequence_preserves_only_last_reasoning() { use forge_domain::ReasoningFull; let environment = test_environment(); let compactor = Compactor::new(Compact::new(), environment); let first_reasoning = vec![ReasoningFull { text: Some("First thought".to_string()), signature: Some("sig1".to_string()), ..Default::default() }]; let last_reasoning = vec![ReasoningFull { text: Some("Last thought".to_string()), signature: Some("sig2".to_string()), ..Default::default() }]; let context = Context::default() .add_message(ContextMessage::user("M1", None)) .add_message(ContextMessage::assistant( "R1", None, Some(first_reasoning.clone()), None, )) .add_message(ContextMessage::user("M2", None)) .add_message(ContextMessage::assistant( "R2", None, Some(last_reasoning.clone()), None, )) .add_message(ContextMessage::user("M3", None)) .add_message(ContextMessage::assistant("R3", None, None, None)); let actual = compactor.compress_single_sequence(context, (0, 3)).unwrap(); // Verify only LAST reasoning_details were preserved let assistant_msg = actual .messages .iter() .find(|msg| msg.has_role(forge_domain::Role::Assistant)) .expect("Should have an assistant message"); if let ContextMessage::Text(text_msg) = &**assistant_msg { assert_eq!( text_msg.reasoning_details.as_ref(), Some(&last_reasoning), "Should preserve only the last reasoning, not the first" ); } else { panic!("Expected TextMessage"); } } #[test] fn test_compress_single_sequence_no_reasoning_accumulation() { use forge_domain::ReasoningFull; let environment = test_environment(); let compactor = Compactor::new(Compact::new(), environment); let reasoning = vec![ReasoningFull { text: Some("Original thought".to_string()), signature: Some("sig1".to_string()), ..Default::default() }]; // First compaction let context = Context::default() .add_message(ContextMessage::user("M1", None)) .add_message(ContextMessage::assistant( "R1", None, Some(reasoning.clone()), None, )) .add_message(ContextMessage::user("M2", None)) .add_message(ContextMessage::assistant("R2", None, None, None)); let context = compactor.compress_single_sequence(context, (0, 1)).unwrap(); // Verify first assistant has the reasoning let first_assistant = context .messages .iter() .find(|msg| msg.has_role(forge_domain::Role::Assistant)) .unwrap(); if let ContextMessage::Text(text_msg) = &**first_assistant { assert_eq!(text_msg.reasoning_details.as_ref().unwrap().len(), 1); } // Second compaction - add more messages let context = context .add_message(ContextMessage::user("M3", None)) .add_message(ContextMessage::assistant("R3", None, None, None)); let context = compactor.compress_single_sequence(context, (0, 2)).unwrap(); // Verify reasoning didn't accumulate - should still be just 1 reasoning block let first_assistant = context .messages .iter() .find(|msg| msg.has_role(forge_domain::Role::Assistant)) .unwrap(); if let ContextMessage::Text(text_msg) = &**first_assistant { assert_eq!( text_msg.reasoning_details.as_ref().unwrap().len(), 1, "Reasoning should not accumulate across compactions" ); } } #[test] fn test_compress_single_sequence_filters_empty_reasoning() { use forge_domain::ReasoningFull; let environment = test_environment(); let compactor = Compactor::new(Compact::new(), environment); let non_empty_reasoning = vec![ReasoningFull { text: Some("Valid thought".to_string()), signature: Some("sig1".to_string()), ..Default::default() }]; // Most recent message in range has empty reasoning, earlier has non-empty let context = Context::default() .add_message(ContextMessage::user("M1", None)) .add_message(ContextMessage::assistant( "R1", None, Some(non_empty_reasoning.clone()), None, )) .add_message(ContextMessage::user("M2", None)) .add_message(ContextMessage::assistant("R2", None, Some(vec![]), None)) // Empty - most recent in range .add_message(ContextMessage::user("M3", None)) .add_message(ContextMessage::assistant("R3", None, None, None)); // Outside range let actual = compactor.compress_single_sequence(context, (0, 3)).unwrap(); // After compression: [U-summary, U3, A3] // The reasoning from R1 (non-empty) should be injected into A3 let assistant_msg = actual .messages .iter() .find(|msg| msg.has_role(forge_domain::Role::Assistant)) .expect("Should have an assistant message"); if let ContextMessage::Text(text_msg) = &**assistant_msg { assert_eq!( text_msg.reasoning_details.as_ref(), Some(&non_empty_reasoning), "Should skip most recent empty reasoning and preserve earlier non-empty" ); } else { panic!("Expected TextMessage"); } } fn render_template(data: &serde_json::Value) -> String { TemplateEngine::default() .render("forge-partial-summary-frame.md", data) .unwrap() } #[test] fn test_template_engine_renders_summary_frame() { use forge_domain::{ContextSummary, Role, SummaryBlock, SummaryMessage, SummaryToolCall}; // Create test data with various tool calls and text content let messages = vec![ SummaryBlock::new( Role::User, vec![SummaryMessage::content("Please read the config file")], ), SummaryBlock::new( Role::Assistant, vec![ SummaryToolCall::read("config.toml") .id("call_1") .is_success(false) .into(), ], ), SummaryBlock::new( Role::User, vec![SummaryMessage::content("Now update the version number")], ), SummaryBlock::new( Role::Assistant, vec![SummaryToolCall::update("Cargo.toml").id("call_2").into()], ), SummaryBlock::new( Role::User, vec![SummaryMessage::content("Search for TODO comments")], ), SummaryBlock::new( Role::Assistant, vec![ SummaryToolCall::search("TODO") .id("call_3") .is_success(false) .into(), ], ), SummaryBlock::new( Role::Assistant, vec![ SummaryToolCall::codebase_search(vec![forge_domain::SearchQuery::new( "authentication logic", "Find authentication implementation", )]) .id("call_4") .is_success(false) .into(), ], ), SummaryBlock::new( Role::Assistant, vec![ SummaryToolCall::shell("cargo test") .id("call_5") .is_success(false) .into(), ], ), SummaryBlock::new( Role::User, vec![SummaryMessage::content("Great! Everything looks good.")], ), ]; let context_summary = ContextSummary { messages }; let data = serde_json::json!({"messages": context_summary.messages}); let actual = render_template(&data); insta::assert_snapshot!(actual); } #[test] fn test_template_engine_renders_todo_write() { use forge_domain::{ ContextSummary, Role, SummaryBlock, SummaryMessage, SummaryTool, SummaryToolCall, Todo, TodoChange, TodoChangeKind, TodoStatus, }; // Create test data with todo_write tool call showing a diff let changes = vec![ TodoChange { todo: Todo::new("Implement user authentication") .id("1") .status(TodoStatus::Completed), kind: TodoChangeKind::Updated, }, TodoChange { todo: Todo::new("Add database migrations") .id("2") .status(TodoStatus::InProgress), kind: TodoChangeKind::Added, }, TodoChange { todo: Todo::new("Write documentation") .id("3") .status(TodoStatus::Pending), kind: TodoChangeKind::Removed, }, ]; let messages = vec![ SummaryBlock::new( Role::User, vec![SummaryMessage::content("Create a task plan")], ), SummaryBlock::new( Role::Assistant, vec![ SummaryToolCall { id: Some(forge_domain::ToolCallId::new("call_1")), tool: SummaryTool::TodoWrite { changes }, is_success: true, } .into(), ], ), ]; let context_summary = ContextSummary { messages }; let data = serde_json::json!({"messages": context_summary.messages}); let actual = render_template(&data); insta::assert_snapshot!(actual); } #[tokio::test] async fn test_render_summary_frame_snapshot() { // Load the conversation fixture let fixture_json = forge_test_kit::fixture!("/src/fixtures/conversation.json").await; let conversation: forge_domain::Conversation = serde_json::from_str(&fixture_json).expect("Failed to parse conversation fixture"); // Extract context from conversation let context = conversation .context .expect("Conversation should have context"); // Create compactor instance for transformer access let environment = test_environment().cwd(PathBuf::from( "/Users/tushar/Documents/Projects/code-forge-workspace/code-forge", )); let compactor = Compactor::new(Compact::new(), environment); // Create context summary with tool call information let context_summary = ContextSummary::from(&context); // Apply transformers to reduce redundant operations and clean up let context_summary = compactor.transform(context_summary); let data = serde_json::json!({"messages": context_summary.messages}); let summary = render_template(&data); insta::assert_snapshot!(summary); // Perform a full compaction let compacted_context = compactor.compact(context, true).unwrap(); insta::assert_yaml_snapshot!(compacted_context); } #[test] fn test_compaction_removes_droppable_messages() { use forge_domain::{ContextMessage, Role, TextMessage}; let environment = test_environment(); let compactor = Compactor::new(Compact::new(), environment); // Create a context with droppable attachment messages let context = Context::default() .add_message(ContextMessage::user("User message 1", None)) .add_message(ContextMessage::assistant( "Assistant response 1", None, None, None, )) .add_message(ContextMessage::Text( TextMessage::new(Role::User, "Attachment content").droppable(true), )) .add_message(ContextMessage::user("User message 2", None)) .add_message(ContextMessage::assistant( "Assistant response 2", None, None, None, )); let actual = compactor.compress_single_sequence(context, (0, 1)).unwrap(); // The compaction should remove the droppable message // Expected: [U-summary, U2, A2] assert_eq!(actual.messages.len(), 3); // Verify the droppable attachment message was removed for msg in &actual.messages { if let ContextMessage::Text(text_msg) = &**msg { assert!(!text_msg.droppable, "Droppable messages should be removed"); } } } #[test] fn test_compaction_preserves_usage_information() { use forge_domain::{TokenCount, Usage}; let environment = test_environment(); let compactor = Compactor::new(Compact::new(), environment); // Usage on a message INSIDE the compaction range (index 1) let inside_usage = Usage { total_tokens: TokenCount::Actual(20000), prompt_tokens: TokenCount::Actual(18000), completion_tokens: TokenCount::Actual(2000), cached_tokens: TokenCount::Actual(0), cost: Some(0.5), }; // Usage on a message INSIDE the compaction range (index 3) let inside_usage2 = Usage { total_tokens: TokenCount::Actual(30000), prompt_tokens: TokenCount::Actual(27000), completion_tokens: TokenCount::Actual(3000), cached_tokens: TokenCount::Actual(0), cost: Some(1.0), }; // Usage on a message OUTSIDE the compaction range (index 5) let outside_usage = Usage { total_tokens: TokenCount::Actual(50000), prompt_tokens: TokenCount::Actual(45000), completion_tokens: TokenCount::Actual(5000), cached_tokens: TokenCount::Actual(0), cost: Some(1.5), }; let mut entry1 = MessageEntry::from(ContextMessage::assistant("Response 1", None, None, None)); entry1.usage = Some(inside_usage); let mut entry3 = MessageEntry::from(ContextMessage::assistant("Response 2", None, None, None)); entry3.usage = Some(inside_usage2); let mut entry5 = MessageEntry::from(ContextMessage::assistant("Response 3", None, None, None)); entry5.usage = Some(outside_usage); let context = Context::default() .add_entry(ContextMessage::user("Message 1", None)) .add_entry(entry1) // index 1: usage INSIDE range .add_entry(ContextMessage::user("Message 2", None)) .add_entry(entry3) // index 3: usage INSIDE range .add_entry(ContextMessage::user("Message 3", None)) .add_entry(entry5); // index 5: usage OUTSIDE range // Compact the sequence (first 4 messages, indices 0-3) let compacted = compactor.compress_single_sequence(context, (0, 3)).unwrap(); // Expected: [summary-entry, U3, A3] — 3 messages remain assert_eq!( compacted.messages.len(), 3, "Expected 3 messages after compaction: summary + 2 remaining messages" ); // The summary entry at index 0 should carry the accumulated usage from // indices 1 and 3 (inside_usage + inside_usage2) let expected_compacted_usage = Usage { total_tokens: TokenCount::Actual(50000), prompt_tokens: TokenCount::Actual(45000), completion_tokens: TokenCount::Actual(5000), cached_tokens: TokenCount::Actual(0), cost: Some(1.5), }; assert_eq!( compacted.messages[0].usage, Some(expected_compacted_usage), "Summary message should carry accumulated usage from compacted messages" ); // accumulate_usage() must sum both the compacted range usage (on the summary // message) and the surviving outside_usage — total = inside + inside2 + outside let expected_total_usage = Usage { total_tokens: TokenCount::Actual(100000), prompt_tokens: TokenCount::Actual(90000), completion_tokens: TokenCount::Actual(10000), cached_tokens: TokenCount::Actual(0), cost: Some(3.0), }; assert_eq!( compacted.accumulate_usage(), Some(expected_total_usage), "accumulate_usage() must include usage from both compacted and surviving messages" ); } /// Creates a Context from a condensed string pattern where: /// - 'u' = User message /// - 'a' = Assistant message /// - 's' = System message fn ctx(pattern: &str) -> Context { forge_domain::MessagePattern::new(pattern).build() } #[test] fn test_should_compact_no_thresholds_set() { let fixture = Compact::new().model("test-model"); let context = ctx("ua"); let actual = fixture.should_compact(&context, 1000); assert_eq!(actual, false); } #[test] fn test_should_compact_token_threshold_triggers() { let fixture = Compact::new() .model("test-model") .token_threshold(100_usize); let context = ctx("u"); let actual = fixture.should_compact(&context, 150); assert_eq!(actual, true); } #[test] fn test_should_compact_turn_threshold_triggers() { let fixture = Compact::new().model("test-model").turn_threshold(1_usize); let context = ctx("uau"); let actual = fixture.should_compact(&context, 50); assert_eq!(actual, true); } #[test] fn test_should_compact_message_threshold_triggers() { let fixture = Compact::new() .model("test-model") .message_threshold(2_usize); let context = ctx("uau"); let actual = fixture.should_compact(&context, 50); assert_eq!(actual, true); } #[test] fn test_should_compact_multiple_thresholds_any_triggers() { let fixture = Compact::new() .model("test-model") .token_threshold(200_usize) .turn_threshold(5_usize) .message_threshold(10_usize); let context = ctx("ua"); let actual = fixture.should_compact(&context, 250); assert_eq!(actual, true); } #[test] fn test_should_compact_multiple_thresholds_none_trigger() { let fixture = Compact::new() .model("test-model") .token_threshold(200_usize) .turn_threshold(5_usize) .message_threshold(10_usize); let context = ctx("ua"); let actual = fixture.should_compact(&context, 100); assert_eq!(actual, false); } #[test] fn test_should_compact_empty_context() { let fixture = Compact::new() .model("test-model") .message_threshold(1_usize); let context = ctx(""); let actual = fixture.should_compact(&context, 0); assert_eq!(actual, false); } #[test] fn test_should_compact_last_user_message_integration() { let fixture = Compact::new().model("test-model").on_turn_end(true); let context = ctx("au"); let actual = fixture.should_compact(&context, 10); assert_eq!(actual, true); } #[test] fn test_should_compact_last_user_message_integration_disabled() { let fixture = Compact::new().model("test-model").on_turn_end(false); let context = ctx("au"); let actual = fixture.should_compact(&context, 10); assert_eq!(actual, false); } #[test] fn test_should_compact_multiple_conditions_with_last_user_message() { let fixture = Compact::new() .model("test-model") .token_threshold(200_usize) .on_turn_end(true); let context = ctx("au"); let actual = fixture.should_compact(&context, 50); assert_eq!(actual, true); } #[test] fn test_compact_model_none_falls_back_to_agent_model() { let compact = Compact::new() .token_threshold(1000_usize) .turn_threshold(5_usize); assert_eq!(compact.model, None); assert_eq!(compact.token_threshold, Some(1000_usize)); assert_eq!(compact.turn_threshold, Some(5_usize)); } /// BUG 5: Context growth simulation showing how context_length_exceeded /// error occurs. /// /// This test simulates a conversation with codex-spark (128K context /// window) and default token_threshold of 100K. It shows how: /// 1. Context grows turn by turn without triggering compaction (below 100K /// threshold) /// 2. Each turn adds user message + tool outputs /// 3. Eventually context + tool outputs exceed 128K limit /// 4. API returns context_length_exceeded error /// /// Test that demonstrates how the fixed compaction threshold prevents /// context_length_exceeded errors. /// /// With the fix, token_threshold of 100K is capped to 89600 (70% of 128K), /// ensuring compaction triggers earlier to provide safety margin. #[test] fn test_safe_threshold_triggers_earlier_than_unsafe_threshold() { use forge_domain::{ContextMessage, ToolCallId, ToolName, ToolResult}; // Two configurations: unsafe (100K) vs safe (89.6K = 70% of 128K) let unsafe_compact = Compact::new() .token_threshold(100_000_usize) // Old unsafe threshold .max_tokens(2000_usize); let safe_compact = Compact::new() .token_threshold(89_600_usize) // Safe threshold (70% of 128K) .max_tokens(2000_usize); let _environment = test_environment(); // Start with initial context of 80000 tokens let mut unsafe_context = create_large_context(80_000); let mut safe_context = create_large_context(80_000); // Simulate 2 conversation turns for turn in 1..=2 { // Add same messages to both contexts let user_msg = ContextMessage::user(format!("Turn {}: Please analyze this file", turn), None); let assistant_msg = ContextMessage::assistant( format!("I'll analyze for turn {}", turn), None, None, None, ); unsafe_context = unsafe_context.add_message(user_msg.clone()); safe_context = safe_context.add_message(user_msg); unsafe_context = unsafe_context.add_message(assistant_msg.clone()); safe_context = safe_context.add_message(assistant_msg); // Add tool outputs for file_read in 1..=3 { let tool_result = ToolResult::new(ToolName::new("read")) .call_id(ToolCallId::new(format!("call_{}_{}", turn, file_read))) .success(create_large_content(5000)); unsafe_context = unsafe_context.add_tool_results(vec![tool_result.clone()]); safe_context = safe_context.add_tool_results(vec![tool_result]); } let unsafe_token_count = unsafe_context.token_count_approx(); let safe_token_count = safe_context.token_count_approx(); let _unsafe_should_compact = unsafe_compact.should_compact(&unsafe_context, unsafe_token_count); let _safe_should_compact = safe_compact.should_compact(&safe_context, safe_token_count); } // At turn 1: // - Unsafe threshold (100K): ~95K tokens, NO compaction (false) // - Safe threshold (89.6K): ~95K tokens, SHOULD compact (true) // // At turn 2: // - Unsafe threshold (100K): ~110K tokens, SHOULD compact (true) - but too // late! // - Safe threshold (89.6K): ~110K tokens, already compacted at turn 1 // Verify that safe threshold triggers at turn 1 (providing early warning) let safe_token_count_turn1 = 95_000; // Approximate let safe_should_compact_turn1 = safe_compact.should_compact(&safe_context, safe_token_count_turn1); // The key fix: safe threshold (89.6K) triggers at ~95K, while unsafe (100K) // doesn't This provides a safety margin before we hit the 128K limit assert!( safe_should_compact_turn1 || safe_token_count_turn1 < 89_600, "Safe threshold (89.6K) should trigger compaction at ~95K tokens to provide safety margin" ); // After 2 turns, both contexts are similar size (~110K) // But with safe threshold, compaction would have triggered earlier let final_unsafe = unsafe_context.token_count_approx(); let final_safe = safe_context.token_count_approx(); // Both should be identical since we're just testing threshold logic, not actual // compaction assert_eq!( final_unsafe, final_safe, "Both contexts should have same token count" ); // The important assertion: with unsafe 100K threshold, context can grow // to ~110K before compaction triggers, leaving only 18K // headroom for the 128K limit. With safe 89.6K threshold, // compaction triggers at ~95K, leaving 33K headroom. // // This extra headroom is critical because tool outputs can add 15K+ // tokens per turn, and without early compaction, context + tool // outputs can exceed 128K limit. } /// Helper to create a large context with approximately `token_count` tokens fn create_large_context(token_count: usize) -> Context { use forge_domain::ContextMessage; // Each char is ~0.25 tokens (4 chars per token) let char_count = token_count * 4; let content = "x".repeat(char_count); // Split into multiple messages to avoid single huge message let messages_needed = 10; let content_per_message = content.len() / messages_needed; let mut context = Context::default(); for i in 0..messages_needed { let start = i * content_per_message; let end = ((i + 1) * content_per_message).min(content.len()); let msg_content = &content[start..end]; if i % 2 == 0 { context = context.add_message(ContextMessage::user(msg_content, None)); } else { context = context.add_message(ContextMessage::assistant(msg_content, None, None, None)); } } context } /// Helper to create large content of approximately `token_count` tokens fn create_large_content(token_count: usize) -> String { // 4 chars per token approximation "x".repeat(token_count * 4) } }