aa05308803
Signed-off-by: clayarnoldg2m <carnold@g2m.ai> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
749 lines
26 KiB
Rust
749 lines
26 KiB
Rust
use anyhow::Result;
|
|
use async_trait::async_trait;
|
|
use futures::StreamExt;
|
|
use goose::agents::{Agent, AgentEvent, SessionConfig};
|
|
use goose::conversation::message::{Message, MessageContent};
|
|
use goose::conversation::Conversation;
|
|
use goose::model::ModelConfig;
|
|
use goose::providers::base::{
|
|
stream_from_single_message, MessageStream, Provider, ProviderDef, ProviderMetadata,
|
|
ProviderUsage, Usage,
|
|
};
|
|
use goose::providers::errors::ProviderError;
|
|
use goose::session::session_manager::SessionType;
|
|
use goose::session::Session;
|
|
use rmcp::model::Tool;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use tempfile::TempDir;
|
|
|
|
struct MockCompactionProvider {
|
|
/// Tracks whether compaction has occurred (for context limit recovery case)
|
|
has_compacted: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl MockCompactionProvider {
|
|
fn new() -> Self {
|
|
Self {
|
|
has_compacted: Arc::new(AtomicBool::new(false)),
|
|
}
|
|
}
|
|
|
|
/// Calculate input tokens based on system prompt and messages
|
|
/// Simulates realistic token counts for different scenarios
|
|
fn calculate_input_tokens(&self, system_prompt: &str, messages: &[Message]) -> i32 {
|
|
// Check if this is a compaction call
|
|
let is_compaction_call = messages.len() == 1
|
|
&& messages[0].content.iter().any(|c| {
|
|
if let MessageContent::Text(text) = c {
|
|
text.text.to_lowercase().contains("summarize")
|
|
} else {
|
|
false
|
|
}
|
|
});
|
|
|
|
if is_compaction_call {
|
|
// For compaction: system prompt length is a good proxy for conversation size
|
|
// Base: 6000 (system) + conversation content embedded in prompt
|
|
6000 + (system_prompt.len() as i32 / 4).max(400)
|
|
} else {
|
|
// Regular call: system prompt + messages
|
|
let system_tokens = if system_prompt.is_empty() { 0 } else { 6000 };
|
|
|
|
let message_tokens: i32 = messages
|
|
.iter()
|
|
.map(|msg| {
|
|
let mut tokens = 100;
|
|
for content in &msg.content {
|
|
if let MessageContent::Text(text) = content {
|
|
if text.text.contains("long_tool_call") {
|
|
tokens += 15000;
|
|
}
|
|
}
|
|
}
|
|
tokens
|
|
})
|
|
.sum();
|
|
|
|
system_tokens + message_tokens
|
|
}
|
|
}
|
|
|
|
/// Calculate output tokens based on response type
|
|
fn calculate_output_tokens(&self, is_compaction: bool, messages: &[Message]) -> i32 {
|
|
if is_compaction {
|
|
// Compaction produces a compact summary
|
|
200
|
|
} else {
|
|
// Regular responses vary by content
|
|
let has_hello = messages.iter().any(|msg| {
|
|
msg.content.iter().any(|c| {
|
|
if let MessageContent::Text(text) = c {
|
|
text.text.to_lowercase().contains("hello")
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
});
|
|
|
|
if has_hello {
|
|
50 // Simple greeting response
|
|
} else {
|
|
100 // Default response
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Provider for MockCompactionProvider {
|
|
async fn stream(
|
|
&self,
|
|
_model_config: &ModelConfig,
|
|
_session_id: &str,
|
|
system_prompt: &str,
|
|
messages: &[Message],
|
|
_tools: &[Tool],
|
|
) -> Result<MessageStream, ProviderError> {
|
|
// Check if this is a compaction call (message contains "summarize")
|
|
let is_compaction = messages.iter().any(|msg| {
|
|
msg.content.iter().any(|content| {
|
|
if let MessageContent::Text(text) = content {
|
|
text.text.to_lowercase().contains("summarize")
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
});
|
|
|
|
// Calculate realistic token counts based on actual content
|
|
let input_tokens = self.calculate_input_tokens(system_prompt, messages);
|
|
let output_tokens = self.calculate_output_tokens(is_compaction, messages);
|
|
|
|
// Simulate context limit: if input > 20k tokens and we haven't compacted yet, fail
|
|
const CONTEXT_LIMIT: i32 = 20000;
|
|
if !is_compaction
|
|
&& input_tokens > CONTEXT_LIMIT
|
|
&& !self.has_compacted.load(Ordering::SeqCst)
|
|
{
|
|
return Err(ProviderError::ContextLengthExceeded(format!(
|
|
"Context limit exceeded: {} > {}",
|
|
input_tokens, CONTEXT_LIMIT
|
|
)));
|
|
}
|
|
|
|
// If this is a compaction call, mark that we've compacted
|
|
if is_compaction {
|
|
self.has_compacted.store(true, Ordering::SeqCst);
|
|
}
|
|
|
|
// Generate response
|
|
let message = if is_compaction {
|
|
Message::assistant().with_text("<mock summary of conversation>")
|
|
} else {
|
|
let response_text = if messages.iter().any(|msg| {
|
|
msg.content.iter().any(|c| {
|
|
if let MessageContent::Text(text) = c {
|
|
text.text.to_lowercase().contains("hello")
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
}) {
|
|
"Hi there! How can I help you?"
|
|
} else {
|
|
"This is a mock response."
|
|
};
|
|
Message::assistant().with_text(response_text)
|
|
};
|
|
|
|
let usage = ProviderUsage::new(
|
|
"mock-model".to_string(),
|
|
Usage::new(
|
|
Some(input_tokens),
|
|
Some(output_tokens),
|
|
Some(input_tokens + output_tokens),
|
|
),
|
|
);
|
|
|
|
Ok(stream_from_single_message(message, usage))
|
|
}
|
|
|
|
fn get_model_config(&self) -> ModelConfig {
|
|
ModelConfig::new("mock-model").unwrap()
|
|
}
|
|
|
|
fn get_name(&self) -> &str {
|
|
"mock-compaction"
|
|
}
|
|
}
|
|
|
|
impl ProviderDef for MockCompactionProvider {
|
|
type Provider = Self;
|
|
|
|
fn metadata() -> ProviderMetadata {
|
|
ProviderMetadata {
|
|
name: "mock".to_string(),
|
|
display_name: "Mock Compaction Provider".to_string(),
|
|
description: "Mock provider for compaction testing".to_string(),
|
|
default_model: "mock-model".to_string(),
|
|
known_models: vec![],
|
|
model_doc_link: "".to_string(),
|
|
config_keys: vec![],
|
|
}
|
|
}
|
|
|
|
fn from_env(
|
|
_model: ModelConfig,
|
|
_extensions: Vec<goose::config::ExtensionConfig>,
|
|
) -> futures::future::BoxFuture<'static, anyhow::Result<Self>> {
|
|
Box::pin(async { Ok(Self::new()) })
|
|
}
|
|
}
|
|
|
|
/// Helper: Set up a test session with initial messages and token counts
|
|
async fn setup_test_session(
|
|
agent: &Agent,
|
|
temp_dir: &TempDir,
|
|
session_name: &str,
|
|
messages: Vec<Message>,
|
|
) -> Result<Session> {
|
|
let session = agent
|
|
.config
|
|
.session_manager
|
|
.create_session(
|
|
temp_dir.path().to_path_buf(),
|
|
session_name.to_string(),
|
|
SessionType::Hidden,
|
|
)
|
|
.await?;
|
|
|
|
let conversation = Conversation::new_unvalidated(messages);
|
|
agent
|
|
.config
|
|
.session_manager
|
|
.replace_conversation(&session.id, &conversation)
|
|
.await?;
|
|
|
|
// Set initial token counts
|
|
agent
|
|
.config
|
|
.session_manager
|
|
.update(&session.id)
|
|
.total_tokens(Some(1000))
|
|
.input_tokens(Some(600))
|
|
.output_tokens(Some(400))
|
|
.accumulated_total_tokens(Some(1000))
|
|
.accumulated_input_tokens(Some(600))
|
|
.accumulated_output_tokens(Some(400))
|
|
.apply()
|
|
.await?;
|
|
|
|
Ok(session)
|
|
}
|
|
|
|
/// Helper: Assert conversation has been compacted with proper message visibility
|
|
fn assert_conversation_compacted(conversation: &Conversation) {
|
|
let messages = conversation.messages();
|
|
assert!(!messages.is_empty(), "Conversation should not be empty");
|
|
|
|
// Find the summary message (contains "mock summary")
|
|
let summary_index = messages
|
|
.iter()
|
|
.position(|msg| {
|
|
msg.content.iter().any(|content| {
|
|
if let MessageContent::Text(text) = content {
|
|
text.text.contains("mock summary")
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
})
|
|
.expect("Conversation should contain the summary message");
|
|
|
|
let summary_msg = &messages[summary_index];
|
|
|
|
// Assert summary message visibility
|
|
assert!(
|
|
summary_msg.is_agent_visible(),
|
|
"Summary message should be agent visible"
|
|
);
|
|
assert!(
|
|
!summary_msg.is_user_visible(),
|
|
"Summary message should NOT be user visible"
|
|
);
|
|
|
|
// Check messages BEFORE the summary (the compacted original messages)
|
|
// These should be made agent-invisible
|
|
for (idx, msg) in messages.iter().enumerate() {
|
|
if idx < summary_index {
|
|
// Old messages before summary: agent can't see them
|
|
assert!(
|
|
!msg.is_agent_visible(),
|
|
"Message before summary at index {} should be agent-invisible",
|
|
idx
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check for continuation message after summary
|
|
// (Should exist and be agent-only)
|
|
if summary_index + 1 < messages.len() {
|
|
let continuation_msg = &messages[summary_index + 1];
|
|
// Continuation message should contain instructions about not mentioning summary
|
|
let has_continuation_text = continuation_msg.content.iter().any(|content| {
|
|
if let MessageContent::Text(text) = content {
|
|
text.text.contains("previous message contains a summary")
|
|
|| text.text.contains("summarization occurred")
|
|
} else {
|
|
false
|
|
}
|
|
});
|
|
|
|
if has_continuation_text {
|
|
assert!(
|
|
continuation_msg.is_agent_visible(),
|
|
"Continuation message should be agent visible"
|
|
);
|
|
assert!(
|
|
!continuation_msg.is_user_visible(),
|
|
"Continuation message should NOT be user visible"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Any messages AFTER the continuation (e.g., preserved recent user message)
|
|
// should be fully visible to both agent and user
|
|
let continuation_end = summary_index + 2;
|
|
for (idx, msg) in messages.iter().enumerate() {
|
|
if idx >= continuation_end {
|
|
assert!(
|
|
msg.is_agent_visible() && msg.is_user_visible(),
|
|
"Message after compaction at index {} should be fully visible",
|
|
idx
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_manual_compaction_updates_token_counts_and_conversation() -> Result<()> {
|
|
let temp_dir = TempDir::new()?;
|
|
let agent = Agent::new();
|
|
|
|
// Setup session with initial messages
|
|
// Each message ~100 tokens, so 4 messages = ~400 tokens in conversation
|
|
let messages = vec![
|
|
Message::user().with_text("Hello, can you help me with something?"),
|
|
Message::assistant().with_text("Of course! What do you need help with?"),
|
|
Message::user().with_text("I need to understand how compaction works."),
|
|
Message::assistant()
|
|
.with_text("Compaction is a process that summarizes conversation history."),
|
|
];
|
|
|
|
let session = setup_test_session(&agent, &temp_dir, "manual-compact-test", messages).await?;
|
|
|
|
// Setup mock provider
|
|
let provider = Arc::new(MockCompactionProvider::new());
|
|
agent.update_provider(provider, &session.id).await?;
|
|
|
|
// Execute manual compaction
|
|
let result = agent.execute_command("/compact", &session.id).await?;
|
|
assert!(result.is_some(), "Compaction should return a result");
|
|
|
|
// Verify token counts
|
|
let updated_session = agent
|
|
.config
|
|
.session_manager
|
|
.get_session(&session.id, true)
|
|
.await?;
|
|
|
|
// Expected token calculation for compaction:
|
|
// During compaction, the 4 messages are embedded in the system prompt template
|
|
// - Input: system prompt with embedded conversation + "Please summarize" message
|
|
// - Output: summary (200 tokens)
|
|
//
|
|
// From mock provider calculation:
|
|
// - System prompt (with 4 embedded messages): varies based on template + content
|
|
// - Single "summarize" message: 100 tokens
|
|
// - Total input observed: ~6100 tokens
|
|
//
|
|
// After compaction:
|
|
// - current input_tokens = summary output (200) - the new compact context
|
|
// - current output_tokens = None (compaction doesn't produce new output)
|
|
// - current total_tokens = 200
|
|
// - accumulated_total = initial (1000) + compaction cost
|
|
let expected_summary_output = 200; // compact summary
|
|
|
|
// Verify the key invariants after manual compaction:
|
|
// After compaction, the current context is ONLY the summary (200 tokens)
|
|
// This is the new agent-visible input context
|
|
assert_eq!(
|
|
updated_session.input_tokens,
|
|
Some(expected_summary_output),
|
|
"Input tokens should be exactly the summary output (200 tokens)"
|
|
);
|
|
assert_eq!(
|
|
updated_session.output_tokens, None,
|
|
"Output tokens should be None after compaction (no new assistant output)"
|
|
);
|
|
assert_eq!(
|
|
updated_session.total_tokens,
|
|
Some(expected_summary_output),
|
|
"Total should equal input (200 tokens) after compaction"
|
|
);
|
|
|
|
// Accumulated tokens increased by the compaction cost
|
|
// Initial: 1000
|
|
// Compaction input: ~6400 (system 6000 + 4 messages ~400)
|
|
// Compaction output: 200
|
|
// Expected accumulated: 1000 + 6400 + 200 = 7600
|
|
let accumulated = updated_session.accumulated_total_tokens.unwrap();
|
|
assert!(
|
|
(7300..=7900).contains(&accumulated),
|
|
"Accumulated should be ~7600 (1000 initial + 6400 input + 200 output). Got: {}",
|
|
accumulated
|
|
);
|
|
|
|
// Verify conversation has been compacted
|
|
let compacted_conversation = updated_session
|
|
.conversation
|
|
.expect("Session should have conversation");
|
|
|
|
assert_conversation_compacted(&compacted_conversation);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_auto_compaction_during_reply() -> Result<()> {
|
|
let temp_dir = TempDir::new()?;
|
|
let agent = Agent::new();
|
|
|
|
// Setup session with many messages to have substantial context
|
|
// 20 exchanges = 40 messages * 100 tokens = ~4000 tokens in conversation
|
|
let mut messages = vec![];
|
|
for i in 0..20 {
|
|
messages.push(Message::user().with_text(format!("User message {}", i)));
|
|
messages.push(Message::assistant().with_text(format!("Assistant response {}", i)));
|
|
}
|
|
|
|
let session = setup_test_session(&agent, &temp_dir, "auto-compact-test", messages).await?;
|
|
|
|
// Capture initial context size before triggering reply
|
|
// Should be: system (6000) + 40 messages (4000) = ~10000 tokens
|
|
let initial_session = agent
|
|
.config
|
|
.session_manager
|
|
.get_session(&session.id, true)
|
|
.await?;
|
|
let initial_input_tokens = initial_session.input_tokens.unwrap_or(0);
|
|
|
|
// Setup mock provider (no context limit enforcement)
|
|
let provider = Arc::new(MockCompactionProvider::new());
|
|
agent.update_provider(provider, &session.id).await?;
|
|
|
|
// Trigger a reply
|
|
// Expected tokens for reply:
|
|
// - Input: system (6000) + 40 messages (4000) + new user message (100) = 10100 tokens
|
|
// - Output: regular response (100 tokens)
|
|
let user_message = Message::user().with_text("Tell me more about compaction");
|
|
|
|
let session_config = SessionConfig {
|
|
id: session.id.clone(),
|
|
schedule_id: None,
|
|
max_turns: None,
|
|
retry_config: None,
|
|
};
|
|
|
|
let reply_stream = agent.reply(user_message, session_config, None).await?;
|
|
tokio::pin!(reply_stream);
|
|
|
|
// Track compaction and context size changes
|
|
let mut compaction_occurred = false;
|
|
let mut input_tokens_after_compaction: Option<i32> = None;
|
|
|
|
while let Some(event_result) = reply_stream.next().await {
|
|
match event_result {
|
|
Ok(AgentEvent::HistoryReplaced(_)) => {
|
|
compaction_occurred = true;
|
|
|
|
// Capture the input tokens immediately after compaction
|
|
let session_after_compact = agent
|
|
.config
|
|
.session_manager
|
|
.get_session(&session.id, true)
|
|
.await?;
|
|
input_tokens_after_compaction = session_after_compact.input_tokens;
|
|
}
|
|
Ok(_) => {}
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
|
|
let updated_session = agent
|
|
.config
|
|
.session_manager
|
|
.get_session(&session.id, true)
|
|
.await?;
|
|
|
|
if compaction_occurred {
|
|
// Verify that current input context decreased after compaction
|
|
let tokens_after =
|
|
input_tokens_after_compaction.expect("Should have captured tokens after compaction");
|
|
|
|
// Before compaction: system (6000) + 40 messages (4000) = 10,000 tokens
|
|
// After compaction: only the summary (200 tokens) - this becomes the new input
|
|
assert!(
|
|
tokens_after < initial_input_tokens,
|
|
"Input tokens should decrease after compaction. Before: {}, After: {}",
|
|
initial_input_tokens,
|
|
tokens_after
|
|
);
|
|
|
|
// After compaction, input should be exactly the summary: 200 tokens
|
|
assert_eq!(
|
|
tokens_after, 200,
|
|
"Input tokens after compaction should be exactly 200 (summary). Got: {}",
|
|
tokens_after
|
|
);
|
|
|
|
// After the subsequent reply, the current window includes:
|
|
// - system (6000) + summary (200) + new user message (100) + reply (100) = 6400
|
|
let final_input = updated_session.input_tokens.unwrap();
|
|
let final_output = updated_session.output_tokens.unwrap();
|
|
let final_total = updated_session.total_tokens.unwrap();
|
|
|
|
assert!(
|
|
final_input >= 6000,
|
|
"Final input should include at least system prompt (6000). Got: {}",
|
|
final_input
|
|
);
|
|
assert_eq!(
|
|
final_output, 100,
|
|
"Final output should be 100 tokens (default response). Got: {}",
|
|
final_output
|
|
);
|
|
assert_eq!(
|
|
final_total,
|
|
final_input + final_output,
|
|
"Final total should equal input + output"
|
|
);
|
|
|
|
// Accumulated tokens should include:
|
|
// - Initial: 1000
|
|
// - Compaction: ~10,400 input + 200 output = 10,600
|
|
// - Reply: ~6,300 input + 100 output = 6,400
|
|
// Total: 1000 + 10,600 + 6,400 = 18,000
|
|
let accumulated = updated_session.accumulated_total_tokens.unwrap();
|
|
assert!(
|
|
(17000..=19000).contains(&accumulated),
|
|
"Accumulated should be ~18,000 (initial + compaction + reply). Got: {}",
|
|
accumulated
|
|
);
|
|
} else {
|
|
// If no compaction, accumulated should include reply cost
|
|
// - Initial: 1000
|
|
// - Reply: system (6000) + 40 messages (4000) + new message (100) = 10,100 input
|
|
// - Reply output: 100
|
|
// Total: 1000 + 10,100 + 100 = 11,200
|
|
let accumulated = updated_session.accumulated_total_tokens.unwrap();
|
|
assert!(
|
|
(11000..=11500).contains(&accumulated),
|
|
"Accumulated should be ~11,200 (initial + reply). Got: {}",
|
|
accumulated
|
|
);
|
|
|
|
// Current window should be: 10,100 input + 100 output = 10,200
|
|
let final_input = updated_session.input_tokens.unwrap();
|
|
let final_output = updated_session.output_tokens.unwrap();
|
|
|
|
assert!(
|
|
(10000..=10500).contains(&final_input),
|
|
"Input should be ~10,100. Got: {}",
|
|
final_input
|
|
);
|
|
assert_eq!(
|
|
final_output, 100,
|
|
"Output should be 100. Got: {}",
|
|
final_output
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_context_limit_recovery_compaction() -> Result<()> {
|
|
let temp_dir = TempDir::new()?;
|
|
let agent = Agent::new();
|
|
|
|
// Setup session with messages that will push context over the limit
|
|
// Each message = 100 tokens, but we'll add a large one
|
|
let messages = vec![
|
|
Message::user().with_text("Hello"),
|
|
Message::assistant().with_text("Hi there"),
|
|
Message::user().with_text("Can you process this long_tool_call result?"),
|
|
Message::assistant().with_text("Processing..."),
|
|
];
|
|
// Token calculation:
|
|
// - 3 regular messages: 300 tokens
|
|
// - 1 message with "long_tool_call": 100 + 15000 = 15100 tokens
|
|
// - Total conversation: ~15400 tokens
|
|
// - With system prompt (6000): 21400 tokens
|
|
|
|
let session = setup_test_session(&agent, &temp_dir, "context-limit-test", messages).await?;
|
|
|
|
// Setup mock provider with context limit of 20000 tokens
|
|
// Initial context (6000 system + 15400 messages = 21400) exceeds this limit
|
|
let provider = Arc::new(MockCompactionProvider::new());
|
|
agent.update_provider(provider, &session.id).await?;
|
|
|
|
// Try to send a message - should trigger context limit, then recover via compaction
|
|
let session_config = SessionConfig {
|
|
id: session.id.clone(),
|
|
schedule_id: None,
|
|
max_turns: None,
|
|
retry_config: None,
|
|
};
|
|
|
|
let reply_stream = agent
|
|
.reply(
|
|
Message::user().with_text("Tell me more"),
|
|
session_config,
|
|
None,
|
|
)
|
|
.await?;
|
|
tokio::pin!(reply_stream);
|
|
|
|
// Track compaction and context size changes
|
|
let mut compaction_occurred = false;
|
|
let mut got_response = false;
|
|
let mut input_tokens_after_compaction: Option<i32> = None;
|
|
|
|
while let Some(event_result) = reply_stream.next().await {
|
|
match event_result {
|
|
Ok(AgentEvent::HistoryReplaced(_)) => {
|
|
compaction_occurred = true;
|
|
|
|
// Capture the input tokens immediately after compaction
|
|
let session_after_compact = agent
|
|
.config
|
|
.session_manager
|
|
.get_session(&session.id, true)
|
|
.await?;
|
|
input_tokens_after_compaction = session_after_compact.input_tokens;
|
|
}
|
|
Ok(AgentEvent::Message(msg)) => {
|
|
// Check if we got a real response (not just a notification)
|
|
if msg
|
|
.content
|
|
.iter()
|
|
.any(|c| matches!(c, MessageContent::Text(_)))
|
|
{
|
|
got_response = true;
|
|
}
|
|
}
|
|
Ok(_) => {}
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
|
|
// Verify recovery occurred
|
|
assert!(
|
|
compaction_occurred,
|
|
"Compaction should have occurred due to context limit (>20000 tokens)"
|
|
);
|
|
assert!(
|
|
got_response,
|
|
"Should have received a response after recovery"
|
|
);
|
|
|
|
// Verify token counts
|
|
let updated_session = agent
|
|
.config
|
|
.session_manager
|
|
.get_session(&session.id, true)
|
|
.await?;
|
|
|
|
// Expected token flow:
|
|
// 1. Initial attempt: >20000 tokens -> Context limit exceeded
|
|
// 2. Compaction triggered:
|
|
// - Input: system prompt + messages (including long_tool_call with 15k tokens)
|
|
// - Output: 200 tokens (summary)
|
|
// - New context size: 200 tokens
|
|
// 3. Retry with compacted context:
|
|
// - Input: system prompt + summary (200) + new message
|
|
// - Output: 100 tokens (response)
|
|
|
|
// Verify that current input context is dramatically reduced after compaction
|
|
let tokens_after =
|
|
input_tokens_after_compaction.expect("Should have captured tokens after compaction");
|
|
|
|
// After compaction, the input context should be ONLY the summary: 200 tokens
|
|
// Before: system (6000) + long_tool_call messages (~15,400) = 21,400 (exceeded limit!)
|
|
// After: only summary (200 tokens)
|
|
assert_eq!(
|
|
tokens_after, 200,
|
|
"Input tokens after compaction should be exactly 200 (summary only). Got: {}",
|
|
tokens_after
|
|
);
|
|
|
|
// The compacted context is now well under the 20k limit
|
|
assert!(
|
|
tokens_after < 20000,
|
|
"Compacted context should be under 20k limit. Got: {}",
|
|
tokens_after
|
|
);
|
|
|
|
// Check the final token state after recovery
|
|
// Note: The session state reflects the RETRY call (after compaction),
|
|
// which only sees agent-visible messages (summary + continuation + user message)
|
|
let final_input = updated_session.input_tokens.unwrap();
|
|
let final_output = updated_session.output_tokens;
|
|
let final_total = updated_session.total_tokens.unwrap();
|
|
|
|
// After compaction, the retry only sees agent-visible messages:
|
|
// Input: system (6000) + summary (~100) + continuation (~100) + user message (~100) = ~6300
|
|
// Output: 200 (mock detects "summarized" in continuation as compaction)
|
|
// Total: ~6500
|
|
assert!(
|
|
(6000..=6600).contains(&final_input),
|
|
"Final input should reflect retry with agent-visible messages (~6300). Got: {}",
|
|
final_input
|
|
);
|
|
|
|
assert_eq!(
|
|
final_output,
|
|
Some(200),
|
|
"Final output should be 200 (mock detects continuation as compaction). Got: {:?}",
|
|
final_output
|
|
);
|
|
|
|
assert_eq!(
|
|
final_total,
|
|
final_input + final_output.unwrap(),
|
|
"Final total should equal input + output"
|
|
);
|
|
|
|
// Accumulated tokens should include all operations:
|
|
// - Initial: 1000
|
|
// - Compaction: ~6400 input (mock uses system_prompt.len()/4) + 200 output = ~6600
|
|
// - Reply: ~6500 input + 200 output = ~6700
|
|
// Total: 1000 + 6600 + 6700 = ~14300
|
|
let accumulated = updated_session.accumulated_total_tokens.unwrap();
|
|
assert!(
|
|
(13000..=16000).contains(&accumulated),
|
|
"Accumulated should be ~14300 (initial + compaction + reply). Got: {}",
|
|
accumulated
|
|
);
|
|
|
|
// Verify that the conversation was compacted
|
|
let updated_conversation = updated_session
|
|
.conversation
|
|
.expect("Session should have conversation");
|
|
assert_conversation_compacted(&updated_conversation);
|
|
|
|
Ok(())
|
|
}
|