use reqwest::Client;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use crate::{Result, RuntimeError, ToolRegistry};
use std::sync::Mutex;
use tokio::sync::{mpsc, RwLock};
use tokio_stream::wrappers::UnboundedReceiverStream;
use tokio_util::sync::CancellationToken;
use futures::stream::Stream;
use std::pin::Pin;
mod types;
mod auth;
mod api;
mod api_sync;
mod request;
mod stream;
mod helpers;
pub mod subagent;
pub mod openai;
pub use types::{StreamEvent, LlmEvent, SessionEvent, AgentEvent};
use types::AuthState;
use auth::AuthMethods;
use api::ApiMethods;
use stream::StreamMethods;
use helpers::HelperMethods;
pub enum BeforeToolCallDecision {
Continue { input: Value },
Block { reason: String },
}
pub async fn emit_before_tool_call(
hook_bus: &Arc<crate::extensions::hooks::HookBus>,
tool_name: &str,
runtime_tool_name: Option<&str>,
input: Value,
) -> crate::extensions::hooks::events::HookResult {
let mut event = crate::extensions::hooks::events::HookEvent::before_tool_call(tool_name, input);
if let Some(runtime_tool_name) = runtime_tool_name {
event.tool_runtime_name = Some(runtime_tool_name.to_string());
}
hook_bus.emit(&event).await
}
pub async fn resolve_before_tool_call_result(
hook_result: crate::extensions::hooks::events::HookResult,
secret_prompt: Option<&crate::tools::SecretPromptHandle>,
) -> crate::extensions::hooks::events::HookResult {
match hook_result {
crate::extensions::hooks::events::HookResult::Confirm { message } => {
let Some(prompt) = secret_prompt else {
return crate::extensions::hooks::events::HookResult::Block {
reason: format!(
"Tool call requires confirmation but no interactive prompt is available: {}",
message
),
};
};
let response = prompt
.prompt(
"Confirm tool call".to_string(),
format!("{}\n\nType 'yes' or 'y' to allow.", message),
)
.await;
match response.as_deref().map(str::trim) {
Some(answer) if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") => {
crate::extensions::hooks::events::HookResult::Continue
}
_ => crate::extensions::hooks::events::HookResult::Block {
reason: format!("Tool call confirmation denied: {}", message),
},
}
}
other => other,
}
}
pub async fn resolve_before_tool_call_decision(
original_input: Value,
hook_result: crate::extensions::hooks::events::HookResult,
secret_prompt: Option<&crate::tools::SecretPromptHandle>,
) -> BeforeToolCallDecision {
match resolve_before_tool_call_result(hook_result, secret_prompt).await {
crate::extensions::hooks::events::HookResult::Block { reason } => {
BeforeToolCallDecision::Block { reason }
}
crate::extensions::hooks::events::HookResult::Modify { input } => {
BeforeToolCallDecision::Continue { input }
}
_ => BeforeToolCallDecision::Continue { input: original_input },
}
}
pub async fn emit_after_tool_call(
hook_bus: &Arc<crate::extensions::hooks::HookBus>,
tool_name: &str,
runtime_tool_name: Option<&str>,
input: Value,
output: String,
) -> crate::extensions::hooks::events::HookResult {
let mut event = crate::extensions::hooks::events::HookEvent::after_tool_call(
tool_name,
input,
output,
);
if let Some(runtime_tool_name) = runtime_tool_name {
event.tool_runtime_name = Some(runtime_tool_name.to_string());
}
hook_bus.emit(&event).await
}
pub struct Runtime {
client: Client,
auth: Arc<RwLock<AuthState>>,
model: String,
tools: Arc<RwLock<ToolRegistry>>,
system_prompt: Option<String>,
thinking_budget: u32,
context_window_override: Option<u64>,
compaction_model: Option<String>,
subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
event_queue: Arc<crate::events::EventQueue>,
pub watcher_exit_path: Option<PathBuf>,
max_tool_output: usize,
bash_timeout: u64,
bash_max_timeout: u64,
subagent_timeout: u64,
api_retries: u32,
session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
hook_bus: Arc<crate::extensions::hooks::HookBus>,
#[allow(dead_code)]
reaper_handle: Option<tokio::task::JoinHandle<()>>,
#[allow(dead_code)]
reaper_cancel: Option<tokio_util::sync::CancellationToken>,
}
impl Runtime {
pub async fn new() -> Result<Self> {
let (auth_token, auth_type, refresh_token, token_expires) = AuthMethods::get_auth_token()?;
let client = Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(300))
.build()
.map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))?;
let session_manager = {
let config = crate::tools::shell::ShellConfig::default();
crate::tools::shell::SessionManager::new(config)
};
let mgr = session_manager.clone();
let cancel = tokio_util::sync::CancellationToken::new();
let reaper_handle = crate::tools::shell::session::start_reaper(mgr, cancel.clone());
Ok(Runtime {
client,
auth: Arc::new(RwLock::new(AuthState {
auth_token,
auth_type,
refresh_token,
token_expires,
})),
model: crate::models::default_model().to_string(),
tools: Arc::new(RwLock::new(ToolRegistry::new())),
system_prompt: None,
thinking_budget: 4096,
context_window_override: None,
compaction_model: None,
subagent_registry: Arc::new(Mutex::new(crate::runtime::subagent::SubagentRegistry::new())),
event_queue: Arc::new(crate::events::EventQueue::new(1000)),
watcher_exit_path: None,
max_tool_output: 30000,
bash_timeout: 30,
bash_max_timeout: 300,
subagent_timeout: 300,
api_retries: 3,
session_manager,
hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
reaper_handle: Some(reaper_handle),
reaper_cancel: Some(cancel),
})
}
pub fn set_system_prompt(&mut self, prompt: String) {
self.system_prompt = Some(prompt);
}
pub fn system_prompt(&self) -> Option<&str> {
self.system_prompt.as_deref()
}
pub fn set_model(&mut self, model: String) {
let cleaned = if let Some(pos) = model.find("claude-") {
model[pos..].to_string()
} else if let Some(pos) = model.find('/') {
let before = &model[..pos];
let key_start = before.rfind(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
.map(|i| i + before[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1))
.unwrap_or(0);
model[key_start..].to_string()
} else {
model
};
self.model = cleaned;
}
pub fn set_tools(&mut self, tools: ToolRegistry) {
self.tools = Arc::new(RwLock::new(tools));
}
pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
&self.subagent_registry
}
pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
&self.event_queue
}
pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
&self.hook_bus
}
pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
Arc::clone(&self.tools)
}
pub fn model(&self) -> &str {
&self.model
}
pub fn http_client(&self) -> &Client {
&self.client
}
pub fn set_thinking_budget(&mut self, budget: u32) {
self.thinking_budget = budget;
}
pub fn set_compaction_model(&mut self, model: Option<String>) {
self.compaction_model = model;
}
pub fn set_context_window(&mut self, window: Option<u64>) {
self.context_window_override = window;
}
pub fn compaction_model(&self) -> &str {
self.compaction_model.as_deref().unwrap_or("claude-sonnet-4-6")
}
pub fn context_window(&self) -> u64 {
self.context_window_override
.unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
}
pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
if let Some(ref model) = config.model {
self.set_model(model.clone());
}
if let Some(budget) = config.thinking_budget {
self.set_thinking_budget(budget);
}
self.context_window_override = config.context_window;
self.compaction_model = config.compaction_model.clone();
self.max_tool_output = config.max_tool_output;
self.bash_timeout = config.bash_timeout;
self.bash_max_timeout = config.bash_max_timeout;
self.subagent_timeout = config.subagent_timeout;
self.api_retries = config.api_retries;
}
pub fn thinking_budget(&self) -> u32 {
self.thinking_budget
}
pub fn max_tool_output(&self) -> usize {
self.max_tool_output
}
pub fn bash_timeout(&self) -> u64 {
self.bash_timeout
}
pub fn bash_max_timeout(&self) -> u64 {
self.bash_max_timeout
}
pub fn subagent_timeout(&self) -> u64 {
self.subagent_timeout
}
pub fn api_retries(&self) -> u32 {
self.api_retries
}
pub fn set_max_tool_output(&mut self, v: usize) {
self.max_tool_output = v;
}
pub fn set_bash_timeout(&mut self, v: u64) {
self.bash_timeout = v;
}
pub fn set_bash_max_timeout(&mut self, v: u64) {
self.bash_max_timeout = v;
}
pub fn set_subagent_timeout(&mut self, v: u64) {
self.subagent_timeout = v;
}
pub fn set_api_retries(&mut self, v: u32) {
self.api_retries = v;
}
pub fn thinking_level(&self) -> &str {
crate::core::models::thinking_level_for_budget(self.thinking_budget)
}
pub async fn refresh_if_needed(&self) -> Result<()> {
AuthMethods::refresh_if_needed(Arc::clone(&self.auth), &self.client).await
}
pub async fn compact_call(&self, messages: Vec<Value>) -> Result<String> {
self.refresh_if_needed().await?;
use crate::core::compaction::COMPACTION_SYSTEM_PROMPT;
ApiMethods::call_api_simple(
&self.auth,
&self.client,
self.compaction_model(),
COMPACTION_SYSTEM_PROMPT,
self.thinking_budget,
&messages,
self.api_retries,
).await
}
pub async fn run_single(&self, prompt: &str) -> Result<String> {
self.refresh_if_needed().await?;
let mut messages = vec![json!({"role": "user", "content": prompt})];
loop {
let response = ApiMethods::call_api(
&self.auth,
&self.client,
&self.model,
&*self.tools.read().await,
&self.system_prompt,
self.thinking_budget,
&messages,
self.api_retries,
&api::ApiOptions {
use_1m_context: self.context_window_override == Some(1_000_000),
},
).await?;
if let Some(content) = response["content"].as_array() {
let mut response_text = String::new();
let mut tool_uses = Vec::new();
for item in content {
match item["type"].as_str() {
Some("text") => {
if let Some(text) = item["text"].as_str() {
response_text.push_str(text);
}
}
Some("tool_use") => {
tool_uses.push(item.clone());
}
_ => {}
}
}
if tool_uses.is_empty() {
return Ok(response_text);
}
messages.push(json!({
"role": "assistant",
"content": content
}));
let mut tool_results = Vec::new();
if tool_uses.len() == 1 {
let tool_use = &tool_uses[0];
if let (Some(tool_name), Some(tool_id)) = (
tool_use["name"].as_str(),
tool_use["id"].as_str()
) {
let input = &tool_use["input"];
let result = match self.tools.read().await.get(tool_name).cloned() {
Some(tool) => {
let input = self.tools.read().await.translate_input_for_api_tool(tool_name, input.clone());
let runtime_name = self.tools.read().await.runtime_name_for_api(tool_name).to_string();
let ctx = crate::ToolContext {
channels: crate::tools::ToolChannels {
tx_delta: None,
tx_events: None,
},
capabilities: crate::tools::ToolCapabilities {
watcher_exit_path: self.watcher_exit_path.clone(),
tool_register_tx: None,
session_manager: Some(self.session_manager.clone()),
subagent_registry: Some(self.subagent_registry.clone()),
event_queue: Some(self.event_queue.clone()),
secret_prompt: None,
},
limits: crate::tools::ToolLimits {
max_tool_output: self.max_tool_output,
bash_timeout: self.bash_timeout,
bash_max_timeout: self.bash_max_timeout,
subagent_timeout: self.subagent_timeout,
},
};
let decision = resolve_before_tool_call_decision(
input.clone(),
emit_before_tool_call(
&self.hook_bus,
&tool_name,
Some(&runtime_name),
input.clone(),
).await,
None,
).await;
if let BeforeToolCallDecision::Block { reason } = decision {
format!("Tool call blocked by extension: {}", reason)
} else {
let BeforeToolCallDecision::Continue { input } = decision else { unreachable!() };
let input_for_hook = input.clone();
let output = match tool.execute(input, ctx).await {
Ok(output) => output,
Err(e) => format!("Tool execution failed: {}", e),
};
let _ = emit_after_tool_call(
&self.hook_bus,
&tool_name,
Some(&runtime_name),
input_for_hook,
output.clone(),
).await;
output
}
}
None => format!("Unknown tool: {}", tool_name),
};
tool_results.push(json!({
"type": "tool_result",
"tool_use_id": tool_id,
"content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
}));
}
} else {
let mut join_set = tokio::task::JoinSet::new();
let cfg_max_tool_output = self.max_tool_output;
let cfg_bash_timeout = self.bash_timeout;
let cfg_bash_max_timeout = self.bash_max_timeout;
let cfg_subagent_timeout = self.subagent_timeout;
let session_mgr = self.session_manager.clone();
let cfg_subagent_registry = self.subagent_registry.clone();
let cfg_event_queue = self.event_queue.clone();
let cfg_hook_bus = self.hook_bus.clone();
for tool_use in &tool_uses {
if let (Some(tool_name), Some(tool_id)) = (
tool_use["name"].as_str().map(|s| s.to_string()),
tool_use["id"].as_str().map(|s| s.to_string()),
) {
let input = tool_use["input"].clone();
let tools_snapshot = self.tools.read().await;
let input = tools_snapshot.translate_input_for_api_tool(&tool_name, input);
let runtime_name = tools_snapshot.runtime_name_for_api(&tool_name).to_string();
let tool = tools_snapshot.get(&tool_name).cloned();
drop(tools_snapshot);
let exit_path = self.watcher_exit_path.clone();
let session_mgr_inner = session_mgr.clone();
let registry_inner = cfg_subagent_registry.clone();
let event_queue_inner = cfg_event_queue.clone();
let hook_bus_inner = cfg_hook_bus.clone();
let tool_name_for_hook = tool_name.clone();
let runtime_name_for_hook = runtime_name.clone();
join_set.spawn(async move {
let result = match tool {
Some(t) => {
let decision = crate::runtime::resolve_before_tool_call_decision(
input.clone(),
crate::runtime::emit_before_tool_call(
&hook_bus_inner,
&tool_name_for_hook,
Some(&runtime_name_for_hook),
input.clone(),
).await,
None,
).await;
if let crate::runtime::BeforeToolCallDecision::Block { reason } = decision {
format!("Tool call blocked by extension: {}", reason)
} else {
let crate::runtime::BeforeToolCallDecision::Continue { input } = decision else { unreachable!() };
let ctx = crate::ToolContext {
channels: crate::tools::ToolChannels {
tx_delta: None,
tx_events: None,
},
capabilities: crate::tools::ToolCapabilities {
watcher_exit_path: exit_path,
tool_register_tx: None,
session_manager: Some(session_mgr_inner),
subagent_registry: Some(registry_inner),
event_queue: Some(event_queue_inner),
secret_prompt: None,
},
limits: crate::tools::ToolLimits {
max_tool_output: cfg_max_tool_output,
bash_timeout: cfg_bash_timeout,
bash_max_timeout: cfg_bash_max_timeout,
subagent_timeout: cfg_subagent_timeout,
},
};
let input_for_hook = input.clone();
let output = match t.execute(input, ctx).await {
Ok(output) => output,
Err(e) => format!("Tool execution failed: {}", e),
};
let _ = crate::runtime::emit_after_tool_call(
&hook_bus_inner,
&tool_name_for_hook,
Some(&runtime_name_for_hook),
input_for_hook,
output.clone(),
).await;
output
}
}
None => format!("Unknown tool: {}", tool_name),
};
(tool_id, result)
});
}
}
let mut results_map = std::collections::HashMap::new();
while let Some(res) = join_set.join_next().await {
match res {
Ok((tool_id, result)) => {
results_map.insert(tool_id, result);
}
Err(e) => {
tracing::error!("Parallel tool task panicked: {}", e);
}
}
}
for tool_use in &tool_uses {
if let Some(tool_id) = tool_use["id"].as_str() {
let result = results_map.remove(tool_id)
.unwrap_or_else(|| "Tool execution failed: task panicked".to_string());
tool_results.push(json!({
"type": "tool_result",
"tool_use_id": tool_id,
"content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
}));
}
}
}
messages.push(json!({
"role": "user",
"content": tool_results
}));
} else {
return Err(RuntimeError::Tool("Invalid response format".to_string()));
}
}
}
pub async fn run_stream(&self, prompt: String, cancel: CancellationToken) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
self.run_stream_with_messages(vec![json!({"role": "user", "content": prompt})], cancel, None, None).await
}
pub async fn run_stream_with_messages(
&self,
messages: Vec<Value>,
cancel: CancellationToken,
steering_rx: Option<mpsc::UnboundedReceiver<String>>,
secret_prompt: Option<crate::tools::SecretPromptHandle>,
) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
let (tx, rx) = mpsc::unbounded_channel();
if let Err(e) = self.refresh_if_needed().await {
let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
return Box::pin(UnboundedReceiverStream::new(rx));
}
let auth = Arc::clone(&self.auth);
let client = self.client.clone();
let model = self.model.clone();
let tools = self.tools.clone();
let system_prompt = self.system_prompt.clone();
let thinking_budget = self.thinking_budget;
let watcher_exit_path = self.watcher_exit_path.clone();
let max_tool_output = self.max_tool_output;
let bash_timeout = self.bash_timeout;
let bash_max_timeout = self.bash_max_timeout;
let subagent_timeout = self.subagent_timeout;
let api_retries = self.api_retries;
let session_manager = self.session_manager.clone();
let subagent_registry = self.subagent_registry.clone();
let event_queue = self.event_queue.clone();
let options = api::ApiOptions {
use_1m_context: self.context_window_override == Some(1_000_000),
};
let session = crate::runtime::stream::StreamSession {
auth, client, options, api_retries,
model, tools, system_prompt, thinking_budget,
tx: tx.clone(), cancel, steering_rx,
watcher_exit_path, max_tool_output,
bash_timeout, bash_max_timeout, subagent_timeout,
session_manager, subagent_registry, event_queue, secret_prompt,
hook_bus: self.hook_bus.clone(),
};
tokio::spawn(async move {
if let Err(e) = StreamMethods::run_stream_internal(session, messages).await {
let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
}
let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
});
Box::pin(UnboundedReceiverStream::new(rx))
}
}
impl Clone for Runtime {
fn clone(&self) -> Self {
Self {
client: self.client.clone(),
auth: Arc::clone(&self.auth),
model: self.model.clone(),
tools: self.tools.clone(),
system_prompt: self.system_prompt.clone(),
thinking_budget: self.thinking_budget,
context_window_override: self.context_window_override,
compaction_model: self.compaction_model.clone(),
subagent_registry: self.subagent_registry.clone(),
event_queue: self.event_queue.clone(),
watcher_exit_path: self.watcher_exit_path.clone(),
max_tool_output: self.max_tool_output,
bash_timeout: self.bash_timeout,
bash_max_timeout: self.bash_max_timeout,
subagent_timeout: self.subagent_timeout,
api_retries: self.api_retries,
session_manager: self.session_manager.clone(),
hook_bus: self.hook_bus.clone(),
reaper_handle: None, reaper_cancel: None, }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn confirm_without_prompt_fails_closed() {
let result = resolve_before_tool_call_result(
crate::extensions::hooks::events::HookResult::Confirm {
message: "Run deploy?".into(),
},
None,
)
.await;
assert!(matches!(
result,
crate::extensions::hooks::events::HookResult::Block { reason }
if reason.contains("requires confirmation") && reason.contains("Run deploy?")
));
}
#[tokio::test]
async fn modify_result_replaces_tool_input() {
let result = resolve_before_tool_call_decision(
serde_json::json!({"command":"rm -rf /"}),
crate::extensions::hooks::events::HookResult::Modify {
input: serde_json::json!({"command":"echo safe"}),
},
None,
).await;
match result {
BeforeToolCallDecision::Continue { input } => {
assert_eq!(input, serde_json::json!({"command":"echo safe"}));
}
BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
}
}
#[tokio::test]
async fn confirm_prompt_yes_continues() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let handle = crate::tools::SecretPromptHandle::new(tx);
let task = tokio::spawn(async move {
let request = rx.recv().await.expect("confirm prompt request");
assert_eq!(request.title, "Confirm tool call");
assert!(request.prompt.contains("Run deploy?"));
let _ = request.response_tx.send(Some("yes".to_string()));
});
let result = resolve_before_tool_call_result(
crate::extensions::hooks::events::HookResult::Confirm {
message: "Run deploy?".into(),
},
Some(&handle),
)
.await;
task.await.unwrap();
assert!(matches!(
result,
crate::extensions::hooks::events::HookResult::Continue
));
}
#[tokio::test]
async fn confirm_prompt_non_yes_blocks() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let handle = crate::tools::SecretPromptHandle::new(tx);
let task = tokio::spawn(async move {
let request = rx.recv().await.expect("confirm prompt request");
let _ = request.response_tx.send(Some("no".to_string()));
});
let result = resolve_before_tool_call_result(
crate::extensions::hooks::events::HookResult::Confirm {
message: "Run deploy?".into(),
},
Some(&handle),
)
.await;
task.await.unwrap();
assert!(matches!(
result,
crate::extensions::hooks::events::HookResult::Block { reason }
if reason.contains("confirmation denied")
));
}
#[test]
fn test_max_tokens_for_model() {
assert_eq!(HelperMethods::max_tokens_for_model("claude-opus-4-6"), 128000);
assert_eq!(HelperMethods::max_tokens_for_model("opus-something"), 128000);
assert_eq!(HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"), 64000);
assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
assert_eq!(HelperMethods::max_tokens_for_model("some-other-model"), 64000);
assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); assert_eq!(HelperMethods::max_tokens_for_model("model-opus-end"), 128000); }
#[test]
fn test_truncate_tool_result() {
let default_max = 30000;
let short = "This is a short string.";
assert_eq!(HelperMethods::truncate_tool_result(short, default_max), short);
let exact = "x".repeat(30000);
assert_eq!(HelperMethods::truncate_tool_result(&exact, default_max), exact);
let too_long = "x".repeat(30001);
let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
assert!(truncated.starts_with(&"x".repeat(30000)));
assert!(truncated.contains("[truncated — 30001 total chars, showing first 30000]"));
assert!(truncated.len() > 30000);
let very_long = "a".repeat(50000);
let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
assert!(truncated_very_long.contains("[truncated — 50000 total chars, showing first 30000]"));
assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
assert!(custom_truncated.starts_with(&"a".repeat(100)));
assert!(custom_truncated.contains("[truncated — 50000 total chars, showing first 100]"));
}
#[test]
fn test_thinking_level_ranges() {
use crate::core::models::thinking_level_for_budget;
assert_eq!(thinking_level_for_budget(0), "adaptive");
assert_eq!(thinking_level_for_budget(1), "low");
assert_eq!(thinking_level_for_budget(1024), "low");
assert_eq!(thinking_level_for_budget(2048), "low");
assert_eq!(thinking_level_for_budget(2049), "medium");
assert_eq!(thinking_level_for_budget(3000), "medium");
assert_eq!(thinking_level_for_budget(4096), "medium");
assert_eq!(thinking_level_for_budget(4097), "high");
assert_eq!(thinking_level_for_budget(8192), "high");
assert_eq!(thinking_level_for_budget(16384), "high");
assert_eq!(thinking_level_for_budget(16385), "xhigh");
assert_eq!(thinking_level_for_budget(32768), "xhigh");
assert_eq!(thinking_level_for_budget(100000), "xhigh");
}
}