use anyhow::Result;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, oneshot};
use crate::api::{ApiEvent, ContentBlock, Message, Provider, ProviderStream};
use crate::compact::{self};
use crate::cost::CostTracker;
use crate::permissions::{PermissionChecker, PermissionResponse, PermissionResult};
use crate::tools::ToolRegistry;
pub type SteeringQueue = Arc<Mutex<VecDeque<String>>>;
pub struct Engine {
provider: Box<dyn Provider>,
tools: ToolRegistry,
permissions: PermissionChecker,
messages: Vec<Message>,
system_prompt: String,
model: String,
max_tokens: u32,
auto_compact_threshold: f64,
steering: SteeringQueue,
pub cost: CostTracker,
}
pub enum StreamEvent {
Text(String),
Notice(String),
SteeringSent(String),
ToolStart {
name: String,
id: String,
summary: String,
},
ToolResult {
name: String,
content: String,
is_error: bool,
},
PermissionRequest {
tool_name: String,
summary: String,
input: serde_json::Value,
respond: oneshot::Sender<PermissionResponse>,
},
PermissionRequestWithDiff {
tool_name: String,
summary: String,
diff: String,
input: serde_json::Value,
respond: oneshot::Sender<PermissionResponse>,
},
Interrupted,
Error(String),
Done,
}
impl Engine {
pub fn new(
provider: Box<dyn Provider>,
tools: ToolRegistry,
permissions: PermissionChecker,
model: &str,
) -> Self {
Self {
provider,
tools,
permissions,
messages: Vec::new(),
system_prompt: String::new(),
model: model.to_string(),
max_tokens: 16384,
auto_compact_threshold: 0.8,
steering: SteeringQueue::default(),
cost: CostTracker::new(model),
}
}
#[cfg(test)]
pub(crate) fn for_tests(
provider: Box<dyn Provider>,
steering: SteeringQueue,
mode: crate::permissions::PermissionMode,
) -> Self {
Self {
provider,
tools: ToolRegistry::without_agent(),
permissions: PermissionChecker::new(mode),
messages: vec![],
system_prompt: String::new(),
model: "test".to_string(),
max_tokens: 1000,
auto_compact_threshold: 0.8,
steering,
cost: CostTracker::new("test"),
}
}
pub fn steering_queue(&self) -> SteeringQueue {
self.steering.clone()
}
pub fn inject_steering(&mut self) -> Vec<String> {
let drained: Vec<String> = {
let mut q = self.steering.lock().expect("steering queue poisoned");
q.drain(..).collect()
};
for text in &drained {
self.messages.push(Message::user(text));
}
drained
}
pub fn steering_pending(&self) -> bool {
!self
.steering
.lock()
.expect("steering queue poisoned")
.is_empty()
}
pub const SKIPPED_FOR_STEERING: &'static str =
"Skipped: superseded by a new user message before this tool ran.";
async fn execute_tool_steerable(
&self,
name: &str,
input: serde_json::Value,
cancel: &tokio_util::sync::CancellationToken,
) -> crate::tools::ToolOutput {
let token = cancel.child_token();
let steering = self.steering.clone();
let watch_token = token.clone();
let watcher = tokio::spawn(async move {
loop {
if !steering.lock().expect("steering queue poisoned").is_empty() {
watch_token.cancel();
return;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
});
let output = self.tools.execute(name, input, token).await;
watcher.abort();
output
}
pub fn set_auto_compact_threshold(&mut self, threshold: f64) {
self.auto_compact_threshold = threshold.clamp(0.0, 1.0);
}
pub fn set_system_prompt(&mut self, prompt: String) {
self.system_prompt = prompt;
}
pub fn messages(&self) -> &[Message] {
&self.messages
}
pub fn messages_mut(&mut self) -> &mut Vec<Message> {
&mut self.messages
}
pub fn set_messages(&mut self, messages: Vec<Message>) {
self.messages = messages;
}
pub fn model(&self) -> &str {
&self.model
}
pub fn set_model(&mut self, model: &str) {
self.model = model.to_string();
self.provider.set_model(model);
self.cost = CostTracker::new(model);
}
pub fn set_theme(&mut self, _theme: crate::theme::ThemeName) {
}
pub fn message_count(&self) -> usize {
self.messages.len()
}
pub fn tool_definitions(&self) -> Vec<crate::api::ToolDefinition> {
self.tools.definitions()
}
pub async fn start_stream(
&self,
tool_defs: &[crate::api::ToolDefinition],
cancel: tokio_util::sync::CancellationToken,
) -> Result<ProviderStream> {
self.provider
.stream(
&self.messages,
&self.system_prompt,
tool_defs,
self.max_tokens,
cancel,
)
.await
}
pub fn check_permission(
&self,
tool_name: &str,
input: &serde_json::Value,
is_read_only: bool,
) -> PermissionResult {
self.permissions.check(tool_name, input, is_read_only)
}
pub fn summarize_tool(&self, name: &str, input: &serde_json::Value) -> String {
self.tools.summarize(name, input)
}
pub fn is_tool_read_only(&self, name: &str) -> bool {
self.tools.is_read_only(name)
}
pub async fn execute_tool(
&self,
name: &str,
input: serde_json::Value,
cancel: tokio_util::sync::CancellationToken,
) -> crate::tools::ToolOutput {
self.tools.execute(name, input, cancel).await
}
pub fn always_allow_tool(&mut self, name: &str) {
self.permissions.always_allow(name);
}
pub fn always_allow_command(&mut self, cmd: &str) {
self.permissions.always_allow_command(cmd);
}
pub async fn maybe_auto_compact(&mut self) -> Result<bool> {
if self.auto_compact_threshold <= 0.0 {
return Ok(false);
}
let ctx_window = compact::context_window_for_model(&self.model);
let current_tokens = compact::estimate_tokens(&self.messages);
let threshold_tokens = (ctx_window as f64 * self.auto_compact_threshold) as usize;
if current_tokens > threshold_tokens {
tracing::info!(
"Auto-compact triggered: {} tokens > {} (threshold: {:.0}% of {})",
current_tokens,
threshold_tokens,
self.auto_compact_threshold * 100.0,
ctx_window
);
let result = self.compact().await?;
tracing::info!("Auto-compact completed: {}", result);
Ok(true)
} else {
Ok(false)
}
}
pub async fn compact(&mut self) -> Result<String> {
if self.messages.is_empty() {
return Ok("Nothing to compact.".to_string());
}
let old_count = self.messages.len();
let old_tokens = compact::estimate_tokens(&self.messages);
if let Some(snipped) = compact::snip_old_messages(&self.messages, 10) {
let new_tokens = compact::estimate_tokens(&snipped);
self.messages = snipped;
tracing::info!(
"Snip compaction: {} msgs → {}, ~{} → ~{} tokens",
old_count,
self.messages.len(),
old_tokens,
new_tokens
);
let ctx_window = compact::context_window_for_model(&self.model);
if new_tokens < ctx_window * 70 / 100 {
return Ok(format!(
"Snipped {} old messages (~{} tokens freed)",
old_count - self.messages.len() + 1, old_tokens - new_tokens
));
}
}
self.summarize_conversation().await
}
async fn summarize_conversation(&mut self) -> Result<String> {
let summary_prompt = "Summarize the conversation so far in a concise paragraph. \
Focus on what was discussed, what decisions were made, what files were modified, \
and any outstanding tasks. Be specific about file paths and changes.";
let mut summary_messages = self.messages.clone();
summary_messages.push(Message::user(summary_prompt));
let mut rx = self
.provider
.stream(
&summary_messages,
&self.system_prompt,
&[],
self.max_tokens,
tokio_util::sync::CancellationToken::new(),
)
.await?;
let mut summary = String::new();
let mut completed = false;
while let Some(event) = rx.recv().await {
match event {
ApiEvent::Text(t) => summary.push_str(&t),
ApiEvent::Usage(usage) => self.cost.add_usage(&usage),
ApiEvent::Done => {
completed = true;
break;
}
ApiEvent::Error(e) => return Err(anyhow::anyhow!("Compact error: {e}")),
_ => {}
}
}
if !completed {
anyhow::bail!("Compact error: API stream ended without completion");
}
let old_count = self.messages.len();
self.messages = vec![
Message::user("Here is a summary of our conversation so far:"),
Message::assistant_text(&summary),
];
Ok(format!(
"Compacted {old_count} messages into summary.\n\n\x1b[2m{summary}\x1b[0m"
))
}
fn is_prompt_too_long(err: &str) -> bool {
err.contains("413")
|| err.contains("prompt is too long")
|| err.contains("maximum context length")
|| err.contains("max_tokens")
|| err.contains("context_length_exceeded")
}
fn is_max_output_tokens(err: &str) -> bool {
err.contains("max_output_tokens") || err.contains("max_tokens_exceeded")
}
pub const INTERRUPTED_BY_USER: &'static str = "Interrupted by user.";
pub async fn submit(
&mut self,
user_input: &str,
cancel: tokio_util::sync::CancellationToken,
) -> Result<String> {
let (tx, mut rx) = mpsc::channel::<StreamEvent>(256);
let collector = tokio::spawn(async move {
let mut text = String::new();
while let Some(event) = rx.recv().await {
if let StreamEvent::Text(t) = event {
text.push_str(&t);
}
}
text
});
let result = self.run_turn(user_input, tx, false, cancel).await;
let text = collector.await.unwrap_or_default();
result?;
Ok(text)
}
pub async fn submit_streaming(
&mut self,
user_input: &str,
tx: mpsc::Sender<StreamEvent>,
cancel: tokio_util::sync::CancellationToken,
) -> Result<()> {
self.run_turn(user_input, tx, true, cancel).await
}
async fn run_turn(
&mut self,
user_input: &str,
tx: mpsc::Sender<StreamEvent>,
interactive: bool,
cancel: tokio_util::sync::CancellationToken,
) -> Result<()> {
let compacted = self.maybe_auto_compact().await?;
if compacted {
let _ = tx
.send(StreamEvent::Notice(
"conversation auto-compacted to free context".to_string(),
))
.await;
}
self.messages.push(Message::user(user_input));
let mut recovery_attempts = 0;
const MAX_RECOVERY: u32 = 3;
loop {
for text in self.inject_steering() {
let _ = tx.send(StreamEvent::SteeringSent(text)).await;
}
if cancel.is_cancelled() {
let _ = tx.send(StreamEvent::Interrupted).await;
return Ok(());
}
let tool_defs = self.tools.definitions();
let stream_result = self
.provider
.stream(
&self.messages,
&self.system_prompt,
&tool_defs,
self.max_tokens,
cancel.clone(),
)
.await;
let mut rx = match stream_result {
Ok(rx) => rx,
Err(e) => {
if cancel.is_cancelled() {
let _ = tx.send(StreamEvent::Interrupted).await;
return Ok(());
}
let err_str = e.to_string();
if Self::is_prompt_too_long(&err_str) && recovery_attempts < MAX_RECOVERY {
recovery_attempts += 1;
let _ = tx
.send(StreamEvent::Notice(
"compacting conversation...".to_string(),
))
.await;
self.compact().await?;
continue;
}
let _ = tx.send(StreamEvent::Error(err_str.clone())).await;
return Err(e);
}
};
let mut text_buf = String::new();
let mut tool_uses: Vec<(String, String, serde_json::Value)> = Vec::new();
let mut had_error = false;
let mut stream_interrupted = false;
loop {
let event = tokio::select! {
event = rx.recv() => match event {
Some(event) => event,
None => {
let error = "API stream ended without completion".to_string();
let _ = tx.send(StreamEvent::Error(error.clone())).await;
return Err(anyhow::anyhow!(error));
}
},
_ = cancel.cancelled() => {
stream_interrupted = true;
break;
}
};
match event {
ApiEvent::Text(t) => {
let _ = tx.send(StreamEvent::Text(t.clone())).await;
text_buf.push_str(&t);
}
ApiEvent::ToolUse { id, name, input } => {
let summary = self.tools.summarize(&name, &input);
let _ = tx
.send(StreamEvent::ToolStart {
name: name.clone(),
id: id.clone(),
summary,
})
.await;
tool_uses.push((id, name, input));
}
ApiEvent::Usage(usage) => {
self.cost.add_usage(&usage);
}
ApiEvent::Done => break,
ApiEvent::Error(e) => {
if Self::is_prompt_too_long(&e) && recovery_attempts < MAX_RECOVERY {
recovery_attempts += 1;
let _ = tx
.send(StreamEvent::Notice(
"compacting conversation...".to_string(),
))
.await;
self.compact().await?;
had_error = true;
break;
}
if Self::is_max_output_tokens(&e) && self.max_tokens < 64_000 {
self.max_tokens = (self.max_tokens * 2).min(64_000);
had_error = true;
break;
}
let _ = tx.send(StreamEvent::Error(e.clone())).await;
return Err(anyhow::anyhow!("API error: {e}"));
}
}
}
if had_error {
continue;
}
let mut blocks = Vec::new();
if !text_buf.is_empty() {
blocks.push(ContentBlock::Text {
text: text_buf.clone(),
});
}
for (id, name, input) in &tool_uses {
blocks.push(ContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.clone(),
});
}
if !blocks.is_empty() {
self.messages.push(Message::assistant_blocks(blocks));
}
if stream_interrupted {
if !tool_uses.is_empty() {
let mut result_blocks = Vec::with_capacity(tool_uses.len());
for (id, name, _) in &tool_uses {
let _ = tx
.send(StreamEvent::ToolResult {
name: name.clone(),
content: Self::INTERRUPTED_BY_USER.to_string(),
is_error: true,
})
.await;
result_blocks.push(ContentBlock::ToolResult {
tool_use_id: id.clone(),
content: Self::INTERRUPTED_BY_USER.to_string(),
is_error: Some(true),
});
}
self.messages.push(Message::tool_results(result_blocks));
}
let _ = tx.send(StreamEvent::Interrupted).await;
return Ok(());
}
if tool_uses.is_empty() {
let _ = tx.send(StreamEvent::Done).await;
break;
}
let (result_blocks, interrupted) = self
.execute_tool_batch(&tool_uses, &tx, interactive, &cancel)
.await;
self.messages.push(Message::tool_results(result_blocks));
if interrupted {
let _ = tx.send(StreamEvent::Interrupted).await;
return Ok(());
}
}
Ok(())
}
async fn execute_tool_batch(
&mut self,
tool_uses: &[(String, String, serde_json::Value)],
tx: &mpsc::Sender<StreamEvent>,
interactive: bool,
cancel: &tokio_util::sync::CancellationToken,
) -> (Vec<ContentBlock>, bool) {
let mut outputs: Vec<Option<crate::tools::ToolOutput>> =
(0..tool_uses.len()).map(|_| None).collect();
let parallel: Vec<usize> = tool_uses
.iter()
.enumerate()
.filter(|(_, (_, name, input))| {
let ro = self.tools.is_read_only(name);
ro && matches!(
self.permissions.check(name, input, ro),
PermissionResult::Allow
)
})
.map(|(idx, _)| idx)
.collect();
let mut interrupted = false;
if !self.steering_pending() && !cancel.is_cancelled() && !parallel.is_empty() {
let this: &Self = &*self;
let futures: Vec<_> = parallel
.iter()
.map(|&idx| {
let (_, name, input) = &tool_uses[idx];
async move {
(
idx,
this.execute_tool_steerable(name, input.clone(), cancel)
.await,
)
}
})
.collect();
for (idx, output) in futures_util::future::join_all(futures).await {
outputs[idx] = Some(output);
}
}
for (idx, (_, name, input)) in tool_uses.iter().enumerate() {
if outputs[idx].is_some() {
continue;
}
if cancel.is_cancelled() {
interrupted = true;
outputs[idx] = Some(crate::tools::ToolOutput {
content: Self::INTERRUPTED_BY_USER.to_string(),
is_error: true,
});
continue;
}
if self.steering_pending() {
outputs[idx] = Some(crate::tools::ToolOutput {
content: Self::SKIPPED_FOR_STEERING.to_string(),
is_error: true,
});
continue;
}
let is_read_only = self.tools.is_read_only(name);
let perm = self.permissions.check(name, input, is_read_only);
let output = match perm {
PermissionResult::Allow => {
self.execute_tool_steerable(name, input.clone(), cancel)
.await
}
PermissionResult::Deny(reason) => crate::tools::ToolOutput {
content: format!("Permission denied: {reason}"),
is_error: true,
},
PermissionResult::Ask { message, diff } => {
if !interactive {
crate::tools::ToolOutput {
content: format!(
"Permission denied: {message} (one-shot mode has no prompt; set permission_mode in config.toml to allow)"
),
is_error: true,
}
} else {
self.ask_permission(name, input, message, diff, tx, cancel)
.await
}
}
};
outputs[idx] = Some(output);
}
if cancel.is_cancelled() {
interrupted = true;
}
let mut result_blocks = Vec::with_capacity(tool_uses.len());
for (idx, (id, name, _)) in tool_uses.iter().enumerate() {
let output = outputs[idx].take().expect("every tool got an output");
let (content, was_truncated) = compact::truncate_tool_output(&output.content);
if was_truncated {
tracing::debug!("Truncated tool output for {}", name);
}
let _ = tx
.send(StreamEvent::ToolResult {
name: name.clone(),
content: content.clone(),
is_error: output.is_error,
})
.await;
result_blocks.push(ContentBlock::ToolResult {
tool_use_id: id.clone(),
content,
is_error: if output.is_error { Some(true) } else { None },
});
}
(result_blocks, interrupted)
}
async fn ask_permission(
&mut self,
name: &str,
input: &serde_json::Value,
message: String,
diff: Option<String>,
tx: &mpsc::Sender<StreamEvent>,
cancel: &tokio_util::sync::CancellationToken,
) -> crate::tools::ToolOutput {
let (resp_tx, resp_rx) = oneshot::channel();
let event = if let Some(d) = diff {
StreamEvent::PermissionRequestWithDiff {
tool_name: name.to_string(),
summary: message,
diff: d,
input: input.clone(),
respond: resp_tx,
}
} else {
StreamEvent::PermissionRequest {
tool_name: name.to_string(),
summary: message,
input: input.clone(),
respond: resp_tx,
}
};
let _ = tx.send(event).await;
match resp_rx.await {
Ok(PermissionResponse::Allow) => {
self.execute_tool_steerable(name, input.clone(), cancel)
.await
}
Ok(PermissionResponse::AlwaysAllow) => {
self.permissions.always_allow(name);
self.execute_tool_steerable(name, input.clone(), cancel)
.await
}
Ok(PermissionResponse::AlwaysAllowCommand(ref cmd)) => {
self.permissions.always_allow_command(cmd);
self.execute_tool_steerable(name, input.clone(), cancel)
.await
}
Ok(PermissionResponse::Deny) | Ok(PermissionResponse::DenyAndCancel) | Err(_) => {
crate::tools::ToolOutput {
content: "Permission denied by user.".to_string(),
is_error: true,
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::ToolDefinition;
use crate::permissions::PermissionMode;
use std::time::Instant;
struct MockProvider;
#[async_trait::async_trait]
impl Provider for MockProvider {
fn name(&self) -> &str {
"mock"
}
fn model(&self) -> &str {
"test-model"
}
fn set_model(&mut self, _model: &str) {
}
async fn stream(
&self,
_messages: &[Message],
_system: &str,
_tools: &[ToolDefinition],
_max_tokens: u32,
cancel: tokio_util::sync::CancellationToken,
) -> Result<ProviderStream> {
let (tx, rx) = mpsc::channel(10);
drop(tx);
Ok(ProviderStream::new(rx, cancel.child_token()))
}
}
struct TruncatedProvider;
#[async_trait::async_trait]
impl Provider for TruncatedProvider {
fn name(&self) -> &str {
"truncated"
}
fn model(&self) -> &str {
"test-model"
}
fn set_model(&mut self, _model: &str) {}
async fn stream(
&self,
_messages: &[Message],
_system: &str,
_tools: &[ToolDefinition],
_max_tokens: u32,
cancel: tokio_util::sync::CancellationToken,
) -> Result<ProviderStream> {
let (tx, rx) = mpsc::channel(10);
let _ = tx
.send(ApiEvent::Text("partial response".to_string()))
.await;
drop(tx);
Ok(ProviderStream::new(rx, cancel.child_token()))
}
}
#[tokio::test]
async fn test_parallel_tool_execution() {
let provider = Box::new(MockProvider);
let tools = ToolRegistry::without_agent();
let permissions = PermissionChecker::new(PermissionMode::Bypass);
let mut engine = Engine {
provider,
tools,
permissions,
messages: vec![],
system_prompt: String::new(),
model: "test".to_string(),
max_tokens: 1000,
auto_compact_threshold: 0.8,
steering: SteeringQueue::default(),
cost: CostTracker::new("test"),
};
let tool_uses = vec![
(
"test1".to_string(),
"Read".to_string(),
serde_json::json!({"file_path": "/dev/null"}),
),
(
"test2".to_string(),
"Glob".to_string(),
serde_json::json!({"pattern": "*.rs"}),
),
(
"test3".to_string(),
"Read".to_string(),
serde_json::json!({"file_path": "/dev/null"}),
),
];
let start = Instant::now();
let (batch_tx, mut batch_rx) = mpsc::channel(64);
let drain = tokio::spawn(async move { while batch_rx.recv().await.is_some() {} });
let (blocks, _interrupted) = engine
.execute_tool_batch(
&tool_uses,
&batch_tx,
false,
&tokio_util::sync::CancellationToken::new(),
)
.await;
drop(batch_tx);
drain.await.unwrap();
let duration = start.elapsed();
assert_eq!(blocks.len(), 3, "Should have 3 result blocks");
for (i, block) in blocks.iter().enumerate() {
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
let expected_id = format!("test{}", i + 1);
assert_eq!(
tool_use_id, &expected_id,
"Results should be in original order"
);
} else {
panic!("Expected ToolResult block");
}
}
println!("Parallel execution took: {duration:?}");
}
#[tokio::test]
async fn test_mixed_readonly_and_write_tools() {
let provider = Box::new(MockProvider);
let tools = ToolRegistry::without_agent();
let permissions = PermissionChecker::new(PermissionMode::Bypass);
let mut engine = Engine {
provider,
tools,
permissions,
messages: vec![],
system_prompt: String::new(),
model: "test".to_string(),
max_tokens: 1000,
auto_compact_threshold: 0.8,
steering: SteeringQueue::default(),
cost: CostTracker::new("test"),
};
let tool_uses = vec![
(
"test1".to_string(),
"Read".to_string(), serde_json::json!({"file_path": "/dev/null"}),
),
(
"test2".to_string(),
"Bash".to_string(), serde_json::json!({"command": "echo test"}),
),
(
"test3".to_string(),
"Glob".to_string(), serde_json::json!({"pattern": "*.rs"}),
),
];
let (batch_tx, mut batch_rx) = mpsc::channel(64);
let drain = tokio::spawn(async move { while batch_rx.recv().await.is_some() {} });
let (blocks, _interrupted) = engine
.execute_tool_batch(
&tool_uses,
&batch_tx,
false,
&tokio_util::sync::CancellationToken::new(),
)
.await;
drop(batch_tx);
drain.await.unwrap();
assert_eq!(blocks.len(), 3, "Should have 3 result blocks");
for (i, block) in blocks.iter().enumerate() {
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
let expected_id = format!("test{}", i + 1);
assert_eq!(tool_use_id, &expected_id, "Results should maintain order");
}
}
}
fn steering_engine(
first_round: Vec<(String, String, serde_json::Value)>,
push_on_first_call: Option<String>,
) -> Engine {
crate::test_support::scripted_engine(
first_round,
push_on_first_call,
PermissionMode::Bypass,
)
}
async fn run_streaming(engine: &mut Engine, prompt: &str) {
let (tx, mut rx) = mpsc::channel(64);
let drain = tokio::spawn(async move { while rx.recv().await.is_some() {} });
engine
.submit_streaming(prompt, tx, tokio_util::sync::CancellationToken::new())
.await
.unwrap();
drain.await.unwrap();
}
#[tokio::test]
async fn test_steering_message_injected_after_tool_results() {
let mut engine = steering_engine(
vec![(
"tu_1".to_string(),
"Glob".to_string(),
serde_json::json!({"pattern": "*.does-not-exist"}),
)],
Some("also check the auth module".to_string()),
);
run_streaming(&mut engine, "do a deep review").await;
let msgs = engine.messages();
assert_eq!(msgs.len(), 4, "got: {msgs:?}");
assert_eq!(msgs[0].role, "user");
assert_eq!(msgs[1].role, "assistant");
assert_eq!(msgs[2].role, "user"); assert_eq!(msgs[3].role, "user");
match &msgs[3].content {
crate::api::MessageContent::Text(t) => {
assert_eq!(t, "also check the auth module")
}
other => panic!("expected steering text message, got {other:?}"),
}
assert!(engine.steering_queue().lock().unwrap().is_empty());
}
#[tokio::test]
async fn test_pending_steering_skips_whole_batch() {
let mut engine = steering_engine(
vec![
(
"tu_1".to_string(),
"Glob".to_string(),
serde_json::json!({"pattern": "*.a"}),
),
(
"tu_2".to_string(),
"Glob".to_string(),
serde_json::json!({"pattern": "*.b"}),
),
],
Some("wrong direction, stop".to_string()),
);
run_streaming(&mut engine, "explore").await;
let msgs = engine.messages();
let crate::api::MessageContent::Blocks(blocks) = &msgs[2].content else {
panic!("expected tool results, got {msgs:?}");
};
assert_eq!(blocks.len(), 2);
for block in blocks {
match block {
ContentBlock::ToolResult {
content, is_error, ..
} => {
assert_eq!(content, Engine::SKIPPED_FOR_STEERING);
assert_eq!(*is_error, Some(true));
}
other => panic!("expected ToolResult, got {other:?}"),
}
}
}
#[tokio::test]
async fn test_steering_cancels_running_tool() {
let mut engine = steering_engine(
vec![(
"tu_1".to_string(),
"Bash".to_string(),
serde_json::json!({"command": "sleep 5"}),
)],
None,
);
let steering = engine.steering_queue();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
steering
.lock()
.unwrap()
.push_back("no, run it in nix-shell instead".to_string());
});
let start = std::time::Instant::now();
run_streaming(&mut engine, "run the tests").await;
assert!(
start.elapsed() < std::time::Duration::from_secs(3),
"steering should cancel the running tool, not wait it out (took {:?})",
start.elapsed()
);
let last = engine.messages().last().unwrap();
match &last.content {
crate::api::MessageContent::Text(t) => {
assert_eq!(t, "no, run it in nix-shell instead")
}
other => panic!("expected steering message last, got {other:?}"),
}
}
#[tokio::test]
async fn test_cancellation_ends_turn_with_paired_results() {
let mut engine = steering_engine(
vec![(
"tu_1".to_string(),
"Bash".to_string(),
serde_json::json!({"command": "sleep 5"}),
)],
None,
);
let cancel = tokio_util::sync::CancellationToken::new();
let canceller = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
canceller.cancel();
});
let (tx, mut rx) = mpsc::channel(64);
let events = tokio::spawn(async move {
let mut interrupted = false;
while let Some(ev) = rx.recv().await {
if matches!(ev, StreamEvent::Interrupted) {
interrupted = true;
}
}
interrupted
});
let start = std::time::Instant::now();
engine.submit_streaming("run it", tx, cancel).await.unwrap();
assert!(
start.elapsed() < std::time::Duration::from_secs(3),
"cancellation should not wait out the tool (took {:?})",
start.elapsed()
);
assert!(events.await.unwrap(), "Interrupted event must be emitted");
let msgs = engine.messages();
let crate::api::MessageContent::Blocks(blocks) = &msgs.last().unwrap().content else {
panic!("expected tool results last, got {msgs:?}");
};
assert!(matches!(
&blocks[0],
ContentBlock::ToolResult {
is_error: Some(true),
..
}
));
}
#[tokio::test]
async fn test_submit_returns_text_without_notices() {
let mut engine = steering_engine(
vec![(
"tu_1".to_string(),
"Glob".to_string(),
serde_json::json!({"pattern": "*.x"}),
)],
Some("check auth too".to_string()),
);
let text = engine
.submit("go", tokio_util::sync::CancellationToken::new())
.await
.unwrap();
assert_eq!(text, "working on it", "notices must not leak into text");
let last = engine.messages().last().unwrap();
match &last.content {
crate::api::MessageContent::Text(t) => assert_eq!(t, "check auth too"),
other => panic!("expected steering message last, got {other:?}"),
}
}
#[tokio::test]
async fn test_submit_rejects_stream_closed_without_done() {
let mut engine = Engine::for_tests(
Box::new(TruncatedProvider),
SteeringQueue::default(),
PermissionMode::Bypass,
);
let error = engine
.submit("go", tokio_util::sync::CancellationToken::new())
.await
.unwrap_err();
assert!(error.to_string().contains("without completion"));
assert_eq!(
engine.messages().len(),
1,
"partial assistant content must not be committed to history"
);
assert_eq!(engine.messages()[0].role, "user");
}
#[tokio::test]
async fn test_compact_rejects_stream_closed_without_done() {
let mut engine = Engine::for_tests(
Box::new(TruncatedProvider),
SteeringQueue::default(),
PermissionMode::Bypass,
);
engine
.messages_mut()
.push(Message::user("important context"));
let error = engine.compact().await.unwrap_err();
assert!(error.to_string().contains("without completion"));
assert_eq!(
engine.messages().len(),
1,
"failed compaction must preserve the original history"
);
}
#[tokio::test]
async fn test_steering_preempts_in_non_streaming_submit() {
let mut engine = steering_engine(
vec![(
"tu_1".to_string(),
"Bash".to_string(),
serde_json::json!({"command": "sleep 5"}),
)],
None,
);
let steering = engine.steering_queue();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
steering
.lock()
.unwrap()
.push_back("stop, wrong command".to_string());
});
let start = std::time::Instant::now();
engine
.submit("run it", tokio_util::sync::CancellationToken::new())
.await
.unwrap();
assert!(
start.elapsed() < std::time::Duration::from_secs(3),
"steering should cancel the running tool via submit() too (took {:?})",
start.elapsed()
);
}
#[tokio::test]
async fn test_unknown_tool_yields_error_block_not_abort() {
let provider = Box::new(MockProvider);
let tools = ToolRegistry::without_agent();
let permissions = PermissionChecker::new(PermissionMode::Bypass);
let mut engine = Engine {
provider,
tools,
permissions,
messages: vec![],
system_prompt: String::new(),
model: "test".to_string(),
max_tokens: 1000,
auto_compact_threshold: 0.8,
steering: SteeringQueue::default(),
cost: CostTracker::new("test"),
};
let tool_uses = vec![(
"test1".to_string(),
"TaskCreate".to_string(), serde_json::json!({"subject": "x"}),
)];
let (batch_tx, mut batch_rx) = mpsc::channel(64);
let drain = tokio::spawn(async move { while batch_rx.recv().await.is_some() {} });
let (blocks, _interrupted) = engine
.execute_tool_batch(
&tool_uses,
&batch_tx,
false,
&tokio_util::sync::CancellationToken::new(),
)
.await;
drop(batch_tx);
drain.await.unwrap();
assert_eq!(blocks.len(), 1, "every tool_use must get a tool_result");
match &blocks[0] {
ContentBlock::ToolResult {
tool_use_id,
is_error,
content,
} => {
assert_eq!(tool_use_id, "test1");
assert_eq!(*is_error, Some(true));
assert!(content.contains("Unknown tool"));
}
_ => panic!("Expected ToolResult block"),
}
}
#[tokio::test]
async fn test_ask_permission_denies_in_non_streaming_mode() {
let provider = Box::new(MockProvider);
let tools = ToolRegistry::without_agent();
let permissions = PermissionChecker::new(PermissionMode::Default);
let mut engine = Engine {
provider,
tools,
permissions,
messages: vec![],
system_prompt: String::new(),
model: "test".to_string(),
max_tokens: 1000,
auto_compact_threshold: 0.8,
steering: SteeringQueue::default(),
cost: CostTracker::new("test"),
};
let tool_uses = vec![(
"test1".to_string(),
"Read".to_string(),
serde_json::json!({"file_path": "/etc/hosts"}),
)];
let (batch_tx, mut batch_rx) = mpsc::channel(64);
let drain = tokio::spawn(async move { while batch_rx.recv().await.is_some() {} });
let (blocks, _interrupted) = engine
.execute_tool_batch(
&tool_uses,
&batch_tx,
false,
&tokio_util::sync::CancellationToken::new(),
)
.await;
drop(batch_tx);
drain.await.unwrap();
assert_eq!(blocks.len(), 1);
match &blocks[0] {
ContentBlock::ToolResult {
is_error, content, ..
} => {
assert_eq!(
*is_error,
Some(true),
"Ask-permission tool must be denied, not executed, in non-streaming mode"
);
assert!(
content.contains("Permission denied"),
"expected a permission-denied message, got: {content}"
);
}
_ => panic!("Expected ToolResult block"),
}
}
}