Skip to main content

agent_engine/runtime/
mod.rs

1use crate::{Result, RuntimeError, ToolRegistry};
2use futures::stream::Stream;
3use reqwest::Client;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::sync::Mutex;
9use std::time::Duration;
10use tokio::sync::{mpsc, RwLock};
11use tokio_stream::wrappers::UnboundedReceiverStream;
12use tokio_util::sync::CancellationToken;
13
14mod api;
15mod api_sync;
16mod auth;
17#[cfg(test)]
18mod body_golden;
19pub mod compaction;
20pub(crate) mod helpers;
21pub mod openai;
22mod request;
23mod sse;
24mod sse_types;
25mod stream;
26pub mod subagent;
27pub mod telemetry;
28mod types;
29
30use api::ApiMethods;
31use auth::AuthMethods;
32use helpers::HelperMethods;
33use stream::StreamMethods;
34use types::AuthState;
35pub use types::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
36
37/// Result of resolving before_tool_call extension policy.
38pub enum BeforeToolCallDecision {
39    Continue { input: Value },
40    Block { reason: String },
41}
42
43/// Emit a `before_tool_call` event and include the runtime tool name when it
44/// differs from the API-safe name.
45pub async fn emit_before_tool_call(
46    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
47    tool_name: &str,
48    runtime_tool_name: Option<&str>,
49    input: Value,
50) -> crate::extensions::hooks::events::HookResult {
51    let mut event = crate::extensions::hooks::events::HookEvent::before_tool_call(tool_name, input);
52    if let Some(runtime_tool_name) = runtime_tool_name {
53        event.tool_runtime_name = Some(runtime_tool_name.to_string());
54    }
55    hook_bus.emit(&event).await
56}
57
58/// Resolve a before_tool_call result that may request user confirmation.
59///
60/// When `auto_approve_confirms` is true, `Confirm` is short-circuited to `Continue`.
61/// Headless/non-interactive callers with `auto_approve_confirms = false` fail closed.
62pub async fn resolve_before_tool_call_result(
63    hook_result: crate::extensions::hooks::events::HookResult,
64    secret_prompt: Option<&crate::tools::SecretPromptHandle>,
65    auto_approve_confirms: bool,
66) -> crate::extensions::hooks::events::HookResult {
67    match hook_result {
68        crate::extensions::hooks::events::HookResult::Confirm { message } => {
69            if auto_approve_confirms {
70                tracing::info!(message = %message, "confirm auto-approved (auto_approve_confirms=true)");
71                return crate::extensions::hooks::events::HookResult::Continue;
72            }
73
74            let Some(prompt) = secret_prompt else {
75                return crate::extensions::hooks::events::HookResult::Block {
76                    reason: format!(
77                        "Tool call requires confirmation but no interactive prompt is available: {}",
78                        message
79                    ),
80                };
81            };
82
83            let response = prompt
84                .prompt(
85                    "Confirm tool call".to_string(),
86                    format!("{}\n\nType 'yes' or 'y' to allow.", message),
87                )
88                .await;
89
90            match response.as_deref().map(str::trim) {
91                Some(answer)
92                    if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") =>
93                {
94                    crate::extensions::hooks::events::HookResult::Continue
95                }
96                _ => crate::extensions::hooks::events::HookResult::Block {
97                    reason: format!("Tool call confirmation denied: {}", message),
98                },
99            }
100        }
101        other => other,
102    }
103}
104
105/// Resolve before_tool_call policy into executable input or a block reason.
106pub async fn resolve_before_tool_call_decision(
107    original_input: Value,
108    hook_result: crate::extensions::hooks::events::HookResult,
109    secret_prompt: Option<&crate::tools::SecretPromptHandle>,
110    auto_approve_confirms: bool,
111) -> BeforeToolCallDecision {
112    match resolve_before_tool_call_result(hook_result, secret_prompt, auto_approve_confirms).await {
113        crate::extensions::hooks::events::HookResult::Block { reason } => {
114            BeforeToolCallDecision::Block { reason }
115        }
116        crate::extensions::hooks::events::HookResult::Modify { input } => {
117            BeforeToolCallDecision::Continue { input }
118        }
119        _ => BeforeToolCallDecision::Continue {
120            input: original_input,
121        },
122    }
123}
124
125/// Maximum size (bytes) of a `Replace` output an extension may substitute.
126/// A transform returning more than this is clamped (with a warning) to bound
127/// memory between deserialization and the downstream tool-output truncation.
128/// Generous on purpose — legitimate compression/redaction shrinks output.
129const MAX_REPLACE_OUTPUT: usize = 1024 * 1024; // 1 MiB
130
131/// Emit an `after_tool_call` event and return the tool output to record in
132/// history — either the original `output`, or a `Replace { output }`
133/// substituted by an extension transform hook (compression, redaction,
134/// summarization). The runtime tool name is included when it differs from the
135/// API-safe name.
136///
137/// Fail-safe: any non-`Replace` result — `Continue`, a result rejected by the
138/// permission matrix, a crashed/timed-out handler, or any future variant —
139/// returns the original `output` unchanged. A misbehaving extension can never
140/// drop or corrupt a tool's output.
141///
142/// Note: the event delivered to the extension carries `tool_output` as a
143/// size-limited preview for large outputs — a ~256 KB prefix plus a
144/// `…[truncated, N total bytes]` marker (see [`HookEvent::after_tool_call`]),
145/// matching `max_tool_buffer`. The final returned string is then truncated to
146/// `max_tool_output` (the context budget) — compress-then-truncate ordering,
147/// so a transform extension sees the full buffered output and decides what to
148/// keep before the hard cap is applied.
149pub async fn emit_after_tool_call(
150    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
151    tool_name: &str,
152    runtime_tool_name: Option<&str>,
153    input: Value,
154    output: String,
155    max_tool_output: usize,
156) -> String {
157    use crate::extensions::hooks::events::HookResult;
158    // Keep the original to return verbatim if no transform fires.
159    let original = output.clone();
160    let mut event =
161        crate::extensions::hooks::events::HookEvent::after_tool_call(tool_name, input, output);
162    if let Some(runtime_tool_name) = runtime_tool_name {
163        event.tool_runtime_name = Some(runtime_tool_name.to_string());
164    }
165    let post_hook = match hook_bus.emit(&event).await {
166        HookResult::Replace { mut output } => {
167            if output.len() > MAX_REPLACE_OUTPUT {
168                tracing::warn!(
169                    tool = %tool_name,
170                    len = output.len(),
171                    cap = MAX_REPLACE_OUTPUT,
172                    "Extension Replace output exceeds cap — clamping",
173                );
174                // Floor to a UTF-8 char boundary before truncating.
175                let mut boundary = MAX_REPLACE_OUTPUT;
176                while boundary > 0 && !output.is_char_boundary(boundary) {
177                    boundary -= 1;
178                }
179                output.truncate(boundary);
180            }
181            output
182        }
183        HookResult::Continue => original,
184        other => {
185            tracing::warn!(
186                tool = %tool_name,
187                ?other,
188                "Unexpected after_tool_call result — using original output",
189            );
190            original
191        }
192    };
193    // Compress-then-truncate: apply the context-budget cap AFTER the hook.
194    // Mirrors `HelperMethods::truncate_tool_result` byte-for-byte so the
195    // no-extension path is behavior-identical to the legacy ordering.
196    crate::runtime::helpers::HelperMethods::truncate_tool_result(&post_hook, max_tool_output)
197}
198
199/// The core runtime — manages API communication, tool execution, authentication,
200/// and streaming for all SynapsCLI binaries (chat, chatui, server, agent, watcher).
201pub struct Runtime {
202    client: Client,
203    auth: Arc<RwLock<AuthState>>,
204    model: String,
205    tools: Arc<RwLock<ToolRegistry>>,
206    system_prompt: Option<String>,
207    thinking_budget: u32,
208    /// User override for context window size (tokens). When set, takes
209    /// precedence over the model's auto-detected window from
210    /// `models::context_window_for_model`. Lets users cap context at e.g.
211    /// 200k even on models that natively support 1M.
212    context_window_override: Option<u64>,
213    /// Model used for compaction. Falls back to claude-sonnet-4-6 if not set.
214    compaction_model: Option<String>,
215    /// Shared registry for reactive subagent handles.
216    subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
217    /// Shared event queue — for Event Bus tooling.
218    event_queue: Arc<crate::events::EventQueue>,
219    /// Path for watcher_exit tool to write handoff state (agent mode only)
220    pub watcher_exit_path: Option<PathBuf>,
221    // New configurable fields
222    max_tool_output: usize,
223    bash_timeout: u64,
224    bash_max_timeout: u64,
225    subagent_timeout: u64,
226    api_retries: u32,
227    refusal_retries: u32,
228    /// Telemetry level for structured per-request API logging (opt-in).
229    telemetry_level: crate::runtime::telemetry::TelemetryLevel,
230    /// Opt into the cache-diagnosis beta (`cache-diagnosis-2026-04-07`).
231    cache_diagnostics: bool,
232    /// Prompt-cache TTL strategy (5m default | 1h | hybrid). Threaded into
233    /// every request via `ApiOptions`.
234    cache_ttl: crate::core::config::CacheTtl,
235    /// One-time-per-session latch for the silent 1h-downgrade notice
236    /// (spec §3.4.1). Shared into `ApiOptions` for every request.
237    ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
238    /// Session-scoped "1h honored at least once" latch (spec §3.4.1) —
239    /// suppresses the downgrade notice on healthy Hybrid turns where the 1h
240    /// prefix is already cached. Shared into `ApiOptions` for every request.
241    saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
242    /// Last Anthropic message id (`msg_...`) — threaded into the next
243    /// request's `diagnostics.previous_message_id` when diagnostics is on.
244    /// Reserved for the cache-diagnosis beta wiring (handoff item).
245    #[allow(dead_code)]
246    last_msg_id: Arc<Mutex<Option<String>>>,
247    session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
248    /// Extension hook bus for dispatching events to extensions.
249    hook_bus: Arc<crate::extensions::hooks::HookBus>,
250    // Held to keep the reaper task alive for the Runtime's lifetime; never read directly.
251    #[allow(dead_code)]
252    reaper_handle: Option<tokio::task::JoinHandle<()>>,
253    #[allow(dead_code)]
254    reaper_cancel: Option<tokio_util::sync::CancellationToken>,
255    /// How provider credentials are resolved: `Local` (read/refresh auth.json —
256    /// the default) or `Remote` (fetch short-lived access tokens from a broker).
257    /// Set from config in `apply_config`. See task #157.
258    credential_source: crate::auth::CredentialSource,
259    /// In-memory cache of broker-fetched access tokens (Remote source only).
260    /// Cheap to clone (Arc inside). Never persisted to disk.
261    token_cache: crate::auth::TokenCache,
262}
263
264impl Runtime {
265    pub async fn new() -> Result<Self> {
266        let (auth_token, auth_type, refresh_token, token_expires) = AuthMethods::get_auth_token()?;
267
268        let client = Client::builder()
269            .tls_built_in_webpki_certs(true)
270            .tls_built_in_native_certs(true)
271            .connect_timeout(Duration::from_secs(10))
272            .timeout(Duration::from_secs(300))
273            .build()
274            .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))?;
275
276        let session_manager = {
277            let config = crate::tools::shell::ShellConfig::default();
278            crate::tools::shell::SessionManager::new(config)
279        };
280
281        // Start the idle session reaper
282        let mgr = session_manager.clone();
283        let cancel = tokio_util::sync::CancellationToken::new();
284        let reaper_handle = crate::tools::shell::session::start_reaper(mgr, cancel.clone());
285
286        Ok(Runtime {
287            client,
288            auth: Arc::new(RwLock::new(AuthState {
289                auth_token,
290                auth_type,
291                refresh_token,
292                token_expires,
293            })),
294            model: crate::models::default_model().to_string(),
295            tools: Arc::new(RwLock::new(ToolRegistry::new())),
296            system_prompt: None,
297            thinking_budget: 4096,
298            context_window_override: None,
299            compaction_model: None,
300            subagent_registry: Arc::new(Mutex::new(
301                crate::runtime::subagent::SubagentRegistry::new(),
302            )),
303            event_queue: Arc::new(crate::events::EventQueue::new(1000)),
304            watcher_exit_path: None,
305            max_tool_output: 30000,
306            bash_timeout: 30,
307            bash_max_timeout: 300,
308            subagent_timeout: 300,
309            api_retries: 3,
310            refusal_retries: 2,
311            telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
312            cache_diagnostics: false,
313            cache_ttl: crate::core::config::CacheTtl::default(),
314            ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
315            saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
316            last_msg_id: Arc::new(Mutex::new(None)),
317            session_manager,
318            hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
319            reaper_handle: Some(reaper_handle),
320            reaper_cancel: Some(cancel),
321            credential_source: crate::auth::CredentialSource::Local,
322            token_cache: crate::auth::TokenCache::new(),
323        })
324    }
325
326    /// Offline construction seam for headless test harnesses (P4).
327    ///
328    /// Identical to [`Runtime::new`] except:
329    /// - auth is a stub token — no `auth.json` read, no keychain, no network
330    /// - the idle shell-session reaper is not spawned — no tokio runtime
331    ///   required at construction time
332    ///
333    /// Every accessor (`model()`, `thinking_level()`, tool registry, …) works
334    /// normally; anything that would hit the Anthropic API fails at call time,
335    /// which is the correct behavior for a harness that only drives the UI.
336    #[cfg(any(test, feature = "testing"))]
337    pub fn new_headless() -> Self {
338        let client = Client::builder()
339            .tls_built_in_webpki_certs(true)
340            .tls_built_in_native_certs(true)
341            .connect_timeout(Duration::from_secs(10))
342            .timeout(Duration::from_secs(300))
343            .build()
344            .expect("reqwest client construction is infallible with built-in roots");
345
346        let session_manager = {
347            let config = crate::tools::shell::ShellConfig::default();
348            crate::tools::shell::SessionManager::new(config)
349        };
350
351        Runtime {
352            client,
353            auth: Arc::new(RwLock::new(AuthState {
354                auth_token: "test-token".to_string(),
355                auth_type: "api_key".to_string(),
356                refresh_token: None,
357                token_expires: None,
358            })),
359            model: crate::models::default_model().to_string(),
360            tools: Arc::new(RwLock::new(ToolRegistry::new())),
361            system_prompt: None,
362            thinking_budget: 4096,
363            context_window_override: None,
364            compaction_model: None,
365            subagent_registry: Arc::new(Mutex::new(
366                crate::runtime::subagent::SubagentRegistry::new(),
367            )),
368            event_queue: Arc::new(crate::events::EventQueue::new(1000)),
369            watcher_exit_path: None,
370            max_tool_output: 30000,
371            bash_timeout: 30,
372            bash_max_timeout: 300,
373            subagent_timeout: 300,
374            api_retries: 3,
375            refusal_retries: 2,
376            telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
377            cache_diagnostics: false,
378            cache_ttl: crate::core::config::CacheTtl::default(),
379            ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
380            saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
381            last_msg_id: Arc::new(Mutex::new(None)),
382            session_manager,
383            hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
384            reaper_handle: None,
385            reaper_cancel: None,
386            credential_source: crate::auth::CredentialSource::Local,
387            token_cache: crate::auth::TokenCache::new(),
388        }
389    }
390
391    pub fn set_system_prompt(&mut self, prompt: String) {
392        self.system_prompt = Some(prompt);
393    }
394
395    pub fn system_prompt(&self) -> Option<&str> {
396        self.system_prompt.as_deref()
397    }
398
399    pub fn set_model(&mut self, model: String) {
400        // Strip any health/status prefix (e.g. "✅  339ms  groq/..." → "groq/...")
401        let cleaned = if let Some(pos) = model.find("claude-") {
402            model[pos..].to_string()
403        } else if let Some(pos) = model.find('/') {
404            let before = &model[..pos];
405            let key_start = before
406                .rfind(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
407                .map(|i| {
408                    i + before[i..]
409                        .chars()
410                        .next()
411                        .map(|c| c.len_utf8())
412                        .unwrap_or(1)
413                })
414                .unwrap_or(0);
415            model[key_start..].to_string()
416        } else {
417            model
418        };
419        self.model = cleaned;
420    }
421
422    pub fn set_tools(&mut self, tools: ToolRegistry) {
423        self.tools = Arc::new(RwLock::new(tools));
424    }
425
426    pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
427        &self.subagent_registry
428    }
429
430    pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
431        &self.event_queue
432    }
433
434    /// Get a shared reference to the extension hook bus.
435    pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
436        &self.hook_bus
437    }
438
439    /// Get a shared reference to the tool registry (for MCP lazy loading).
440    pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
441        Arc::clone(&self.tools)
442    }
443
444    pub fn model(&self) -> &str {
445        &self.model
446    }
447
448    pub fn http_client(&self) -> &Client {
449        &self.client
450    }
451    pub fn set_thinking_budget(&mut self, budget: u32) {
452        self.thinking_budget = budget;
453    }
454
455    pub fn set_compaction_model(&mut self, model: Option<String>) {
456        self.compaction_model = model;
457    }
458
459    pub fn set_context_window(&mut self, window: Option<u64>) {
460        self.context_window_override = window;
461    }
462
463    /// Effective context window for the current model — user override if set,
464    /// otherwise the model's native window from `models::context_window_for_model`.
465    pub fn compaction_model(&self) -> &str {
466        self.compaction_model
467            .as_deref()
468            .unwrap_or("claude-sonnet-4-6")
469    }
470
471    pub fn context_window(&self) -> u64 {
472        self.context_window_override
473            .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
474    }
475
476    /// Apply a parsed config file to this runtime (model, thinking budget, etc.)
477    pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
478        if let Some(ref model) = config.model {
479            self.set_model(model.clone());
480        }
481        if let Some(budget) = config.thinking_budget {
482            self.set_thinking_budget(budget);
483        }
484        self.context_window_override = config.context_window;
485        self.compaction_model = config.compaction_model.clone();
486        self.max_tool_output = config.max_tool_output;
487        self.bash_timeout = config.bash_timeout;
488        self.bash_max_timeout = config.bash_max_timeout;
489        self.subagent_timeout = config.subagent_timeout;
490        self.api_retries = config.api_retries;
491        self.refusal_retries = config.refusal_retries;
492        self.telemetry_level =
493            crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
494        self.cache_diagnostics = config.cache_diagnostics;
495        self.cache_ttl = config.cache_ttl;
496        self.apply_auth_config(config);
497
498        // Remove any built-in tools the user disabled via `disabled_tools`.
499        // try_write is safe here: apply_config runs at boot before the registry
500        // is shared with other tasks.
501        if !config.disabled_tools.is_empty() {
502            if let Ok(mut reg) = self.tools.try_write() {
503                reg.disable(&config.disabled_tools);
504            }
505        }
506    }
507
508    /// Apply only the credential-source portion of config. Used by subagent
509    /// spawns that build a fresh `Runtime::new()` (which defaults to Local) and
510    /// must inherit the user's Remote/broker setup. (#158 A3)
511    ///
512    /// A6: invalidates the token cache if the source/endpoint changed.
513    /// A2: scrubs `AuthState` when Remote so no local `auth.json` credential is
514    /// used or held (invariant 1).
515    pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
516        let new_source = config.auth.credential_source();
517        if new_source != self.credential_source {
518            self.token_cache.invalidate("anthropic");
519        }
520        self.credential_source = new_source;
521        if self.credential_source.is_remote() {
522            if let Ok(mut auth) = self.auth.try_write() {
523                AuthMethods::scrub_for_remote(&mut auth);
524            }
525        }
526    }
527
528    pub fn thinking_budget(&self) -> u32 {
529        self.thinking_budget
530    }
531
532    pub fn max_tool_output(&self) -> usize {
533        self.max_tool_output
534    }
535
536    pub fn bash_timeout(&self) -> u64 {
537        self.bash_timeout
538    }
539
540    pub fn bash_max_timeout(&self) -> u64 {
541        self.bash_max_timeout
542    }
543
544    pub fn subagent_timeout(&self) -> u64 {
545        self.subagent_timeout
546    }
547
548    pub fn api_retries(&self) -> u32 {
549        self.api_retries
550    }
551
552    pub fn set_max_tool_output(&mut self, v: usize) {
553        self.max_tool_output = v;
554    }
555
556    pub fn set_bash_timeout(&mut self, v: u64) {
557        self.bash_timeout = v;
558    }
559
560    pub fn set_bash_max_timeout(&mut self, v: u64) {
561        self.bash_max_timeout = v;
562    }
563
564    pub fn set_subagent_timeout(&mut self, v: u64) {
565        self.subagent_timeout = v;
566    }
567
568    pub fn set_api_retries(&mut self, v: u32) {
569        self.api_retries = v;
570    }
571
572    pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
573        self.telemetry_level
574    }
575
576    pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
577        self.telemetry_level = level;
578    }
579
580    pub fn cache_diagnostics(&self) -> bool {
581        self.cache_diagnostics
582    }
583
584    pub fn set_cache_diagnostics(&mut self, v: bool) {
585        self.cache_diagnostics = v;
586    }
587
588    pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
589        self.cache_ttl
590    }
591
592    /// Change the cache TTL strategy mid-session. The next request re-marks
593    /// with the new TTL; the old prefix expires naturally (single-last
594    /// strategy never prunes old markers, so no invalidation logic needed).
595    pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
596        self.cache_ttl = ttl;
597    }
598
599    pub fn thinking_level(&self) -> &str {
600        crate::core::models::thinking_level_for_budget(self.thinking_budget)
601    }
602
603    /// Check if the OAuth token is expired and refresh it if needed.
604    pub async fn refresh_if_needed(&self) -> Result<()> {
605        // Non-Anthropic models resolve their own provider auth in the OpenAI
606        // path (incl. via the broker), so skip the Anthropic pre-fetch. (#158 #7)
607        if !crate::runtime::auth::model_is_anthropic(&self.model) {
608            return Ok(());
609        }
610        AuthMethods::refresh_if_needed(
611            Arc::clone(&self.auth),
612            &self.client,
613            &self.credential_source,
614            &self.token_cache,
615        )
616        .await
617    }
618
619    /// Make a simple non-streaming API call for compaction (no tools).
620    ///
621    /// Uses a dedicated summarization system prompt (not the user's), omits
622    /// all tools, and returns the raw text response. Caller supplies the
623    /// full message array including the serialized conversation.
624    pub async fn compact_call(&self, messages: Vec<crate::SharedMessage>) -> Result<String> {
625        self.refresh_if_needed().await?;
626
627        use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
628
629        ApiMethods::call_api_simple(
630            &self.auth,
631            &self.client,
632            self.compaction_model(),
633            COMPACTION_SYSTEM_PROMPT,
634            self.thinking_budget,
635            &messages,
636            self.api_retries,
637        )
638        .await
639    }
640
641    /// Run a single prompt synchronously (non-streaming). Handles tool execution
642    /// internally, looping until the model produces a final text response.
643    pub async fn run_single(&self, prompt: &str) -> Result<String> {
644        // Refresh OAuth token if expired
645        self.refresh_if_needed().await?;
646
647        let mut messages: Vec<crate::SharedMessage> =
648            vec![std::sync::Arc::new(json!({"role": "user", "content": prompt}))];
649
650        loop {
651            let response = ApiMethods::call_api(
652                &self.auth,
653                &self.client,
654                &self.model,
655                &*self.tools.read().await,
656                &self.system_prompt,
657                self.thinking_budget,
658                &messages,
659                self.api_retries,
660                &api::ApiOptions {
661                    use_1m_context: self.context_window_override == Some(1_000_000),
662                    cache_ttl: self.cache_ttl,
663                    ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
664                    saw_1h_honored: self.saw_1h_honored.clone(),
665                    credential_source: self.credential_source.clone(),
666                    token_cache: self.token_cache.clone(),
667                    anthropic_base_url: None,
668                },
669            )
670            .await?;
671
672            // Check if Claude wants to use tools
673            if let Some(content) = response["content"].as_array() {
674                let mut response_text = String::new();
675                let mut tool_uses = Vec::new();
676
677                // Process response content
678                for item in content {
679                    match item["type"].as_str() {
680                        Some("text") => {
681                            if let Some(text) = item["text"].as_str() {
682                                response_text.push_str(text);
683                            }
684                        }
685                        Some("tool_use") => {
686                            tool_uses.push(item.clone());
687                        }
688                        _ => {}
689                    }
690                }
691
692                // If no tool uses, return the text response
693                if tool_uses.is_empty() {
694                    return Ok(response_text);
695                }
696
697                // Add assistant's response to conversation (only content, role)
698                messages.push(std::sync::Arc::new(json!({
699                    "role": "assistant",
700                    "content": content
701                })));
702
703                // Execute tools — parallel when multiple are requested
704                let mut tool_results = Vec::new();
705
706                if tool_uses.len() == 1 {
707                    // Single tool — run inline, no spawn overhead
708                    let tool_use = &tool_uses[0];
709                    if let (Some(tool_name), Some(tool_id)) =
710                        (tool_use["name"].as_str(), tool_use["id"].as_str())
711                    {
712                        let input = &tool_use["input"];
713                        let result = match self.tools.read().await.get(tool_name).cloned() {
714                            Some(tool) => {
715                                let input = self
716                                    .tools
717                                    .read()
718                                    .await
719                                    .translate_input_for_api_tool(tool_name, input.clone());
720                                let runtime_name = self
721                                    .tools
722                                    .read()
723                                    .await
724                                    .runtime_name_for_api(tool_name)
725                                    .to_string();
726                                let ctx = crate::ToolContext {
727                                    channels: crate::tools::ToolChannels {
728                                        tx_delta: None,
729                                        tx_events: None,
730                                    },
731                                    capabilities: crate::tools::ToolCapabilities {
732                                        watcher_exit_path: self.watcher_exit_path.clone(),
733                                        tool_register_tx: None,
734                                        session_manager: Some(self.session_manager.clone()),
735                                        subagent_registry: Some(self.subagent_registry.clone()),
736                                        event_queue: Some(self.event_queue.clone()),
737                                        secret_prompt: None,
738                                    },
739                                    limits: crate::tools::ToolLimits {
740                                        max_tool_output: self.max_tool_output,
741                                        max_tool_buffer: 256 * 1024,
742                                        bash_timeout: self.bash_timeout,
743                                        bash_max_timeout: self.bash_max_timeout,
744                                        subagent_timeout: self.subagent_timeout,
745                                    },
746                                };
747                                let decision = resolve_before_tool_call_decision(
748                                    input.clone(),
749                                    emit_before_tool_call(
750                                        &self.hook_bus,
751                                        tool_name,
752                                        Some(&runtime_name),
753                                        input.clone(),
754                                    )
755                                    .await,
756                                    None,
757                                    false,
758                                )
759                                .await;
760                                if let BeforeToolCallDecision::Block { reason } = decision {
761                                    format!("Tool call blocked by extension: {}", reason)
762                                } else {
763                                    let BeforeToolCallDecision::Continue { input } = decision
764                                    else {
765                                        unreachable!()
766                                    };
767                                    let input_for_hook = input.clone();
768                                    let output = match tool.execute(input, ctx).await {
769                                        Ok(output) => output,
770                                        Err(e) => e.to_string(),
771                                    };
772                                    let output = emit_after_tool_call(
773                                        &self.hook_bus,
774                                        tool_name,
775                                        Some(&runtime_name),
776                                        input_for_hook,
777                                        output,
778                                        self.max_tool_output,
779                                    )
780                                    .await;
781                                    output
782                                }
783                            }
784                            None => format!("Unknown tool: {}", tool_name),
785                        };
786                        tool_results.push(json!({
787                            "type": "tool_result",
788                            "tool_use_id": tool_id,
789                            "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
790                        }));
791                    }
792                } else {
793                    // Multiple tools — run in parallel with JoinSet
794                    let mut join_set = tokio::task::JoinSet::new();
795
796                    // Capture config values before spawning (can't borrow &self in 'static spawn)
797                    let cfg_max_tool_output = self.max_tool_output;
798                    let cfg_bash_timeout = self.bash_timeout;
799                    let cfg_bash_max_timeout = self.bash_max_timeout;
800                    let cfg_subagent_timeout = self.subagent_timeout;
801                    let session_mgr = self.session_manager.clone();
802                    let cfg_subagent_registry = self.subagent_registry.clone();
803                    let cfg_event_queue = self.event_queue.clone();
804                    let cfg_hook_bus = self.hook_bus.clone();
805
806                    for tool_use in &tool_uses {
807                        if let (Some(tool_name), Some(tool_id)) = (
808                            tool_use["name"].as_str().map(|s| s.to_string()),
809                            tool_use["id"].as_str().map(|s| s.to_string()),
810                        ) {
811                            let input = tool_use["input"].clone();
812                            let tools_snapshot = self.tools.read().await;
813                            let input =
814                                tools_snapshot.translate_input_for_api_tool(&tool_name, input);
815                            let runtime_name =
816                                tools_snapshot.runtime_name_for_api(&tool_name).to_string();
817                            let tool = tools_snapshot.get(&tool_name).cloned();
818                            drop(tools_snapshot);
819                            let exit_path = self.watcher_exit_path.clone();
820                            let session_mgr_inner = session_mgr.clone();
821                            let registry_inner = cfg_subagent_registry.clone();
822                            let event_queue_inner = cfg_event_queue.clone();
823                            let hook_bus_inner = cfg_hook_bus.clone();
824                            let tool_name_for_hook = tool_name.clone();
825                            let runtime_name_for_hook = runtime_name.clone();
826
827                            join_set.spawn(async move {
828                                let result = match tool {
829                                    Some(t) => {
830                                        let decision =
831                                            crate::runtime::resolve_before_tool_call_decision(
832                                                input.clone(),
833                                                crate::runtime::emit_before_tool_call(
834                                                    &hook_bus_inner,
835                                                    &tool_name_for_hook,
836                                                    Some(&runtime_name_for_hook),
837                                                    input.clone(),
838                                                )
839                                                .await,
840                                                None,
841                                                false,
842                                            )
843                                            .await;
844                                        if let crate::runtime::BeforeToolCallDecision::Block {
845                                            reason,
846                                        } = decision
847                                        {
848                                            format!("Tool call blocked by extension: {}", reason)
849                                        } else {
850                                            let crate::runtime::BeforeToolCallDecision::Continue {
851                                                input,
852                                            } = decision
853                                            else {
854                                                unreachable!()
855                                            };
856                                            let ctx = crate::ToolContext {
857                                                channels: crate::tools::ToolChannels {
858                                                    tx_delta: None,
859                                                    tx_events: None,
860                                                },
861                                                capabilities: crate::tools::ToolCapabilities {
862                                                    watcher_exit_path: exit_path,
863                                                    tool_register_tx: None,
864                                                    session_manager: Some(session_mgr_inner),
865                                                    subagent_registry: Some(registry_inner),
866                                                    event_queue: Some(event_queue_inner),
867                                                    secret_prompt: None,
868                                                },
869                                                limits: crate::tools::ToolLimits {
870                                                    max_tool_output: cfg_max_tool_output,
871                                                    max_tool_buffer: 256 * 1024,
872                                                    bash_timeout: cfg_bash_timeout,
873                                                    bash_max_timeout: cfg_bash_max_timeout,
874                                                    subagent_timeout: cfg_subagent_timeout,
875                                                },
876                                            };
877                                            let input_for_hook = input.clone();
878                                            let output = match t.execute(input, ctx).await {
879                                                Ok(output) => output,
880                                                Err(e) => e.to_string(),
881                                            };
882                                            let output = crate::runtime::emit_after_tool_call(
883                                                &hook_bus_inner,
884                                                &tool_name_for_hook,
885                                                Some(&runtime_name_for_hook),
886                                                input_for_hook,
887                                                output,
888                                                cfg_max_tool_output,
889                                            )
890                                            .await;
891                                            output
892                                        }
893                                    }
894                                    None => format!("Unknown tool: {}", tool_name),
895                                };
896                                (tool_id, result)
897                            });
898                        }
899                    }
900
901                    // Collect results, preserving order by tool_id
902                    let mut results_map = std::collections::HashMap::new();
903                    while let Some(res) = join_set.join_next().await {
904                        match res {
905                            Ok((tool_id, result)) => {
906                                results_map.insert(tool_id, result);
907                            }
908                            Err(e) => {
909                                // Task panicked — log it but don't crash
910                                tracing::error!("Parallel tool task panicked: {}", e);
911                            }
912                        }
913                    }
914
915                    // Build tool_results in original order — every tool_use MUST have a result
916                    for tool_use in &tool_uses {
917                        if let Some(tool_id) = tool_use["id"].as_str() {
918                            let result = results_map.remove(tool_id).unwrap_or_else(|| {
919                                "Tool execution failed: task panicked".to_string()
920                            });
921                            tool_results.push(json!({
922                                "type": "tool_result",
923                                "tool_use_id": tool_id,
924                                "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
925                            }));
926                        }
927                    }
928                }
929
930                // Add tool results to conversation
931                messages.push(std::sync::Arc::new(json!({
932                    "role": "user",
933                    "content": tool_results
934                })));
935
936                // Continue the loop to get Claude's response with tool results
937            } else {
938                return Err(RuntimeError::Tool("Invalid response format".to_string()));
939            }
940        }
941    }
942
943    /// Run a prompt as a cancellable stream of [`StreamEvent`]s. Convenience wrapper
944    /// around [`run_stream_with_messages`] for single-turn usage.
945    pub async fn run_stream(
946        &self,
947        prompt: String,
948        cancel: CancellationToken,
949    ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
950        self.run_stream_with_messages(
951            vec![std::sync::Arc::new(json!({"role": "user", "content": prompt}))],
952            cancel,
953            None,
954            None,
955            false,
956        )
957        .await
958    }
959
960    /// Run a multi-turn conversation as a cancellable stream of [`StreamEvent`]s.
961    /// This is the main entry point for chat UIs and agents. Handles tool execution,
962    /// API retries, and dynamic tool registration (MCP) internally.
963    pub async fn run_stream_with_messages(
964        &self,
965        messages: Vec<crate::SharedMessage>,
966        cancel: CancellationToken,
967        steering_rx: Option<mpsc::UnboundedReceiver<String>>,
968        secret_prompt: Option<crate::tools::SecretPromptHandle>,
969        auto_approve_confirms: bool,
970    ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
971        let (tx, rx) = mpsc::unbounded_channel();
972
973        // Refresh OAuth token if expired before starting the stream.
974        if let Err(e) = self.refresh_if_needed().await {
975            let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
976            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
977            return Box::pin(UnboundedReceiverStream::new(rx));
978        }
979
980        // Clone the Arc, not the whole Runtime — the spawned task shares the
981        // same AuthState so mid-loop token refreshes are visible immediately.
982        let auth = Arc::clone(&self.auth);
983        let client = self.client.clone();
984        let credential_source = self.credential_source.clone();
985        let token_cache = self.token_cache.clone();
986        let model = self.model.clone();
987        let tools = self.tools.clone();
988        let system_prompt = self.system_prompt.clone();
989        let thinking_budget = self.thinking_budget;
990        let watcher_exit_path = self.watcher_exit_path.clone();
991        let max_tool_output = self.max_tool_output;
992        let bash_timeout = self.bash_timeout;
993        let bash_max_timeout = self.bash_max_timeout;
994        let subagent_timeout = self.subagent_timeout;
995        let api_retries = self.api_retries;
996        let refusal_retries = self.refusal_retries;
997        let session_manager = self.session_manager.clone();
998        // Opt into the 1M-context beta header only when the user explicitly
999        // requested 1M (via context_window setting). Default 200k matches
1000        // Anthropic's claude-code default and gives smarter inference.
1001        let subagent_registry = self.subagent_registry.clone();
1002        let event_queue = self.event_queue.clone();
1003        let options = api::ApiOptions {
1004            use_1m_context: self.context_window_override == Some(1_000_000),
1005            cache_ttl: self.cache_ttl,
1006            ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
1007            saw_1h_honored: self.saw_1h_honored.clone(),
1008            credential_source: self.credential_source.clone(),
1009            token_cache: self.token_cache.clone(),
1010            anthropic_base_url: None,
1011        };
1012
1013        let session = crate::runtime::stream::StreamSession {
1014            auth,
1015            client,
1016            credential_source,
1017            token_cache,
1018            options,
1019            api_retries,
1020            refusal_retries,
1021            model,
1022            tools,
1023            system_prompt,
1024            thinking_budget,
1025            tx: tx.clone(),
1026            cancel,
1027            steering_rx,
1028            watcher_exit_path,
1029            max_tool_output,
1030            bash_timeout,
1031            bash_max_timeout,
1032            subagent_timeout,
1033            session_manager,
1034            subagent_registry,
1035            event_queue,
1036            secret_prompt,
1037            hook_bus: self.hook_bus.clone(),
1038            auto_approve_confirms,
1039            telemetry_level: self.telemetry_level,
1040        };
1041
1042        tokio::spawn(async move {
1043            if let Err(e) = StreamMethods::run_stream_internal(session, messages).await {
1044                let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
1045            }
1046            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
1047        });
1048
1049        Box::pin(UnboundedReceiverStream::new(rx))
1050    }
1051}
1052
1053impl Clone for Runtime {
1054    fn clone(&self) -> Self {
1055        Self {
1056            client: self.client.clone(),
1057            auth: Arc::clone(&self.auth),
1058            model: self.model.clone(),
1059            tools: self.tools.clone(),
1060            system_prompt: self.system_prompt.clone(),
1061            thinking_budget: self.thinking_budget,
1062            context_window_override: self.context_window_override,
1063            compaction_model: self.compaction_model.clone(),
1064            subagent_registry: self.subagent_registry.clone(),
1065            event_queue: self.event_queue.clone(),
1066            watcher_exit_path: self.watcher_exit_path.clone(),
1067            max_tool_output: self.max_tool_output,
1068            bash_timeout: self.bash_timeout,
1069            bash_max_timeout: self.bash_max_timeout,
1070            subagent_timeout: self.subagent_timeout,
1071            api_retries: self.api_retries,
1072            refusal_retries: self.refusal_retries,
1073            telemetry_level: self.telemetry_level,
1074            cache_diagnostics: self.cache_diagnostics,
1075            cache_ttl: self.cache_ttl,
1076            // Subagents are their own session — fresh latches so a downgrade
1077            // in the subagent's chain surfaces its own (single) notice and
1078            // honored-state isn't inherited from the parent.
1079            ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1080            saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1081            // Subagents start their own request chain — inheriting the parent's
1082            // last msg_id would produce bogus `messages_changed` diagnostics.
1083            last_msg_id: Arc::new(Mutex::new(None)),
1084            session_manager: self.session_manager.clone(),
1085            hook_bus: self.hook_bus.clone(),
1086            reaper_handle: None, // Cloned runtimes don't own the reaper
1087            reaper_cancel: None, // Cloned runtimes don't own the reaper
1088            credential_source: self.credential_source.clone(),
1089            token_cache: self.token_cache.clone(), // shares the same cache (Arc inside)
1090        }
1091    }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097
1098    #[tokio::test]
1099    async fn confirm_without_prompt_fails_closed() {
1100        let result = resolve_before_tool_call_result(
1101            crate::extensions::hooks::events::HookResult::Confirm {
1102                message: "Run deploy?".into(),
1103            },
1104            None,
1105            false,
1106        )
1107        .await;
1108
1109        assert!(matches!(
1110            result,
1111            crate::extensions::hooks::events::HookResult::Block { reason }
1112                if reason.contains("requires confirmation") && reason.contains("Run deploy?")
1113        ));
1114    }
1115
1116    #[tokio::test]
1117    async fn modify_result_replaces_tool_input() {
1118        let result = resolve_before_tool_call_decision(
1119            serde_json::json!({"command":"rm -rf /"}),
1120            crate::extensions::hooks::events::HookResult::Modify {
1121                input: serde_json::json!({"command":"echo safe"}),
1122            },
1123            None,
1124            false,
1125        )
1126        .await;
1127
1128        match result {
1129            BeforeToolCallDecision::Continue { input } => {
1130                assert_eq!(input, serde_json::json!({"command":"echo safe"}));
1131            }
1132            BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
1133        }
1134    }
1135
1136    #[tokio::test]
1137    async fn confirm_prompt_yes_continues() {
1138        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1139        let handle = crate::tools::SecretPromptHandle::new(tx);
1140
1141        let task = tokio::spawn(async move {
1142            let request = rx.recv().await.expect("confirm prompt request");
1143            assert_eq!(request.title, "Confirm tool call");
1144            assert!(request.prompt.contains("Run deploy?"));
1145            let _ = request.response_tx.send(Some("yes".to_string()));
1146        });
1147
1148        let result = resolve_before_tool_call_result(
1149            crate::extensions::hooks::events::HookResult::Confirm {
1150                message: "Run deploy?".into(),
1151            },
1152            Some(&handle),
1153            false,
1154        )
1155        .await;
1156
1157        task.await.unwrap();
1158        assert!(matches!(
1159            result,
1160            crate::extensions::hooks::events::HookResult::Continue
1161        ));
1162    }
1163
1164    #[tokio::test]
1165    async fn confirm_prompt_non_yes_blocks() {
1166        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1167        let handle = crate::tools::SecretPromptHandle::new(tx);
1168
1169        let task = tokio::spawn(async move {
1170            let request = rx.recv().await.expect("confirm prompt request");
1171            let _ = request.response_tx.send(Some("no".to_string()));
1172        });
1173
1174        let result = resolve_before_tool_call_result(
1175            crate::extensions::hooks::events::HookResult::Confirm {
1176                message: "Run deploy?".into(),
1177            },
1178            Some(&handle),
1179            false,
1180        )
1181        .await;
1182
1183        task.await.unwrap();
1184        assert!(matches!(
1185            result,
1186            crate::extensions::hooks::events::HookResult::Block { reason }
1187                if reason.contains("confirmation denied")
1188        ));
1189    }
1190
1191    #[test]
1192    fn test_max_tokens_for_model() {
1193        // Opus models should return 128000
1194        assert_eq!(
1195            HelperMethods::max_tokens_for_model("claude-opus-4-6"),
1196            128000
1197        );
1198        assert_eq!(
1199            HelperMethods::max_tokens_for_model("opus-something"),
1200            128000
1201        );
1202
1203        // Non-opus models should return 64000
1204        assert_eq!(
1205            HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
1206            64000
1207        );
1208        assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
1209        assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
1210        assert_eq!(
1211            HelperMethods::max_tokens_for_model("some-other-model"),
1212            64000
1213        );
1214
1215        // Edge cases
1216        assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
1217        assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); // Case sensitive - uppercase doesn't match
1218        assert_eq!(
1219            HelperMethods::max_tokens_for_model("model-opus-end"),
1220            128000
1221        ); // Contains "opus" anywhere
1222    }
1223
1224    #[test]
1225    fn test_truncate_tool_result() {
1226        let default_max = 30000;
1227
1228        // Short string should remain unchanged
1229        let short = "This is a short string.";
1230        assert_eq!(
1231            HelperMethods::truncate_tool_result(short, default_max),
1232            short
1233        );
1234
1235        // Exactly max should remain unchanged
1236        let exact = "x".repeat(30000);
1237        assert_eq!(
1238            HelperMethods::truncate_tool_result(&exact, default_max),
1239            exact
1240        );
1241
1242        // String longer than max should be truncated with notice
1243        let too_long = "x".repeat(30001);
1244        let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
1245
1246        // Should start with the truncated content
1247        assert!(truncated.starts_with(&"x".repeat(30000)));
1248
1249        // Should contain truncation notice with total char count
1250        assert!(truncated.contains("[truncated — 30001 total chars, showing first 30000]"));
1251
1252        // Should be longer than max (due to notice)
1253        assert!(truncated.len() > 30000);
1254
1255        // Test with a much longer string
1256        let very_long = "a".repeat(50000);
1257        let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
1258        assert!(
1259            truncated_very_long.contains("[truncated — 50000 total chars, showing first 30000]")
1260        );
1261        assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
1262
1263        // Test with custom limit
1264        let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
1265        assert!(custom_truncated.starts_with(&"a".repeat(100)));
1266        assert!(custom_truncated.contains("[truncated — 50000 total chars, showing first 100]"));
1267    }
1268
1269    #[test]
1270    fn test_thinking_level_ranges() {
1271        use crate::core::models::thinking_level_for_budget;
1272
1273        // Sentinel 0 = "adaptive" (S172 — model decides)
1274        assert_eq!(thinking_level_for_budget(0), "adaptive");
1275
1276        // Low range: 1..=2048
1277        assert_eq!(thinking_level_for_budget(1), "low");
1278        assert_eq!(thinking_level_for_budget(1024), "low");
1279        assert_eq!(thinking_level_for_budget(2048), "low");
1280
1281        // Medium range: 2049..=4096
1282        assert_eq!(thinking_level_for_budget(2049), "medium");
1283        assert_eq!(thinking_level_for_budget(3000), "medium");
1284        assert_eq!(thinking_level_for_budget(4096), "medium");
1285
1286        // High range: 4097..=16384
1287        assert_eq!(thinking_level_for_budget(4097), "high");
1288        assert_eq!(thinking_level_for_budget(8192), "high");
1289        assert_eq!(thinking_level_for_budget(16384), "high");
1290
1291        // XHigh range: _ (everything else)
1292        assert_eq!(thinking_level_for_budget(16385), "xhigh");
1293        assert_eq!(thinking_level_for_budget(32768), "xhigh");
1294        assert_eq!(thinking_level_for_budget(100000), "xhigh");
1295    }
1296}