mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-05 07:51:13 +08:00
Auto compaction was keying off cumulative usage and re-summarizing from the front of the session, which made long chats shed continuity after the first compaction. The runtime now compacts against the current turn's prompt pressure and preserves prior compacted context as retained summary state instead of treating it like disposable history. Constraint: Existing /compact behavior and saved-session resume flow had to keep working without schema changes Rejected: Keep using cumulative input tokens | caused repeat compaction after every subsequent turn once the threshold was crossed Rejected: Re-summarize prior compacted system messages as ordinary history | degraded continuity and could drop earlier context Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve compacted-summary boundaries when extending compaction again; do not fold prior compacted context back into raw-message removal Tested: cargo fmt --check; cargo clippy -p runtime -p commands --tests -- -D warnings; cargo test -p runtime; cargo test -p commands Not-tested: End-to-end interactive CLI auto-compaction against a live Anthropic session
1380 lines
47 KiB
Rust
1380 lines
47 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::fmt::{Display, Formatter};
|
|
|
|
use plugins::{HookRunner as PluginHookRunner, PluginRegistry};
|
|
|
|
use crate::compact::{
|
|
compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
|
|
};
|
|
use crate::config::RuntimeFeatureConfig;
|
|
use crate::hooks::HookRunner;
|
|
use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter};
|
|
use crate::session::{ContentBlock, ConversationMessage, Session};
|
|
use crate::usage::{TokenUsage, UsageTracker};
|
|
|
|
const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 200_000;
|
|
const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS";
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ApiRequest {
|
|
pub system_prompt: Vec<String>,
|
|
pub messages: Vec<ConversationMessage>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum AssistantEvent {
|
|
TextDelta(String),
|
|
ToolUse {
|
|
id: String,
|
|
name: String,
|
|
input: String,
|
|
},
|
|
Usage(TokenUsage),
|
|
MessageStop,
|
|
}
|
|
|
|
pub trait ApiClient {
|
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError>;
|
|
}
|
|
|
|
pub trait ToolExecutor {
|
|
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError>;
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ToolError {
|
|
message: String,
|
|
}
|
|
|
|
impl ToolError {
|
|
#[must_use]
|
|
pub fn new(message: impl Into<String>) -> Self {
|
|
Self {
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Display for ToolError {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.message)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ToolError {}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RuntimeError {
|
|
message: String,
|
|
}
|
|
|
|
impl RuntimeError {
|
|
#[must_use]
|
|
pub fn new(message: impl Into<String>) -> Self {
|
|
Self {
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Display for RuntimeError {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.message)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for RuntimeError {}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TurnSummary {
|
|
pub assistant_messages: Vec<ConversationMessage>,
|
|
pub tool_results: Vec<ConversationMessage>,
|
|
pub iterations: usize,
|
|
pub usage: TokenUsage,
|
|
pub auto_compaction: Option<AutoCompactionEvent>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct AutoCompactionEvent {
|
|
pub removed_message_count: usize,
|
|
}
|
|
|
|
pub struct ConversationRuntime<C, T> {
|
|
session: Session,
|
|
api_client: C,
|
|
tool_executor: T,
|
|
permission_policy: PermissionPolicy,
|
|
system_prompt: Vec<String>,
|
|
max_iterations: usize,
|
|
usage_tracker: UsageTracker,
|
|
hook_runner: HookRunner,
|
|
auto_compaction_input_tokens_threshold: u32,
|
|
plugin_hook_runner: Option<PluginHookRunner>,
|
|
plugin_registry: Option<PluginRegistry>,
|
|
plugins_shutdown: bool,
|
|
}
|
|
|
|
impl<C, T> ConversationRuntime<C, T> {
|
|
fn shutdown_registered_plugins(&mut self) -> Result<(), RuntimeError> {
|
|
if self.plugins_shutdown {
|
|
return Ok(());
|
|
}
|
|
if let Some(registry) = &self.plugin_registry {
|
|
registry
|
|
.shutdown()
|
|
.map_err(|error| RuntimeError::new(format!("plugin shutdown failed: {error}")))?;
|
|
}
|
|
self.plugins_shutdown = true;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<C, T> ConversationRuntime<C, T>
|
|
where
|
|
C: ApiClient,
|
|
T: ToolExecutor,
|
|
{
|
|
#[must_use]
|
|
pub fn new(
|
|
session: Session,
|
|
api_client: C,
|
|
tool_executor: T,
|
|
permission_policy: PermissionPolicy,
|
|
system_prompt: Vec<String>,
|
|
) -> Self {
|
|
Self::new_with_features(
|
|
session,
|
|
api_client,
|
|
tool_executor,
|
|
permission_policy,
|
|
system_prompt,
|
|
RuntimeFeatureConfig::default(),
|
|
)
|
|
}
|
|
|
|
#[must_use]
|
|
#[allow(clippy::needless_pass_by_value)]
|
|
pub fn new_with_features(
|
|
session: Session,
|
|
api_client: C,
|
|
tool_executor: T,
|
|
permission_policy: PermissionPolicy,
|
|
system_prompt: Vec<String>,
|
|
feature_config: RuntimeFeatureConfig,
|
|
) -> Self {
|
|
let usage_tracker = UsageTracker::from_session(&session);
|
|
Self {
|
|
session,
|
|
api_client,
|
|
tool_executor,
|
|
permission_policy,
|
|
system_prompt,
|
|
max_iterations: usize::MAX,
|
|
usage_tracker,
|
|
hook_runner: HookRunner::from_feature_config(&feature_config),
|
|
auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(),
|
|
plugin_hook_runner: None,
|
|
plugin_registry: None,
|
|
plugins_shutdown: false,
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::needless_pass_by_value)]
|
|
pub fn new_with_plugins(
|
|
session: Session,
|
|
api_client: C,
|
|
tool_executor: T,
|
|
permission_policy: PermissionPolicy,
|
|
system_prompt: Vec<String>,
|
|
feature_config: RuntimeFeatureConfig,
|
|
plugin_registry: PluginRegistry,
|
|
) -> Result<Self, RuntimeError> {
|
|
let plugin_hook_runner =
|
|
PluginHookRunner::from_registry(&plugin_registry).map_err(|error| {
|
|
RuntimeError::new(format!("plugin hook registration failed: {error}"))
|
|
})?;
|
|
plugin_registry
|
|
.initialize()
|
|
.map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?;
|
|
let mut runtime = Self::new_with_features(
|
|
session,
|
|
api_client,
|
|
tool_executor,
|
|
permission_policy,
|
|
system_prompt,
|
|
feature_config,
|
|
);
|
|
runtime.plugin_hook_runner = Some(plugin_hook_runner);
|
|
runtime.plugin_registry = Some(plugin_registry);
|
|
Ok(runtime)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
|
|
self.max_iterations = max_iterations;
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_auto_compaction_input_tokens_threshold(mut self, threshold: u32) -> Self {
|
|
self.auto_compaction_input_tokens_threshold = threshold;
|
|
self
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
pub fn run_turn(
|
|
&mut self,
|
|
user_input: impl Into<String>,
|
|
mut prompter: Option<&mut dyn PermissionPrompter>,
|
|
) -> Result<TurnSummary, RuntimeError> {
|
|
self.session
|
|
.messages
|
|
.push(ConversationMessage::user_text(user_input.into()));
|
|
|
|
let mut assistant_messages = Vec::new();
|
|
let mut tool_results = Vec::new();
|
|
let mut iterations = 0;
|
|
let mut max_turn_input_tokens = 0;
|
|
|
|
loop {
|
|
iterations += 1;
|
|
if iterations > self.max_iterations {
|
|
return Err(RuntimeError::new(
|
|
"conversation loop exceeded the maximum number of iterations",
|
|
));
|
|
}
|
|
|
|
let request = ApiRequest {
|
|
system_prompt: self.system_prompt.clone(),
|
|
messages: self.session.messages.clone(),
|
|
};
|
|
let events = self.api_client.stream(request)?;
|
|
let (assistant_message, usage) = build_assistant_message(events)?;
|
|
if let Some(usage) = usage {
|
|
max_turn_input_tokens = max_turn_input_tokens.max(usage.input_tokens);
|
|
self.usage_tracker.record(usage);
|
|
}
|
|
let pending_tool_uses = assistant_message
|
|
.blocks
|
|
.iter()
|
|
.filter_map(|block| match block {
|
|
ContentBlock::ToolUse { id, name, input } => {
|
|
Some((id.clone(), name.clone(), input.clone()))
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
self.session.messages.push(assistant_message.clone());
|
|
assistant_messages.push(assistant_message);
|
|
|
|
if pending_tool_uses.is_empty() {
|
|
break;
|
|
}
|
|
|
|
for (tool_use_id, tool_name, input) in pending_tool_uses {
|
|
let permission_outcome = if let Some(prompt) = prompter.as_mut() {
|
|
self.permission_policy
|
|
.authorize(&tool_name, &input, Some(*prompt))
|
|
} else {
|
|
self.permission_policy.authorize(&tool_name, &input, None)
|
|
};
|
|
|
|
let result_message = match permission_outcome {
|
|
PermissionOutcome::Allow => {
|
|
let pre_hook_result = self.hook_runner.run_pre_tool_use(&tool_name, &input);
|
|
if pre_hook_result.is_denied() {
|
|
let deny_message = format!("PreToolUse hook denied tool `{tool_name}`");
|
|
ConversationMessage::tool_result(
|
|
tool_use_id,
|
|
tool_name,
|
|
format_hook_message(pre_hook_result.messages(), &deny_message),
|
|
true,
|
|
)
|
|
} else {
|
|
let plugin_pre_hook_result =
|
|
self.run_plugin_pre_tool_use(&tool_name, &input);
|
|
if plugin_pre_hook_result.is_denied() {
|
|
let deny_message =
|
|
format!("PreToolUse hook denied tool `{tool_name}`");
|
|
let mut messages = pre_hook_result.messages().to_vec();
|
|
messages.extend(plugin_pre_hook_result.messages().iter().cloned());
|
|
ConversationMessage::tool_result(
|
|
tool_use_id,
|
|
tool_name,
|
|
format_hook_message(&messages, &deny_message),
|
|
true,
|
|
)
|
|
} else {
|
|
let (mut output, mut is_error) =
|
|
match self.tool_executor.execute(&tool_name, &input) {
|
|
Ok(output) => (output, false),
|
|
Err(error) => (error.to_string(), true),
|
|
};
|
|
output =
|
|
merge_hook_feedback(pre_hook_result.messages(), output, false);
|
|
output = merge_hook_feedback(
|
|
plugin_pre_hook_result.messages(),
|
|
output,
|
|
false,
|
|
);
|
|
|
|
let hook_output = output.clone();
|
|
let post_hook_result = self.hook_runner.run_post_tool_use(
|
|
&tool_name,
|
|
&input,
|
|
&hook_output,
|
|
is_error,
|
|
);
|
|
let plugin_post_hook_result = self.run_plugin_post_tool_use(
|
|
&tool_name,
|
|
&input,
|
|
&hook_output,
|
|
is_error,
|
|
);
|
|
if post_hook_result.is_denied() {
|
|
is_error = true;
|
|
}
|
|
if plugin_post_hook_result.is_denied() {
|
|
is_error = true;
|
|
}
|
|
output = merge_hook_feedback(
|
|
post_hook_result.messages(),
|
|
output,
|
|
post_hook_result.is_denied(),
|
|
);
|
|
output = merge_hook_feedback(
|
|
plugin_post_hook_result.messages(),
|
|
output,
|
|
plugin_post_hook_result.is_denied(),
|
|
);
|
|
|
|
ConversationMessage::tool_result(
|
|
tool_use_id,
|
|
tool_name,
|
|
output,
|
|
is_error,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
PermissionOutcome::Deny { reason } => {
|
|
ConversationMessage::tool_result(tool_use_id, tool_name, reason, true)
|
|
}
|
|
};
|
|
self.session.messages.push(result_message.clone());
|
|
tool_results.push(result_message);
|
|
}
|
|
}
|
|
|
|
let auto_compaction = self.maybe_auto_compact(max_turn_input_tokens);
|
|
|
|
Ok(TurnSummary {
|
|
assistant_messages,
|
|
tool_results,
|
|
iterations,
|
|
usage: self.usage_tracker.cumulative_usage(),
|
|
auto_compaction,
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn compact(&self, config: CompactionConfig) -> CompactionResult {
|
|
compact_session(&self.session, config)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn estimated_tokens(&self) -> usize {
|
|
estimate_session_tokens(&self.session)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn usage(&self) -> &UsageTracker {
|
|
&self.usage_tracker
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn session(&self) -> &Session {
|
|
&self.session
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn into_session(mut self) -> Session {
|
|
let _ = self.shutdown_registered_plugins();
|
|
std::mem::take(&mut self.session)
|
|
}
|
|
|
|
pub fn shutdown_plugins(&mut self) -> Result<(), RuntimeError> {
|
|
self.shutdown_registered_plugins()
|
|
}
|
|
|
|
fn run_plugin_pre_tool_use(&self, tool_name: &str, input: &str) -> plugins::HookRunResult {
|
|
self.plugin_hook_runner.as_ref().map_or_else(
|
|
|| plugins::HookRunResult::allow(Vec::new()),
|
|
|runner| runner.run_pre_tool_use(tool_name, input),
|
|
)
|
|
}
|
|
|
|
fn run_plugin_post_tool_use(
|
|
&self,
|
|
tool_name: &str,
|
|
input: &str,
|
|
output: &str,
|
|
is_error: bool,
|
|
) -> plugins::HookRunResult {
|
|
self.plugin_hook_runner.as_ref().map_or_else(
|
|
|| plugins::HookRunResult::allow(Vec::new()),
|
|
|runner| runner.run_post_tool_use(tool_name, input, output, is_error),
|
|
)
|
|
}
|
|
|
|
fn maybe_auto_compact(&mut self, turn_input_tokens: u32) -> Option<AutoCompactionEvent> {
|
|
if turn_input_tokens < self.auto_compaction_input_tokens_threshold {
|
|
return None;
|
|
}
|
|
|
|
let result = compact_session(
|
|
&self.session,
|
|
CompactionConfig {
|
|
max_estimated_tokens: usize::try_from(self.auto_compaction_input_tokens_threshold)
|
|
.unwrap_or(usize::MAX),
|
|
..CompactionConfig::default()
|
|
},
|
|
);
|
|
|
|
if result.removed_message_count == 0 {
|
|
return None;
|
|
}
|
|
|
|
self.session = result.compacted_session;
|
|
Some(AutoCompactionEvent {
|
|
removed_message_count: result.removed_message_count,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<C, T> Drop for ConversationRuntime<C, T> {
|
|
fn drop(&mut self) {
|
|
let _ = self.shutdown_registered_plugins();
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn auto_compaction_threshold_from_env() -> u32 {
|
|
parse_auto_compaction_threshold(
|
|
std::env::var(AUTO_COMPACTION_THRESHOLD_ENV_VAR)
|
|
.ok()
|
|
.as_deref(),
|
|
)
|
|
}
|
|
|
|
#[must_use]
|
|
fn parse_auto_compaction_threshold(value: Option<&str>) -> u32 {
|
|
value
|
|
.and_then(|raw| raw.trim().parse::<u32>().ok())
|
|
.filter(|threshold| *threshold > 0)
|
|
.unwrap_or(DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD)
|
|
}
|
|
|
|
fn build_assistant_message(
|
|
events: Vec<AssistantEvent>,
|
|
) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
|
|
let mut text = String::new();
|
|
let mut blocks = Vec::new();
|
|
let mut finished = false;
|
|
let mut usage = None;
|
|
|
|
for event in events {
|
|
match event {
|
|
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
|
|
AssistantEvent::ToolUse { id, name, input } => {
|
|
flush_text_block(&mut text, &mut blocks);
|
|
blocks.push(ContentBlock::ToolUse { id, name, input });
|
|
}
|
|
AssistantEvent::Usage(value) => usage = Some(value),
|
|
AssistantEvent::MessageStop => {
|
|
finished = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
flush_text_block(&mut text, &mut blocks);
|
|
|
|
if !finished {
|
|
return Err(RuntimeError::new(
|
|
"assistant stream ended without a message stop event",
|
|
));
|
|
}
|
|
if blocks.is_empty() {
|
|
return Err(RuntimeError::new("assistant stream produced no content"));
|
|
}
|
|
|
|
Ok((
|
|
ConversationMessage::assistant_with_usage(blocks, usage),
|
|
usage,
|
|
))
|
|
}
|
|
|
|
fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
|
|
if !text.is_empty() {
|
|
blocks.push(ContentBlock::Text {
|
|
text: std::mem::take(text),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn format_hook_message(messages: &[String], fallback: &str) -> String {
|
|
if messages.is_empty() {
|
|
fallback.to_string()
|
|
} else {
|
|
messages.join("\n")
|
|
}
|
|
}
|
|
|
|
fn merge_hook_feedback(messages: &[String], output: String, denied: bool) -> String {
|
|
if messages.is_empty() {
|
|
return output;
|
|
}
|
|
|
|
let mut sections = Vec::new();
|
|
if !output.trim().is_empty() {
|
|
sections.push(output);
|
|
}
|
|
let label = if denied {
|
|
"Hook feedback (denied)"
|
|
} else {
|
|
"Hook feedback"
|
|
};
|
|
sections.push(format!("{label}:\n{}", messages.join("\n")));
|
|
sections.join("\n\n")
|
|
}
|
|
|
|
type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
|
|
|
|
#[derive(Default)]
|
|
pub struct StaticToolExecutor {
|
|
handlers: BTreeMap<String, ToolHandler>,
|
|
}
|
|
|
|
impl StaticToolExecutor {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn register(
|
|
mut self,
|
|
tool_name: impl Into<String>,
|
|
handler: impl FnMut(&str) -> Result<String, ToolError> + 'static,
|
|
) -> Self {
|
|
self.handlers.insert(tool_name.into(), Box::new(handler));
|
|
self
|
|
}
|
|
}
|
|
|
|
impl ToolExecutor for StaticToolExecutor {
|
|
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
|
self.handlers
|
|
.get_mut(tool_name)
|
|
.ok_or_else(|| ToolError::new(format!("unknown tool: {tool_name}")))?(input)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
parse_auto_compaction_threshold, ApiClient, ApiRequest, AssistantEvent,
|
|
AutoCompactionEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
|
|
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
|
|
};
|
|
use crate::compact::CompactionConfig;
|
|
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
|
use crate::permissions::{
|
|
PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
|
|
PermissionRequest,
|
|
};
|
|
use crate::prompt::{ProjectContext, SystemPromptBuilder};
|
|
use crate::session::{ContentBlock, MessageRole, Session};
|
|
use crate::usage::TokenUsage;
|
|
use plugins::{PluginManager, PluginManagerConfig};
|
|
use std::fs;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
struct ScriptedApiClient {
|
|
call_count: usize,
|
|
}
|
|
|
|
impl ApiClient for ScriptedApiClient {
|
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
self.call_count += 1;
|
|
match self.call_count {
|
|
1 => {
|
|
assert!(request
|
|
.messages
|
|
.iter()
|
|
.any(|message| message.role == MessageRole::User));
|
|
Ok(vec![
|
|
AssistantEvent::TextDelta("Let me calculate that.".to_string()),
|
|
AssistantEvent::ToolUse {
|
|
id: "tool-1".to_string(),
|
|
name: "add".to_string(),
|
|
input: "2,2".to_string(),
|
|
},
|
|
AssistantEvent::Usage(TokenUsage {
|
|
input_tokens: 20,
|
|
output_tokens: 6,
|
|
cache_creation_input_tokens: 1,
|
|
cache_read_input_tokens: 2,
|
|
}),
|
|
AssistantEvent::MessageStop,
|
|
])
|
|
}
|
|
2 => {
|
|
let last_message = request
|
|
.messages
|
|
.last()
|
|
.expect("tool result should be present");
|
|
assert_eq!(last_message.role, MessageRole::Tool);
|
|
Ok(vec![
|
|
AssistantEvent::TextDelta("The answer is 4.".to_string()),
|
|
AssistantEvent::Usage(TokenUsage {
|
|
input_tokens: 24,
|
|
output_tokens: 4,
|
|
cache_creation_input_tokens: 1,
|
|
cache_read_input_tokens: 3,
|
|
}),
|
|
AssistantEvent::MessageStop,
|
|
])
|
|
}
|
|
_ => Err(RuntimeError::new("unexpected extra API call")),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PromptAllowOnce;
|
|
|
|
impl PermissionPrompter for PromptAllowOnce {
|
|
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
|
|
assert_eq!(request.tool_name, "add");
|
|
PermissionPromptDecision::Allow
|
|
}
|
|
}
|
|
|
|
fn temp_dir(label: &str) -> PathBuf {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("time should be after epoch")
|
|
.as_nanos();
|
|
std::env::temp_dir().join(format!("runtime-plugin-{label}-{nanos}"))
|
|
}
|
|
|
|
fn write_lifecycle_plugin(root: &Path, name: &str) -> PathBuf {
|
|
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
|
fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir");
|
|
let log_path = root.join("lifecycle.log");
|
|
fs::write(
|
|
root.join("lifecycle").join("init.sh"),
|
|
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
|
|
)
|
|
.expect("write init script");
|
|
fs::write(
|
|
root.join("lifecycle").join("shutdown.sh"),
|
|
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
|
|
)
|
|
.expect("write shutdown script");
|
|
fs::write(
|
|
root.join(".claude-plugin").join("plugin.json"),
|
|
format!(
|
|
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}"
|
|
),
|
|
)
|
|
.expect("write plugin manifest");
|
|
log_path
|
|
}
|
|
|
|
fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
|
|
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
|
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
|
|
fs::write(
|
|
root.join("hooks").join("pre.sh"),
|
|
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
|
|
)
|
|
.expect("write pre hook");
|
|
fs::write(
|
|
root.join("hooks").join("post.sh"),
|
|
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
|
|
)
|
|
.expect("write post hook");
|
|
#[cfg(unix)]
|
|
{
|
|
let exec_mode = fs::Permissions::from_mode(0o755);
|
|
fs::set_permissions(root.join("hooks").join("pre.sh"), exec_mode.clone())
|
|
.expect("chmod pre hook");
|
|
fs::set_permissions(root.join("hooks").join("post.sh"), exec_mode)
|
|
.expect("chmod post hook");
|
|
}
|
|
fs::write(
|
|
root.join(".claude-plugin").join("plugin.json"),
|
|
format!(
|
|
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
|
|
),
|
|
)
|
|
.expect("write plugin manifest");
|
|
}
|
|
|
|
#[test]
|
|
fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
|
|
let api_client = ScriptedApiClient { call_count: 0 };
|
|
let tool_executor = StaticToolExecutor::new().register("add", |input| {
|
|
let total = input
|
|
.split(',')
|
|
.map(|part| part.parse::<i32>().expect("input must be valid integer"))
|
|
.sum::<i32>();
|
|
Ok(total.to_string())
|
|
});
|
|
let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite);
|
|
let system_prompt = SystemPromptBuilder::new()
|
|
.with_project_context(ProjectContext {
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
current_date: "2026-03-31".to_string(),
|
|
git_status: None,
|
|
git_diff: None,
|
|
instruction_files: Vec::new(),
|
|
})
|
|
.with_os("linux", "6.8")
|
|
.build();
|
|
let mut runtime = ConversationRuntime::new(
|
|
Session::new(),
|
|
api_client,
|
|
tool_executor,
|
|
permission_policy,
|
|
system_prompt,
|
|
);
|
|
|
|
let summary = runtime
|
|
.run_turn("what is 2 + 2?", Some(&mut PromptAllowOnce))
|
|
.expect("conversation loop should succeed");
|
|
|
|
assert_eq!(summary.iterations, 2);
|
|
assert_eq!(summary.assistant_messages.len(), 2);
|
|
assert_eq!(summary.tool_results.len(), 1);
|
|
assert_eq!(runtime.session().messages.len(), 4);
|
|
assert_eq!(summary.usage.output_tokens, 10);
|
|
assert_eq!(summary.auto_compaction, None);
|
|
assert!(matches!(
|
|
runtime.session().messages[1].blocks[1],
|
|
ContentBlock::ToolUse { .. }
|
|
));
|
|
assert!(matches!(
|
|
runtime.session().messages[2].blocks[0],
|
|
ContentBlock::ToolResult {
|
|
is_error: false,
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn records_denied_tool_results_when_prompt_rejects() {
|
|
struct RejectPrompter;
|
|
impl PermissionPrompter for RejectPrompter {
|
|
fn decide(&mut self, _request: &PermissionRequest) -> PermissionPromptDecision {
|
|
PermissionPromptDecision::Deny {
|
|
reason: "not now".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SingleCallApiClient;
|
|
impl ApiClient for SingleCallApiClient {
|
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
if request
|
|
.messages
|
|
.iter()
|
|
.any(|message| message.role == MessageRole::Tool)
|
|
{
|
|
return Ok(vec![
|
|
AssistantEvent::TextDelta("I could not use the tool.".to_string()),
|
|
AssistantEvent::MessageStop,
|
|
]);
|
|
}
|
|
Ok(vec![
|
|
AssistantEvent::ToolUse {
|
|
id: "tool-1".to_string(),
|
|
name: "blocked".to_string(),
|
|
input: "secret".to_string(),
|
|
},
|
|
AssistantEvent::MessageStop,
|
|
])
|
|
}
|
|
}
|
|
|
|
let mut runtime = ConversationRuntime::new(
|
|
Session::new(),
|
|
SingleCallApiClient,
|
|
StaticToolExecutor::new(),
|
|
PermissionPolicy::new(PermissionMode::WorkspaceWrite),
|
|
vec!["system".to_string()],
|
|
);
|
|
|
|
let summary = runtime
|
|
.run_turn("use the tool", Some(&mut RejectPrompter))
|
|
.expect("conversation should continue after denied tool");
|
|
|
|
assert_eq!(summary.tool_results.len(), 1);
|
|
assert!(matches!(
|
|
&summary.tool_results[0].blocks[0],
|
|
ContentBlock::ToolResult { is_error: true, output, .. } if output == "not now"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn denies_tool_use_when_pre_tool_hook_blocks() {
|
|
struct SingleCallApiClient;
|
|
impl ApiClient for SingleCallApiClient {
|
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
if request
|
|
.messages
|
|
.iter()
|
|
.any(|message| message.role == MessageRole::Tool)
|
|
{
|
|
return Ok(vec![
|
|
AssistantEvent::TextDelta("blocked".to_string()),
|
|
AssistantEvent::MessageStop,
|
|
]);
|
|
}
|
|
Ok(vec![
|
|
AssistantEvent::ToolUse {
|
|
id: "tool-1".to_string(),
|
|
name: "blocked".to_string(),
|
|
input: r#"{"path":"secret.txt"}"#.to_string(),
|
|
},
|
|
AssistantEvent::MessageStop,
|
|
])
|
|
}
|
|
}
|
|
|
|
let mut runtime = ConversationRuntime::new_with_features(
|
|
Session::new(),
|
|
SingleCallApiClient,
|
|
StaticToolExecutor::new().register("blocked", |_input| {
|
|
panic!("tool should not execute when hook denies")
|
|
}),
|
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
vec!["system".to_string()],
|
|
RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
|
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
|
|
Vec::new(),
|
|
)),
|
|
);
|
|
|
|
let summary = runtime
|
|
.run_turn("use the tool", None)
|
|
.expect("conversation should continue after hook denial");
|
|
|
|
assert_eq!(summary.tool_results.len(), 1);
|
|
let ContentBlock::ToolResult {
|
|
is_error, output, ..
|
|
} = &summary.tool_results[0].blocks[0]
|
|
else {
|
|
panic!("expected tool result block");
|
|
};
|
|
assert!(
|
|
*is_error,
|
|
"hook denial should produce an error result: {output}"
|
|
);
|
|
assert!(
|
|
output.contains("denied tool") || output.contains("blocked by hook"),
|
|
"unexpected hook denial output: {output:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn appends_post_tool_hook_feedback_to_tool_result() {
|
|
struct TwoCallApiClient {
|
|
calls: usize,
|
|
}
|
|
|
|
impl ApiClient for TwoCallApiClient {
|
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
self.calls += 1;
|
|
match self.calls {
|
|
1 => Ok(vec![
|
|
AssistantEvent::ToolUse {
|
|
id: "tool-1".to_string(),
|
|
name: "add".to_string(),
|
|
input: r#"{"lhs":2,"rhs":2}"#.to_string(),
|
|
},
|
|
AssistantEvent::MessageStop,
|
|
]),
|
|
2 => {
|
|
assert!(request
|
|
.messages
|
|
.iter()
|
|
.any(|message| message.role == MessageRole::Tool));
|
|
Ok(vec![
|
|
AssistantEvent::TextDelta("done".to_string()),
|
|
AssistantEvent::MessageStop,
|
|
])
|
|
}
|
|
_ => Err(RuntimeError::new("unexpected extra API call")),
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut runtime = ConversationRuntime::new_with_features(
|
|
Session::new(),
|
|
TwoCallApiClient { calls: 0 },
|
|
StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
|
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
vec!["system".to_string()],
|
|
RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
|
vec![shell_snippet("printf 'pre hook ran'")],
|
|
vec![shell_snippet("printf 'post hook ran'")],
|
|
)),
|
|
);
|
|
|
|
let summary = runtime
|
|
.run_turn("use add", None)
|
|
.expect("tool loop succeeds");
|
|
|
|
assert_eq!(summary.tool_results.len(), 1);
|
|
let ContentBlock::ToolResult {
|
|
is_error, output, ..
|
|
} = &summary.tool_results[0].blocks[0]
|
|
else {
|
|
panic!("expected tool result block");
|
|
};
|
|
assert!(
|
|
!*is_error,
|
|
"post hook should preserve non-error result: {output:?}"
|
|
);
|
|
assert!(
|
|
output.contains('4'),
|
|
"tool output missing value: {output:?}"
|
|
);
|
|
assert!(
|
|
output.contains("pre hook ran"),
|
|
"tool output missing pre hook feedback: {output:?}"
|
|
);
|
|
assert!(
|
|
output.contains("post hook ran"),
|
|
"tool output missing post hook feedback: {output:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn initializes_and_shuts_down_plugins_with_runtime_lifecycle() {
|
|
let config_home = temp_dir("config");
|
|
let source_root = temp_dir("source");
|
|
let _ = write_lifecycle_plugin(&source_root, "runtime-lifecycle");
|
|
|
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
|
let install = manager
|
|
.install(source_root.to_str().expect("utf8 path"))
|
|
.expect("install should succeed");
|
|
let log_path = install.install_path.join("lifecycle.log");
|
|
let registry = manager.plugin_registry().expect("registry should load");
|
|
|
|
{
|
|
let runtime = ConversationRuntime::new_with_plugins(
|
|
Session::new(),
|
|
ScriptedApiClient { call_count: 0 },
|
|
StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
|
|
PermissionPolicy::new(PermissionMode::WorkspaceWrite),
|
|
vec!["system".to_string()],
|
|
RuntimeFeatureConfig::default(),
|
|
registry,
|
|
)
|
|
.expect("runtime should initialize plugins");
|
|
|
|
let log = fs::read_to_string(&log_path).expect("init log should exist");
|
|
assert_eq!(log, "init\n");
|
|
drop(runtime);
|
|
}
|
|
|
|
let log = fs::read_to_string(&log_path).expect("shutdown log should exist");
|
|
assert_eq!(log, "init\nshutdown\n");
|
|
|
|
let _ = fs::remove_dir_all(config_home);
|
|
let _ = fs::remove_dir_all(source_root);
|
|
}
|
|
|
|
#[test]
|
|
fn executes_hooks_from_installed_plugins_during_tool_use() {
|
|
struct TwoCallApiClient {
|
|
calls: usize,
|
|
}
|
|
|
|
impl ApiClient for TwoCallApiClient {
|
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
self.calls += 1;
|
|
match self.calls {
|
|
1 => Ok(vec![
|
|
AssistantEvent::ToolUse {
|
|
id: "tool-1".to_string(),
|
|
name: "add".to_string(),
|
|
input: r#"{"lhs":2,"rhs":2}"#.to_string(),
|
|
},
|
|
AssistantEvent::MessageStop,
|
|
]),
|
|
2 => {
|
|
assert!(request
|
|
.messages
|
|
.iter()
|
|
.any(|message| message.role == MessageRole::Tool));
|
|
Ok(vec![
|
|
AssistantEvent::TextDelta("done".to_string()),
|
|
AssistantEvent::MessageStop,
|
|
])
|
|
}
|
|
_ => Err(RuntimeError::new("unexpected extra API call")),
|
|
}
|
|
}
|
|
}
|
|
|
|
let config_home = temp_dir("hook-config");
|
|
let first_source_root = temp_dir("hook-source-a");
|
|
let second_source_root = temp_dir("hook-source-b");
|
|
write_hook_plugin(
|
|
&first_source_root,
|
|
"first",
|
|
"plugin pre one",
|
|
"plugin post one",
|
|
);
|
|
write_hook_plugin(
|
|
&second_source_root,
|
|
"second",
|
|
"plugin pre two",
|
|
"plugin post two",
|
|
);
|
|
|
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
|
manager
|
|
.install(first_source_root.to_str().expect("utf8 path"))
|
|
.expect("first plugin install should succeed");
|
|
manager
|
|
.install(second_source_root.to_str().expect("utf8 path"))
|
|
.expect("second plugin install should succeed");
|
|
let registry = manager.plugin_registry().expect("registry should load");
|
|
|
|
let mut runtime = ConversationRuntime::new_with_plugins(
|
|
Session::new(),
|
|
TwoCallApiClient { calls: 0 },
|
|
StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
|
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
vec!["system".to_string()],
|
|
RuntimeFeatureConfig::default(),
|
|
registry,
|
|
)
|
|
.expect("runtime should load plugin hooks");
|
|
|
|
let summary = runtime
|
|
.run_turn("use add", None)
|
|
.expect("tool loop succeeds");
|
|
|
|
assert_eq!(summary.tool_results.len(), 1);
|
|
let ContentBlock::ToolResult {
|
|
is_error, output, ..
|
|
} = &summary.tool_results[0].blocks[0]
|
|
else {
|
|
panic!("expected tool result block");
|
|
};
|
|
assert!(
|
|
!*is_error,
|
|
"plugin hooks should not force an error: {output:?}"
|
|
);
|
|
assert!(
|
|
output.contains('4'),
|
|
"tool output missing value: {output:?}"
|
|
);
|
|
assert!(
|
|
output.contains("plugin pre one"),
|
|
"tool output missing first pre hook feedback: {output:?}"
|
|
);
|
|
assert!(
|
|
output.contains("plugin pre two"),
|
|
"tool output missing second pre hook feedback: {output:?}"
|
|
);
|
|
assert!(
|
|
output.contains("plugin post one"),
|
|
"tool output missing first post hook feedback: {output:?}"
|
|
);
|
|
assert!(
|
|
output.contains("plugin post two"),
|
|
"tool output missing second post hook feedback: {output:?}"
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(config_home);
|
|
let _ = fs::remove_dir_all(first_source_root);
|
|
let _ = fs::remove_dir_all(second_source_root);
|
|
}
|
|
|
|
#[test]
|
|
fn reconstructs_usage_tracker_from_restored_session() {
|
|
struct SimpleApi;
|
|
impl ApiClient for SimpleApi {
|
|
fn stream(
|
|
&mut self,
|
|
_request: ApiRequest,
|
|
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
Ok(vec![
|
|
AssistantEvent::TextDelta("done".to_string()),
|
|
AssistantEvent::MessageStop,
|
|
])
|
|
}
|
|
}
|
|
|
|
let mut session = Session::new();
|
|
session
|
|
.messages
|
|
.push(crate::session::ConversationMessage::assistant_with_usage(
|
|
vec![ContentBlock::Text {
|
|
text: "earlier".to_string(),
|
|
}],
|
|
Some(TokenUsage {
|
|
input_tokens: 11,
|
|
output_tokens: 7,
|
|
cache_creation_input_tokens: 2,
|
|
cache_read_input_tokens: 1,
|
|
}),
|
|
));
|
|
|
|
let runtime = ConversationRuntime::new(
|
|
session,
|
|
SimpleApi,
|
|
StaticToolExecutor::new(),
|
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
vec!["system".to_string()],
|
|
);
|
|
|
|
assert_eq!(runtime.usage().turns(), 1);
|
|
assert_eq!(runtime.usage().cumulative_usage().total_tokens(), 21);
|
|
}
|
|
|
|
#[test]
|
|
fn compacts_session_after_turns() {
|
|
struct SimpleApi;
|
|
impl ApiClient for SimpleApi {
|
|
fn stream(
|
|
&mut self,
|
|
_request: ApiRequest,
|
|
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
Ok(vec![
|
|
AssistantEvent::TextDelta("done".to_string()),
|
|
AssistantEvent::MessageStop,
|
|
])
|
|
}
|
|
}
|
|
|
|
let mut runtime = ConversationRuntime::new(
|
|
Session::new(),
|
|
SimpleApi,
|
|
StaticToolExecutor::new(),
|
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
vec!["system".to_string()],
|
|
);
|
|
runtime.run_turn("a", None).expect("turn a");
|
|
runtime.run_turn("b", None).expect("turn b");
|
|
runtime.run_turn("c", None).expect("turn c");
|
|
|
|
let result = runtime.compact(CompactionConfig {
|
|
preserve_recent_messages: 2,
|
|
max_estimated_tokens: 1,
|
|
});
|
|
assert!(result.summary.contains("Conversation summary"));
|
|
assert_eq!(
|
|
result.compacted_session.messages[0].role,
|
|
MessageRole::System
|
|
);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn shell_snippet(script: &str) -> String {
|
|
script.replace('\'', "\"")
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn shell_snippet(script: &str) -> String {
|
|
script.to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn auto_compacts_when_turn_input_threshold_is_crossed() {
|
|
struct SimpleApi;
|
|
impl ApiClient for SimpleApi {
|
|
fn stream(
|
|
&mut self,
|
|
_request: ApiRequest,
|
|
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
Ok(vec![
|
|
AssistantEvent::TextDelta("done".to_string()),
|
|
AssistantEvent::Usage(TokenUsage {
|
|
input_tokens: 120_000,
|
|
output_tokens: 4,
|
|
cache_creation_input_tokens: 0,
|
|
cache_read_input_tokens: 0,
|
|
}),
|
|
AssistantEvent::MessageStop,
|
|
])
|
|
}
|
|
}
|
|
|
|
let session = Session {
|
|
version: 1,
|
|
messages: vec![
|
|
crate::session::ConversationMessage::user_text("one ".repeat(30_000)),
|
|
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
text: "two ".repeat(30_000),
|
|
}]),
|
|
crate::session::ConversationMessage::user_text("three ".repeat(30_000)),
|
|
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
text: "four ".repeat(30_000),
|
|
}]),
|
|
],
|
|
};
|
|
|
|
let mut runtime = ConversationRuntime::new(
|
|
session,
|
|
SimpleApi,
|
|
StaticToolExecutor::new(),
|
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
vec!["system".to_string()],
|
|
)
|
|
.with_auto_compaction_input_tokens_threshold(100_000);
|
|
|
|
let summary = runtime
|
|
.run_turn("trigger", None)
|
|
.expect("turn should succeed");
|
|
|
|
assert_eq!(
|
|
summary.auto_compaction,
|
|
Some(AutoCompactionEvent {
|
|
removed_message_count: 2,
|
|
})
|
|
);
|
|
assert_eq!(runtime.session().messages[0].role, MessageRole::System);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_compaction_does_not_repeat_after_context_is_already_compacted() {
|
|
struct SequentialUsageApi {
|
|
call_count: usize,
|
|
}
|
|
|
|
impl ApiClient for SequentialUsageApi {
|
|
fn stream(
|
|
&mut self,
|
|
_request: ApiRequest,
|
|
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
self.call_count += 1;
|
|
let input_tokens = if self.call_count == 1 { 120_000 } else { 64 };
|
|
Ok(vec![
|
|
AssistantEvent::TextDelta("done".to_string()),
|
|
AssistantEvent::Usage(TokenUsage {
|
|
input_tokens,
|
|
output_tokens: 4,
|
|
cache_creation_input_tokens: 0,
|
|
cache_read_input_tokens: 0,
|
|
}),
|
|
AssistantEvent::MessageStop,
|
|
])
|
|
}
|
|
}
|
|
|
|
let session = Session {
|
|
version: 1,
|
|
messages: vec![
|
|
crate::session::ConversationMessage::user_text("one ".repeat(30_000)),
|
|
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
text: "two ".repeat(30_000),
|
|
}]),
|
|
crate::session::ConversationMessage::user_text("three ".repeat(30_000)),
|
|
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
text: "four ".repeat(30_000),
|
|
}]),
|
|
],
|
|
};
|
|
|
|
let mut runtime = ConversationRuntime::new(
|
|
session,
|
|
SequentialUsageApi { call_count: 0 },
|
|
StaticToolExecutor::new(),
|
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
vec!["system".to_string()],
|
|
)
|
|
.with_auto_compaction_input_tokens_threshold(100_000);
|
|
|
|
let first = runtime
|
|
.run_turn("trigger", None)
|
|
.expect("first turn should succeed");
|
|
assert_eq!(
|
|
first.auto_compaction,
|
|
Some(AutoCompactionEvent {
|
|
removed_message_count: 2,
|
|
})
|
|
);
|
|
|
|
let second = runtime
|
|
.run_turn("continue", None)
|
|
.expect("second turn should succeed");
|
|
assert_eq!(second.auto_compaction, None);
|
|
assert_eq!(runtime.session().messages[0].role, MessageRole::System);
|
|
}
|
|
|
|
#[test]
|
|
fn skips_auto_compaction_below_threshold() {
|
|
struct SimpleApi;
|
|
impl ApiClient for SimpleApi {
|
|
fn stream(
|
|
&mut self,
|
|
_request: ApiRequest,
|
|
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
Ok(vec![
|
|
AssistantEvent::TextDelta("done".to_string()),
|
|
AssistantEvent::Usage(TokenUsage {
|
|
input_tokens: 99_999,
|
|
output_tokens: 4,
|
|
cache_creation_input_tokens: 0,
|
|
cache_read_input_tokens: 0,
|
|
}),
|
|
AssistantEvent::MessageStop,
|
|
])
|
|
}
|
|
}
|
|
|
|
let mut runtime = ConversationRuntime::new(
|
|
Session::new(),
|
|
SimpleApi,
|
|
StaticToolExecutor::new(),
|
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
vec!["system".to_string()],
|
|
)
|
|
.with_auto_compaction_input_tokens_threshold(100_000);
|
|
|
|
let summary = runtime
|
|
.run_turn("trigger", None)
|
|
.expect("turn should succeed");
|
|
assert_eq!(summary.auto_compaction, None);
|
|
assert_eq!(runtime.session().messages.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_compaction_threshold_defaults_and_parses_values() {
|
|
assert_eq!(
|
|
parse_auto_compaction_threshold(None),
|
|
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
|
|
);
|
|
assert_eq!(parse_auto_compaction_threshold(Some("4321")), 4321);
|
|
assert_eq!(
|
|
parse_auto_compaction_threshold(Some("not-a-number")),
|
|
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
|
|
);
|
|
}
|
|
}
|