Skip to main content

thndrs_lib/core/
agent.rs

1//! Unified agent loop for both the fake provider and the Umans provider.
2//!
3//! The loop runs on a background thread and sends [`AgentEvent`]s through a
4//! channel. The TUI drains them with `try_recv`, keeping the UI responsive.
5//!
6//! ## Lifecycle
7//!
8//! 1. [`RunHandle::spawn`] starts a thread with a run configuration and cancellation token.
9//! 2. The run emits `Started`, then streams reasoning/assistant deltas and
10//!    tool-use requests.
11//! 3. Each tool-use request is dispatched via [`tools::dispatch_full`] and the
12//!    result is emitted as a `ToolFinished` event appended to the transcript.
13//! 4. For the Umans provider, tool results are fed back into the next turn:
14//!    after each dispatched tool batch, the assistant message (with `tool_use`
15//!    blocks) and the user message (with `tool_result` blocks) are appended to
16//!    the message history, and the provider is re-requested.
17//! 5. The loop enforces bounded tool-budget continuations to prevent recursive
18//!    or unbounded tool-call loops while still allowing longer useful runs.
19//! 6. Cancellation is cooperative: the loop checks the shared [`CancelToken`]
20//!    between events, lines, and tool executions. When cancelled, it emits
21//!    [`AgentEvent::Cancelled`] and stops.
22
23use std::collections::HashMap;
24use std::io::{BufRead, BufReader};
25#[cfg(test)]
26use std::sync::mpsc;
27use std::sync::mpsc::{Receiver, Sender};
28use std::thread;
29use std::time::Duration;
30
31use ureq::http::Response;
32
33use crate::WebSearchMode;
34use crate::app::{AgentEvent, ToolStatus};
35use crate::cli::{ReasoningEffort, ReasoningSummary};
36use crate::providers::{
37    ProviderContentBlock, ProviderContinuation, ProviderError, ProviderMessage, ProviderTurn, StreamFormat,
38    StreamingProvider, StreamingRequest,
39};
40use crate::providers::{anthropic, codex, openai, opencode, umans};
41use crate::tools::{self, AgentRunConfig, ToolOutput, ToolUseRequest, WriteResult, shell::ProcessResult};
42use thndrs_agent::CancelToken;
43use thndrs_agent::{ModelProjectionMessage, ProviderRequestAccounting, ProviderUsageComponents, ProviderUsageRule};
44
45const PROVIDER_RETRY_POLICY: RetryPolicy = RetryPolicy::new(4, Duration::from_millis(2500));
46
47/// Which provider drives this agent run.
48///
49/// The live app usually uses Umans or OpenCode Go. The fake provider is kept
50/// for deterministic offline smoke tests.
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum ProviderKind {
53    /// Deterministic fake provider, i.e. no network, scripted events.
54    Fake,
55    /// Umans Code provider
56    Umans,
57    /// OpenCode Go provider.
58    OpenCodeGo,
59    /// OpenCode Zen provider.
60    OpenCodeZen,
61    /// ChatGPT subscription-backed Codex provider.
62    ChatGptCodex,
63}
64
65impl ProviderKind {
66    pub fn for_model(model: &str) -> Self {
67        if model.starts_with("fake-agent") {
68            ProviderKind::Fake
69        } else if opencode::is_go_model_id(model) {
70            ProviderKind::OpenCodeGo
71        } else if opencode::is_zen_model_id(model) {
72            ProviderKind::OpenCodeZen
73        } else if codex::is_model_id(model) {
74            ProviderKind::ChatGptCodex
75        } else {
76            ProviderKind::Umans
77        }
78    }
79
80    pub fn label(self) -> &'static str {
81        match self {
82            ProviderKind::Fake => "fake",
83            ProviderKind::Umans => "umans",
84            ProviderKind::OpenCodeGo => "opencode-go",
85            ProviderKind::OpenCodeZen => "opencode-zen",
86            ProviderKind::ChatGptCodex => "chatgpt-codex",
87        }
88    }
89}
90
91#[derive(Debug)]
92enum ProviderAttemptError {
93    Request(ProviderError),
94    Stream(String),
95}
96
97impl ProviderAttemptError {
98    fn message<P>(&self) -> String
99    where
100        P: StreamingProvider,
101    {
102        match self {
103            ProviderAttemptError::Request(err) => P::request_error_message(err),
104            ProviderAttemptError::Stream(msg) => msg.clone(),
105        }
106    }
107
108    fn is_retryable<P>(&self) -> bool
109    where
110        P: StreamingProvider,
111    {
112        match self {
113            ProviderAttemptError::Request(err) => P::is_retryable_request_error(err),
114            ProviderAttemptError::Stream(msg) => is_retryable_stream_error(msg),
115        }
116    }
117}
118
119#[derive(Debug)]
120enum MetadataLoaded<T> {
121    Abort,
122    Loaded(T),
123    Unavailable,
124}
125
126pub use thndrs_agent::{RetryPolicy, ToolPermissionDecision};
127
128type ToolExecutionResult = Option<(ToolOutput, Option<WriteResult>, Option<ProcessResult>)>;
129
130/// Application-local permission policy supplied to the provider-neutral run.
131pub type ToolPermissionHook = thndrs_agent::ToolPermissionHook<AgentRunConfig>;
132
133/// Application-local execution override supplied to the provider-neutral run.
134pub type ToolExecutionHook = thndrs_agent::ToolExecutionHook<AgentRunConfig, ToolExecutionResult>;
135
136#[derive(Clone, Debug)]
137struct ToolUseBuilder {
138    id: String,
139    name: String,
140    initial_input: serde_json::Value,
141    input_json: String,
142}
143
144impl ToolUseBuilder {
145    fn finish(self) -> Option<ToolUseRequest> {
146        let input = if self.input_json.trim().is_empty() {
147            self.initial_input
148        } else {
149            serde_json::from_str(&self.input_json).unwrap_or(serde_json::Value::Null)
150        };
151        let arguments = if input.is_null() {
152            String::from("{}")
153        } else {
154            serde_json::to_string(&input).unwrap_or_else(|_| String::from("{}"))
155        };
156        Some(ToolUseRequest::new(self.name, arguments, self.id))
157    }
158}
159
160#[derive(Clone, Debug)]
161struct ChatToolCallBuilder {
162    id: String,
163    name: String,
164    arguments_json: String,
165}
166
167impl ChatToolCallBuilder {
168    fn finish(self) -> Option<ToolUseRequest> {
169        if self.name.is_empty() {
170            None
171        } else {
172            Some(ToolUseRequest::new(
173                self.name,
174                if self.arguments_json.trim().is_empty() { "{}".to_string() } else { self.arguments_json },
175                self.id,
176            ))
177        }
178    }
179}
180
181/// Handle for a single agent run: provider kind, config, prompt, and cancel.
182#[derive(Debug)]
183pub struct RunHandle {
184    pub provider: ProviderKind,
185    pub config: AgentRunConfig,
186    pub prompt: String,
187    pub messages: Vec<ProviderMessage>,
188    pub expects_write: bool,
189    pub steering: Option<Receiver<String>>,
190    pub cancel: CancelToken,
191    pub permission_hook: Option<ToolPermissionHook>,
192    pub execution_hook: Option<ToolExecutionHook>,
193}
194
195impl RunHandle {
196    /// Spawn the unified agent loop on a background thread and return the receiver.
197    ///
198    /// The thread closes its sender when done, so the receiver's `try_recv` will
199    /// return `Err(Disconnected)` once the run completes.
200    ///
201    /// If the receiver is dropped early (e.g. the user cancels), the thread exits
202    /// on the next failed send. The [`CancelToken`] inside the handle can also be
203    /// signalled for cooperative cancellation.
204    pub fn spawn(self) -> Receiver<AgentEvent> {
205        let cancel = self.cancel.clone();
206        tracing::info!(provider = ?self.provider, "starting agent thread");
207        thndrs_agent::AgentRun::spawn(cancel, move |sender, cancel| self.run_agent(&sender, &cancel)).into_events()
208    }
209
210    /// Create a fake-provider run handle.
211    pub fn fake(config: AgentRunConfig, prompt: String) -> Self {
212        RunHandle {
213            provider: ProviderKind::Fake,
214            config,
215            prompt,
216            messages: Vec::new(),
217            expects_write: false,
218            steering: None,
219            cancel: CancelToken::new(),
220            permission_hook: None,
221            execution_hook: None,
222        }
223    }
224
225    /// Create a provider run handle with a steering-message receiver.
226    pub fn provider_with_steering(
227        config: AgentRunConfig, messages: Vec<ProviderMessage>, expects_write: bool, steering: Receiver<String>,
228    ) -> Self {
229        let provider = ProviderKind::for_model(&config.model);
230        RunHandle {
231            provider,
232            config,
233            prompt: String::new(),
234            messages,
235            expects_write,
236            steering: Some(steering),
237            cancel: CancelToken::new(),
238            permission_hook: None,
239            execution_hook: None,
240        }
241    }
242
243    /// Attach a permission hook for sensitive tool calls.
244    pub fn with_permission_hook(mut self, hook: ToolPermissionHook) -> Self {
245        self.permission_hook = Some(hook);
246        self
247    }
248
249    /// Attach an execution hook for front-end-specific tool handling.
250    pub fn with_execution_hook(mut self, hook: ToolExecutionHook) -> Self {
251        self.execution_hook = Some(hook);
252        self
253    }
254
255    /// The unified agent loop. Dispatches to the fake or Umans provider, handles
256    /// tool-use requests, enforces the per-turn cap, and checks cancellation
257    /// cooperatively.
258    fn run_agent(&self, tx: &Sender<AgentEvent>, cancel: &CancelToken) {
259        if send(tx, AgentEvent::Started, cancel).is_none() {
260            return;
261        }
262        step();
263
264        match self.provider {
265            ProviderKind::Fake => self.run_fake(tx, cancel),
266            ProviderKind::Umans => self.run_provider::<umans::UmansClient>(tx, cancel),
267            ProviderKind::OpenCodeGo => self.run_provider::<opencode::OpenCodeGoClient>(tx, cancel),
268            ProviderKind::OpenCodeZen => self.run_provider::<opencode::zen::OpenCodeZenClient>(tx, cancel),
269            ProviderKind::ChatGptCodex => self.run_provider::<codex::ChatGptCodexClient>(tx, cancel),
270        }
271    }
272
273    /// A streaming provider sends the prompt to its API, streams the response,
274    /// dispatches any tool-use requests, feeds the tool results back as
275    /// provider-native tool result messages, and repeats until the model stops
276    /// requesting tools or the per-turn cap is hit.
277    #[expect(
278        clippy::cognitive_complexity,
279        reason = "Provider turns intentionally centralize cancellation, tool permissions, and continuation state."
280    )]
281    fn run_provider<P>(&self, tx: &Sender<AgentEvent>, cancel: &CancelToken)
282    where
283        P: StreamingProvider,
284    {
285        let provider = match P::from_env_or_dotenv(&self.config.root) {
286            Ok(provider) => provider,
287            Err(e) => {
288                let message = P::request_error_message(&e);
289                tracing::error!(error = %message, "failed to load provider client");
290                let _ = send(tx, AgentEvent::Failed(message), cancel);
291                return;
292            }
293        };
294
295        tracing::info!(
296            provider = provider.name(),
297            model = %self.config.model,
298            cwd = %self.config.root.display(),
299            messages = self.messages.len(),
300            max_tool_iterations = self.config.max_tool_iterations,
301            "starting provider agent run"
302        );
303        if send(tx, AgentEvent::Status(provider.load_status()), cancel).is_none() {
304            return;
305        }
306
307        let model_metadata = match load_provider_metadata(&provider, &self.config.model, tx, cancel) {
308            MetadataLoaded::Abort => return,
309            MetadataLoaded::Loaded(metadata) => Some(metadata),
310            MetadataLoaded::Unavailable => None,
311        };
312
313        let tool_defs = tools::runtime_tool_definitions(self.config.mcp_manager.as_deref());
314        let tool_schemas = tools::tool_catalog_schemas(&tool_defs);
315        let mut messages = if self.messages.is_empty() {
316            vec![ProviderMessage::user(&self.prompt)]
317        } else {
318            self.messages.clone()
319        };
320        let mut tool_budget =
321            thndrs_agent::ToolIterationBudget::new(self.config.max_tool_iterations, tools::MAX_TOOL_CONTINUATIONS);
322        let mut wrote_file = false;
323        let mut continuation = ProviderContinuation::default();
324
325        loop {
326            if cancel.is_cancelled() {
327                tracing::warn!(
328                    provider = provider.name(),
329                    "provider run cancelled before provider request"
330                );
331                let _ = send(tx, AgentEvent::Cancelled, cancel);
332                return;
333            }
334
335            match tool_budget.before_provider_request() {
336                thndrs_agent::ToolBudgetDecision::Continue => {}
337                thndrs_agent::ToolBudgetDecision::ContinueAfterBudgetMessage => {
338                    let text = format!(
339                        "[tool-budget]\nTool batch segment limit reached after {} total batches. Continue from the current state, avoid repeating completed work, and stop requesting tools once you can answer.",
340                        tool_budget.total_batches()
341                    );
342                    tracing::warn!(
343                        total_batches = tool_budget.total_batches(),
344                        continuations_used = tool_budget.continuations_used(),
345                        "continuing after tool-budget segment cap"
346                    );
347                    messages.push(ProviderMessage::user(&text));
348                    if send(
349                        tx,
350                        AgentEvent::Status(format!(
351                            "tool budget: auto-continue {}/{} after {} batches",
352                            tool_budget.continuations_used(),
353                            tools::MAX_TOOL_CONTINUATIONS,
354                            tool_budget.total_batches()
355                        )),
356                        cancel,
357                    )
358                    .is_none()
359                    {
360                        return;
361                    }
362                }
363                thndrs_agent::ToolBudgetDecision::Exhausted {
364                    segment_iterations,
365                    total_batches,
366                    continuations_used,
367                } => {
368                    tracing::error!(
369                        segment_iterations,
370                        total_batches,
371                        continuations_used,
372                        "tool-call budget exhausted"
373                    );
374                    let _ = send(
375                        tx,
376                        AgentEvent::Failed(format!(
377                            "tool-call budget exhausted ({total_batches} tool batches, {continuations_used} auto-continuations, {segment_iterations} in current segment)"
378                        )),
379                        cancel,
380                    );
381                    return;
382                }
383            }
384
385            if send(
386                tx,
387                AgentEvent::Status(provider.request_status(&self.config.model)),
388                cancel,
389            )
390            .is_none()
391            {
392                return;
393            }
394
395            let max_tokens = provider.token_budget(&self.config.model, model_metadata.as_ref());
396            let request = ProviderTurnRequest {
397                provider: &provider,
398                model: &self.config.model,
399                messages: &messages,
400                max_tokens,
401                reasoning_effort: self.config.reasoning_effort,
402                reasoning_summary: self.config.reasoning_summary,
403                tool_schemas: &tool_schemas,
404                continuation: &continuation,
405                turn_id: self.config.accounting_turn_id.as_deref().unwrap_or("turn_unknown"),
406                context: &self.config.accounting_context,
407            };
408            let Some(mut turn) = request_provider_turn_with_retries(&request, tool_budget.total_batches(), tx, cancel)
409            else {
410                return;
411            };
412            if matches!(self.provider, ProviderKind::ChatGptCodex | ProviderKind::OpenCodeZen)
413                && matches!(
414                    provider.stream_format(&self.config.model),
415                    Ok(StreamFormat::ChatGptCodexResponses)
416                )
417            {
418                codex::record_response_items(&mut continuation, &messages, std::mem::take(&mut turn.response_items));
419            }
420            tracing::info!(
421                text_chars = turn.assistant_text.chars().count(),
422                tool_calls = turn.tool_requests.len(),
423                "provider turn completed"
424            );
425
426            if turn.tool_requests.is_empty() {
427                if turn.assistant_text.is_empty() && turn.stop_reason.as_deref() == Some("max_tokens") {
428                    let _ = send(
429                        tx,
430                        AgentEvent::Failed(format!(
431                            "provider stopped at max_tokens ({max_tokens}) before producing assistant text"
432                        )),
433                        cancel,
434                    );
435                    return;
436                }
437                if self.expects_write && !wrote_file {
438                    let _ = send(
439                        tx,
440                        AgentEvent::Failed(String::from(
441                            "model stopped without writing a file for an edit-like request",
442                        )),
443                        cancel,
444                    );
445                    return;
446                }
447                if append_steering_messages(&mut messages, self) {
448                    tracing::info!(
449                        provider = provider.name(),
450                        "continuing provider run with queued steering messages"
451                    );
452                    continue;
453                }
454                let _ = send(tx, AgentEvent::Finished, cancel);
455                return;
456            }
457
458            tool_budget.record_tool_batch();
459
460            let mut assistant_blocks = Vec::new();
461            if !turn.assistant_text.is_empty() {
462                assistant_blocks.push(ProviderContentBlock::Text { text: turn.assistant_text });
463            }
464
465            let mut tool_results: Vec<ProviderMessage> = Vec::new();
466            let mut response_tool_outputs = Vec::new();
467            for req in &turn.tool_requests {
468                if cancel.is_cancelled() {
469                    tracing::warn!(provider = provider.name(), tool = %req.name, tool_id = %req.tool_use_id, "provider run cancelled before tool dispatch");
470                    let _ = send(tx, AgentEvent::Cancelled, cancel);
471                    return;
472                }
473
474                let tool_id = req.tool_use_id.clone();
475                tracing::info!(tool = %req.name, tool_id = %tool_id, "dispatching tool request");
476                if send(
477                    tx,
478                    AgentEvent::ToolStarted {
479                        id: tool_id.clone(),
480                        name: req.name.clone(),
481                        arguments: req.arguments.clone(),
482                    },
483                    cancel,
484                )
485                .is_none()
486                {
487                    return;
488                }
489
490                let (output, write_result, shell_result) = match approve_tool_request(req, self, cancel) {
491                    ToolPermissionDecision::Allow => dispatch_tool_request(req, self, cancel),
492                    ToolPermissionDecision::Reject => (
493                        ToolOutput::failed(&req.name, String::from("tool call rejected by ACP client")),
494                        None,
495                        None,
496                    ),
497                    ToolPermissionDecision::Cancelled => {
498                        let _ = send(tx, AgentEvent::Cancelled, cancel);
499                        return;
500                    }
501                };
502                let status = output.status;
503                let display_output = output.display_lines();
504                let model_output = output.model_lines();
505                if write_result.is_some() && status == ToolStatus::Ok {
506                    wrote_file = true;
507                }
508                tracing::info!(tool = %req.name, tool_id = %tool_id, status = ?status, "tool request finished");
509                if send(
510                    tx,
511                    AgentEvent::ToolFinished {
512                        id: tool_id.clone(),
513                        output: display_output.clone(),
514                        status,
515                        write_result,
516                        shell_result: shell_result.map(Box::new),
517                    },
518                    cancel,
519                )
520                .is_none()
521                {
522                    return;
523                }
524
525                let input: serde_json::Value = serde_json::from_str(&req.arguments).unwrap_or(serde_json::Value::Null);
526                assistant_blocks.push(ProviderContentBlock::ToolUse {
527                    id: tool_id.clone(),
528                    name: req.name.clone(),
529                    input,
530                });
531
532                let result_content =
533                    if model_output.is_empty() { "(no output)".to_string() } else { model_output.join("\n") };
534                let is_error = status == ToolStatus::Failed;
535                tool_results.push(ProviderMessage::tool_result(&tool_id, &result_content, is_error));
536                response_tool_outputs.push((tool_id, result_content));
537            }
538
539            messages.push(ProviderMessage::assistant_blocks(assistant_blocks));
540            messages.extend(tool_results);
541            if matches!(self.provider, ProviderKind::ChatGptCodex | ProviderKind::OpenCodeZen)
542                && matches!(
543                    provider.stream_format(&self.config.model),
544                    Ok(StreamFormat::ChatGptCodexResponses)
545                )
546            {
547                for (call_id, output) in response_tool_outputs {
548                    codex::record_tool_output(&mut continuation, &call_id, &output, messages.len());
549                }
550            }
551            append_steering_messages(&mut messages, self);
552        }
553    }
554
555    /// Deterministic fake provider: emits reasoning, a tool-use request, assistant
556    /// text, and finishes. Demonstrates the tool dispatch path end-to-end.
557    fn run_fake(&self, tx: &Sender<AgentEvent>, cancel: &CancelToken) {
558        use AgentEvent::*;
559        match send(tx, ReasoningDelta(String::from("Let me think about this... ")), cancel) {
560            None => return,
561            Some(_) => step(),
562        }
563
564        match send(
565            tx,
566            ReasoningDelta(String::from("The repo is a Rust terminal coding harness.")),
567            cancel,
568        ) {
569            None => return,
570            Some(_) => step(),
571        }
572
573        if self.config.model == "fake-agent-slow" {
574            for _ in 0..200 {
575                if cancel.is_cancelled() {
576                    let _ = send(tx, Cancelled, cancel);
577                    return;
578                }
579                step();
580            }
581        }
582
583        if self.config.search_mode != WebSearchMode::None {
584            let search_req = ToolUseRequest::new(
585                String::from("web_search"),
586                serde_json::json!({ "query": "rust terminal coding harness" }).to_string(),
587                String::from("search-0"),
588            );
589            let search_id = String::from("search-0");
590            match send(
591                tx,
592                ToolStarted {
593                    id: search_id.clone(),
594                    name: search_req.name.clone(),
595                    arguments: search_req.arguments.clone(),
596                },
597                cancel,
598            ) {
599                None => return,
600                Some(_) => step(),
601            }
602
603            let search_config = self.config.search_config();
604            let (search_output, _, _) =
605                tools::dispatch_full_with_search(&search_req, &self.config.root, &search_config);
606            let search_status = search_output.status;
607            let search_display_output = search_output.display_lines();
608            match send(
609                tx,
610                ToolFinished {
611                    id: search_id,
612                    output: search_display_output,
613                    status: search_status,
614                    write_result: None,
615                    shell_result: None,
616                },
617                cancel,
618            ) {
619                None => return,
620                Some(_) => {
621                    step();
622                }
623            }
624        }
625
626        let tool_req = ToolUseRequest::new(
627            String::from("read_file_range"),
628            serde_json::json!({ "path": "Cargo.toml", "start_line": 1, "end_line": 5 }).to_string(),
629            String::from("0"),
630        );
631
632        let tool_id = String::from("0");
633        match send(
634            tx,
635            ToolStarted { id: tool_id.clone(), name: tool_req.name.clone(), arguments: tool_req.arguments.clone() },
636            cancel,
637        ) {
638            None => return,
639            Some(_) => step(),
640        }
641
642        let (output, _, _) = tools::dispatch_full(&tool_req, &self.config.root);
643        let status = output.status;
644        let display_output = output.display_lines();
645        match send(
646            tx,
647            ToolFinished { id: tool_id, output: display_output, status, write_result: None, shell_result: None },
648            cancel,
649        ) {
650            None => return,
651            Some(_) => step(),
652        }
653
654        if self.config.model == "fake-agent-shell" {
655            let shell_req = ToolUseRequest::new(
656                String::from("run_shell"),
657                serde_json::json!({ "program": "printf", "args": ["acp-permission-smoke\n"] }).to_string(),
658                String::from("shell-0"),
659            );
660            let shell_id = String::from("shell-0");
661            match send(
662                tx,
663                ToolStarted {
664                    id: shell_id.clone(),
665                    name: shell_req.name.clone(),
666                    arguments: shell_req.arguments.clone(),
667                },
668                cancel,
669            ) {
670                None => return,
671                Some(_) => step(),
672            }
673
674            let (shell_output, write_result, shell_result) = match approve_tool_request(&shell_req, self, cancel) {
675                ToolPermissionDecision::Allow => dispatch_tool_request(&shell_req, self, cancel),
676                ToolPermissionDecision::Reject => (
677                    ToolOutput::failed(&shell_req.name, String::from("tool call rejected by ACP client")),
678                    None,
679                    None,
680                ),
681                ToolPermissionDecision::Cancelled => {
682                    let _ = send(tx, AgentEvent::Cancelled, cancel);
683                    return;
684                }
685            };
686            let shell_status = shell_output.status;
687            let shell_display_output = shell_output.display_lines();
688            match send(
689                tx,
690                ToolFinished {
691                    id: shell_id,
692                    output: shell_display_output,
693                    status: shell_status,
694                    write_result,
695                    shell_result: shell_result.map(Box::new),
696                },
697                cancel,
698            ) {
699                None => return,
700                Some(_) => step(),
701            }
702        }
703        match send(tx, AssistantDelta(String::from("This is a ")), cancel) {
704            None => return,
705            Some(_) => step(),
706        }
707
708        match send(tx, AssistantDelta(String::from("fake streaming response.")), cancel) {
709            None => return,
710            Some(_) => step(),
711        }
712        let _ = tx.send(Finished);
713    }
714}
715
716#[derive(Default)]
717struct AnthropicStreamState {
718    tool_blocks: HashMap<usize, ToolUseBuilder>,
719    tool_requests: Vec<ToolUseRequest>,
720    assistant_text: String,
721    stop_reason: Option<String>,
722    provider_content_blocks: Vec<String>,
723    usage: ProviderUsageComponents,
724}
725
726#[cfg(test)]
727impl AnthropicStreamState {
728    fn collect(
729        &mut self, event_type: &str, data: &str, tx: &Sender<AgentEvent>, cancel: &CancelToken,
730    ) -> Result<(), String> {
731        collect_anthropic_event(event_type, data, self, tx, cancel)
732    }
733}
734
735struct ProviderTurnRequest<'a, P>
736where
737    P: StreamingProvider,
738{
739    provider: &'a P,
740    model: &'a str,
741    messages: &'a [ProviderMessage],
742    max_tokens: u32,
743    reasoning_effort: ReasoningEffort,
744    reasoning_summary: ReasoningSummary,
745    tool_schemas: &'a serde_json::Value,
746    continuation: &'a ProviderContinuation,
747    turn_id: &'a str,
748    context: &'a [thndrs_agent::ContextItemSnapshot],
749}
750
751/// Best-effort classifier for prompts that should not finish without a
752/// workspace write. This is intentionally narrow: it requires both a file-ish
753/// reference and an edit/action verb.
754pub fn prompt_expects_workspace_write(prompt: &str) -> bool {
755    let lower = prompt.to_ascii_lowercase();
756    let fileish = lower.contains(".md")
757        || lower.contains(".rs")
758        || lower.contains(".toml")
759        || lower.contains(".json")
760        || lower.contains(".yaml")
761        || lower.contains(".yml")
762        || lower.contains("file")
763        || lower.contains("todo");
764    let action = [
765        "add",
766        "change",
767        "document",
768        "edit",
769        "fix",
770        "modify",
771        "remove",
772        "replace",
773        "rewrite",
774        "summarize",
775        "update",
776        "write",
777    ]
778    .iter()
779    .any(|word| {
780        lower
781            .split(|c: char| !c.is_ascii_alphanumeric())
782            .any(|part| part == *word)
783    });
784
785    fileish && action
786}
787
788fn is_retryable_stream_error(message: &str) -> bool {
789    let lower = message.to_ascii_lowercase();
790    if lower.contains("cancel")
791        || lower.contains("aborted")
792        || lower.contains("max_tokens")
793        || lower.contains("without writing")
794        || lower.contains("provider returned only provider-side content blocks")
795    {
796        return false;
797    }
798
799    [
800        "429",
801        "500",
802        "502",
803        "503",
804        "504",
805        "overloaded",
806        "rate limit",
807        "server error",
808        "service unavailable",
809        "stream read error",
810        "stream ended without",
811        "connection",
812        "timed out",
813        "timeout",
814        "provider error",
815    ]
816    .iter()
817    .any(|needle| lower.contains(needle))
818}
819
820fn sleep_with_cancel(delay: Duration, tx: &Sender<AgentEvent>, cancel: &CancelToken) -> bool {
821    let mut slept = Duration::ZERO;
822    let tick = Duration::from_millis(100);
823    while slept < delay {
824        if cancel.is_cancelled() {
825            let _ = tx.send(AgentEvent::Cancelled);
826            return false;
827        }
828        let remaining = delay.saturating_sub(slept);
829        let nap = remaining.min(tick);
830        thread::sleep(nap);
831        slept += nap;
832    }
833    true
834}
835
836fn dispatch_tool_request(
837    request: &ToolUseRequest, handle: &RunHandle, cancel: &CancelToken,
838) -> (ToolOutput, Option<WriteResult>, Option<ProcessResult>) {
839    if let Some(hook) = &handle.execution_hook
840        && let Some(output) = hook.execute(request, &handle.config, cancel)
841    {
842        return output;
843    }
844    let search_config = handle.config.search_config();
845    tools::dispatch_runtime_full_with_cancel_and_search_and_registry(
846        // The application-owned registry keeps background children alive after
847        // this tool call returns and lets the TUI cancel/reap them later.
848        request,
849        &handle.config.root,
850        handle.config.mcp_manager.as_deref(),
851        cancel,
852        &search_config,
853        handle.config.process_registry.as_ref(),
854    )
855}
856
857fn approve_tool_request(request: &ToolUseRequest, handle: &RunHandle, cancel: &CancelToken) -> ToolPermissionDecision {
858    if !requires_runtime_permission(&request.name) {
859        return ToolPermissionDecision::Allow;
860    }
861    match &handle.permission_hook {
862        Some(hook) => hook.decide(request, &handle.config, cancel),
863        None => ToolPermissionDecision::Allow,
864    }
865}
866
867fn requires_runtime_permission(tool_name: &str) -> bool {
868    matches!(tool_name, "create_file" | "replace_range" | "write_patch" | "run_shell")
869}
870
871fn load_provider_metadata<P>(
872    provider: &P, model: &str, tx: &Sender<AgentEvent>, cancel: &CancelToken,
873) -> MetadataLoaded<P::Metadata>
874where
875    P: StreamingProvider,
876{
877    match provider.load_metadata() {
878        Ok(models) => {
879            tracing::info!("loaded provider model metadata");
880            if let Some(event) = provider.metadata_loaded_event(&models)
881                && send(tx, event, cancel).is_none()
882            {
883                return MetadataLoaded::Abort;
884            }
885            if let Some(status) = provider.metadata_status(model, &models)
886                && send(tx, AgentEvent::Status(status), cancel).is_none()
887            {
888                return MetadataLoaded::Abort;
889            }
890            MetadataLoaded::Loaded(models)
891        }
892        Err(e) => {
893            let message = P::request_error_message(&e);
894            if e.is_credential_rejected() {
895                tracing::warn!(error = %message, "provider rejected credentials while loading model metadata");
896                let _ = send(tx, AgentEvent::Failed(message), cancel);
897                return MetadataLoaded::Abort;
898            }
899            tracing::warn!(error = %message, "failed to load provider model metadata; using fallback token budget");
900            match send(
901                tx,
902                AgentEvent::Status(String::from(
903                    "provider: model metadata unavailable; using fallback token budget",
904                )),
905                cancel,
906            ) {
907                None => MetadataLoaded::Abort,
908                Some(_) => MetadataLoaded::Unavailable,
909            }
910        }
911    }
912}
913
914fn request_provider_turn_with_retries<P>(
915    request: &ProviderTurnRequest<'_, P>, iteration: usize, tx: &Sender<AgentEvent>, cancel: &CancelToken,
916) -> Option<ProviderTurn>
917where
918    P: StreamingProvider,
919{
920    let mut retry_attempt = 0;
921    loop {
922        tracing::info!(
923            iteration,
924            messages = request.messages.len(),
925            retry_attempt,
926            "requesting provider turn"
927        );
928        let attempt_result = provider_request_attempt(request, iteration, retry_attempt + 1, tx, cancel);
929
930        match attempt_result {
931            Ok(turn) => return Some(turn),
932            Err(error) if error.is_retryable::<P>() && retry_attempt < PROVIDER_RETRY_POLICY.max_retries => {
933                retry_attempt += 1;
934                if !send_retry_event(request.provider, error.message::<P>(), retry_attempt, tx, cancel) {
935                    return None;
936                }
937            }
938            Err(error) => {
939                let message = error.message::<P>();
940                tracing::error!(provider = request.provider.name(), error = %message, "provider attempt failed");
941                let _ = send(tx, AgentEvent::Failed(message), cancel);
942                return None;
943            }
944        }
945    }
946}
947
948fn provider_request_attempt<P>(
949    request: &ProviderTurnRequest<'_, P>, iteration: usize, attempt: u32, tx: &Sender<AgentEvent>, cancel: &CancelToken,
950) -> Result<ProviderTurn, ProviderAttemptError>
951where
952    P: StreamingProvider,
953{
954    let provider_request = StreamingRequest {
955        max_tokens: request.max_tokens,
956        reasoning_effort: request.reasoning_effort,
957        reasoning_summary: request.reasoning_summary,
958        tools: request.tool_schemas,
959        continuation: request.continuation,
960    };
961    let serialized_body = request
962        .provider
963        .serialized_request_body(request.model, request.messages, &provider_request)
964        .map_err(ProviderAttemptError::Request)?;
965    match request
966        .provider
967        .send_streaming_request(request.model, request.messages, &provider_request)
968    {
969        Ok(response) => {
970            match send(
971                tx,
972                AgentEvent::Status(format!("provider: connected HTTP {}", response.status().as_u16())),
973                cancel,
974            ) {
975                None => Err(ProviderAttemptError::Stream("cancelled".to_string())),
976                Some(_) => stream_provider_response(
977                    request.provider,
978                    request.model,
979                    response,
980                    tx,
981                    cancel,
982                    request.max_tokens,
983                )
984                .map_err(ProviderAttemptError::Stream)
985                .and_then(|mut turn| {
986                    let stream_format = request
987                        .provider
988                        .stream_format(request.model)
989                        .map_err(ProviderAttemptError::Request)?;
990                    let provider_usage = turn.usage.take().and_then(|components| {
991                        if components.input_tokens.is_none()
992                            && components.output_tokens.is_none()
993                            && components.cache_read_input_tokens.is_none()
994                            && components.cache_creation_input_tokens.is_none()
995                            && components.reasoning_tokens.is_none()
996                        {
997                            None
998                        } else {
999                            Some(
1000                                components
1001                                    .normalize(request.provider.name(), usage_rule_for_stream_format(stream_format)),
1002                            )
1003                        }
1004                    });
1005                    let mut accounting = ProviderRequestAccounting::from_serialized_request(
1006                        request.turn_id,
1007                        format!("{}:request:{iteration}", request.turn_id),
1008                        attempt,
1009                        request.provider.name(),
1010                        request.model,
1011                        &serialized_body,
1012                        request.context.to_vec(),
1013                    );
1014                    accounting = accounting.with_model_projection(
1015                        request
1016                            .messages
1017                            .iter()
1018                            .map(|message| ModelProjectionMessage {
1019                                role: message.role.clone(),
1020                                content: match &message.content {
1021                                    crate::providers::ProviderMessageContent::Text(content) => content.clone(),
1022                                    crate::providers::ProviderMessageContent::Blocks(blocks) => {
1023                                        serde_json::to_string(blocks)
1024                                            .unwrap_or_else(|_| String::from("[unserializable blocks]"))
1025                                    }
1026                                },
1027                            })
1028                            .collect(),
1029                    );
1030                    accounting.provider_usage = provider_usage;
1031                    if send(tx, AgentEvent::RequestAccounting(Box::new(accounting)), cancel).is_none() {
1032                        return Err(ProviderAttemptError::Stream("cancelled".to_string()));
1033                    }
1034                    Ok(turn)
1035                }),
1036            }
1037        }
1038        Err(e) => Err(ProviderAttemptError::Request(e)),
1039    }
1040}
1041
1042fn usage_rule_for_stream_format(format: StreamFormat) -> ProviderUsageRule {
1043    match format {
1044        StreamFormat::AnthropicMessages => ProviderUsageRule::AnthropicMessages,
1045        StreamFormat::OpenAiChat => ProviderUsageRule::OpenAiChat,
1046        StreamFormat::ChatGptCodexResponses => ProviderUsageRule::OpenAiResponses,
1047    }
1048}
1049
1050fn send_retry_event<P>(
1051    provider: &P, message: String, retry_attempt: u32, tx: &Sender<AgentEvent>, cancel: &CancelToken,
1052) -> bool
1053where
1054    P: StreamingProvider,
1055{
1056    let delay = PROVIDER_RETRY_POLICY.delay_for_attempt(retry_attempt);
1057    tracing::warn!(
1058        provider = provider.name(),
1059        attempt = retry_attempt,
1060        max_retries = PROVIDER_RETRY_POLICY.max_retries,
1061        delay_ms = delay.as_millis(),
1062        error = %message,
1063        "retrying provider attempt"
1064    );
1065    match send(
1066        tx,
1067        AgentEvent::Retrying {
1068            attempt: retry_attempt,
1069            max_attempts: PROVIDER_RETRY_POLICY.max_retries,
1070            delay_ms: delay.as_millis() as u64,
1071            error: message,
1072        },
1073        cancel,
1074    ) {
1075        None => false,
1076        Some(_) => sleep_with_cancel(delay, tx, cancel),
1077    }
1078}
1079
1080fn append_steering_messages(messages: &mut Vec<ProviderMessage>, handle: &RunHandle) -> bool {
1081    match handle.steering.as_ref() {
1082        Some(rx) => {
1083            let mut appended = false;
1084            while let Ok(text) = rx.try_recv() {
1085                let trimmed = text.trim();
1086                if trimmed.is_empty() {
1087                    continue;
1088                }
1089                messages.push(ProviderMessage::user(&format!("[steering]\n{trimmed}")));
1090                appended = true;
1091            }
1092            if appended {
1093                tracing::debug!(messages = messages.len(), "appended steering messages");
1094            }
1095            appended
1096        }
1097        None => false,
1098    }
1099}
1100
1101/// Read an Anthropic-compatible SSE streaming response, converting events to [`AgentEvent`]
1102/// instances and collecting any tool-use requests plus the assistant text.
1103///
1104/// Returns a [`TurnOutput`] with the tool-use requests and the accumulated
1105/// assistant text, or an error message if the stream failed.
1106fn stream_anthropic_response(
1107    resp: Response<ureq::Body>, tx: &Sender<AgentEvent>, cancel: &CancelToken, max_tokens: u32,
1108) -> Result<ProviderTurn, String> {
1109    let reader = BufReader::new(resp.into_body().into_reader());
1110    let mut buffer = String::new();
1111    let mut state = AnthropicStreamState::default();
1112    let mut event_count = 0usize;
1113    let mut saw_response = false;
1114    tracing::info!("reading Anthropic-compatible SSE stream");
1115
1116    for line_result in reader.lines() {
1117        if cancel.is_cancelled() {
1118            tracing::warn!("cancelled while reading Anthropic-compatible SSE stream");
1119            return Err("cancelled".to_string());
1120        }
1121
1122        match line_result {
1123            Ok(line) => {
1124                buffer.push_str(&line);
1125                buffer.push('\n');
1126
1127                if line.is_empty() {
1128                    let events = anthropic::parse_sse_chunk(&buffer);
1129                    buffer.clear();
1130
1131                    for (event_type, data) in events {
1132                        event_count += 1;
1133                        log_sse_event(event_count, &event_type, &data);
1134                        if !saw_response {
1135                            saw_response = true;
1136                            if send(tx, AgentEvent::Status(String::from("provider: receiving SSE")), cancel).is_none() {
1137                                return Err("cancelled".to_string());
1138                            }
1139                        }
1140                        collect_anthropic_event(&event_type, &data, &mut state, tx, cancel)?;
1141                    }
1142                }
1143            }
1144            Err(e) => {
1145                tracing::error!(error = %e, "failed reading Anthropic-compatible SSE stream");
1146                return Err(format!("stream read error: {e}"));
1147            }
1148        }
1149    }
1150
1151    if !buffer.is_empty() {
1152        let events = anthropic::parse_sse_chunk(&buffer);
1153        for (event_type, data) in events {
1154            event_count += 1;
1155            log_sse_event(event_count, &event_type, &data);
1156            if !saw_response {
1157                saw_response = true;
1158                if send(tx, AgentEvent::Status(String::from("provider: receiving SSE")), cancel).is_none() {
1159                    return Err("cancelled".to_string());
1160                }
1161            }
1162            collect_anthropic_event(&event_type, &data, &mut state, tx, cancel)?;
1163        }
1164    }
1165
1166    for (_, block) in state.tool_blocks {
1167        if let Some(req) = block.finish() {
1168            state.tool_requests.push(req);
1169        }
1170    }
1171
1172    if state.assistant_text.is_empty() && state.tool_requests.is_empty() {
1173        tracing::error!(
1174            event_count,
1175            "Anthropic-compatible stream ended without assistant text or tool calls"
1176        );
1177        if state.stop_reason.as_deref() == Some("max_tokens") {
1178            return Err(format!(
1179                "provider stopped at max_tokens ({max_tokens}) before producing assistant text"
1180            ));
1181        }
1182        if !state.provider_content_blocks.is_empty() {
1183            let blocks = state.provider_content_blocks.join(", ");
1184            return Err(format!(
1185                "provider returned only provider-side content blocks ({blocks}) and no assistant text or tool calls; retry with --websearch none"
1186            ));
1187        }
1188        return Err(format!(
1189            "provider stream ended without assistant text or tool calls ({event_count} SSE events)"
1190        ));
1191    }
1192
1193    tracing::info!(
1194        event_count,
1195        text_chars = state.assistant_text.chars().count(),
1196        tool_calls = state.tool_requests.len(),
1197        "finished reading Anthropic-compatible SSE stream"
1198    );
1199    let _ = send(
1200        tx,
1201        AgentEvent::Status(format!(
1202            "provider: stream ended ({event_count} SSE events, {} text chars, {} tool calls)",
1203            state.assistant_text.chars().count(),
1204            state.tool_requests.len()
1205        )),
1206        cancel,
1207    );
1208
1209    Ok(ProviderTurn {
1210        tool_requests: state.tool_requests,
1211        assistant_text: state.assistant_text,
1212        stop_reason: state.stop_reason,
1213        response_items: Vec::new(),
1214        usage: Some(state.usage),
1215    })
1216}
1217
1218fn stream_provider_response<P: StreamingProvider>(
1219    provider: &P, model: &str, resp: Response<ureq::Body>, tx: &Sender<AgentEvent>, cancel: &CancelToken,
1220    max_tokens: u32,
1221) -> Result<ProviderTurn, String> {
1222    match provider
1223        .stream_format(model)
1224        .map_err(|e| P::request_error_message(&e))?
1225    {
1226        StreamFormat::OpenAiChat => stream_openai_chat_response(resp, tx, cancel, max_tokens),
1227        StreamFormat::AnthropicMessages => stream_anthropic_response(resp, tx, cancel, max_tokens),
1228        StreamFormat::ChatGptCodexResponses => stream_chatgpt_codex_response(resp, tx, cancel, max_tokens),
1229    }
1230}
1231
1232fn stream_openai_chat_response(
1233    resp: Response<ureq::Body>, tx: &Sender<AgentEvent>, cancel: &CancelToken, max_tokens: u32,
1234) -> Result<ProviderTurn, String> {
1235    let reader = BufReader::new(resp.into_body().into_reader());
1236    let mut assistant_text = String::new();
1237    let mut tool_blocks: HashMap<usize, ChatToolCallBuilder> = HashMap::new();
1238    let mut tool_requests = Vec::new();
1239    let mut event_count = 0usize;
1240    let mut saw_response = false;
1241    let mut stop_reason = None;
1242    let mut usage = ProviderUsageComponents::default();
1243    tracing::info!("reading OpenAI-compatible chat-completions SSE stream");
1244
1245    for line_result in reader.lines() {
1246        if cancel.is_cancelled() {
1247            tracing::warn!("cancelled while reading OpenAI-compatible SSE stream");
1248            return Err("cancelled".to_string());
1249        }
1250
1251        let line = line_result.map_err(|e| {
1252            tracing::error!(error = %e, "failed reading OpenAI-compatible SSE stream");
1253            format!("stream read error: {e}")
1254        })?;
1255        if !line.starts_with("data:") {
1256            continue;
1257        }
1258
1259        if !saw_response {
1260            saw_response = true;
1261            if send(tx, AgentEvent::Status(String::from("provider: receiving SSE")), cancel).is_none() {
1262                return Err("cancelled".to_string());
1263            }
1264        }
1265
1266        for data in openai::parse_chat_sse_chunk(&(line + "\n")) {
1267            event_count += 1;
1268            let events = openai::parse_chat_sse_event(&data);
1269            for event in events {
1270                match &event {
1271                    openai::ChatSseEvent::Usage { input_tokens, output_tokens } => {
1272                        usage.merge_snapshot(&ProviderUsageComponents::new(*input_tokens, *output_tokens));
1273                    }
1274                    openai::ChatSseEvent::UsageComponents(components) => usage.merge_snapshot(components),
1275                    _ => {}
1276                }
1277                collect_openai_chat_event(
1278                    event,
1279                    &mut tool_blocks,
1280                    &mut tool_requests,
1281                    &mut assistant_text,
1282                    &mut stop_reason,
1283                    tx,
1284                    cancel,
1285                )?;
1286            }
1287        }
1288    }
1289
1290    for (_, block) in tool_blocks {
1291        if let Some(req) = block.finish() {
1292            tool_requests.push(req);
1293        }
1294    }
1295
1296    if assistant_text.is_empty() && tool_requests.is_empty() {
1297        tracing::error!(
1298            event_count,
1299            "OpenAI-compatible stream ended without assistant text or tool calls"
1300        );
1301        return match stop_reason.as_deref() {
1302            Some("length") => Err(format!(
1303                "provider stopped at max_tokens ({max_tokens}) before producing assistant text"
1304            )),
1305            _ => Err(format!(
1306                "provider stream ended without assistant text or tool calls ({event_count} SSE events)"
1307            )),
1308        };
1309    }
1310
1311    tracing::info!(
1312        event_count,
1313        text_chars = assistant_text.chars().count(),
1314        tool_calls = tool_requests.len(),
1315        "finished reading OpenAI-compatible SSE stream"
1316    );
1317    let _ = send(
1318        tx,
1319        AgentEvent::Status(format!(
1320            "provider: stream ended ({event_count} SSE events, {} text chars, {} tool calls)",
1321            assistant_text.chars().count(),
1322            tool_requests.len()
1323        )),
1324        cancel,
1325    );
1326
1327    Ok(ProviderTurn { tool_requests, assistant_text, stop_reason, response_items: Vec::new(), usage: Some(usage) })
1328}
1329
1330fn collect_openai_chat_event(
1331    event: openai::ChatSseEvent, tool_blocks: &mut HashMap<usize, ChatToolCallBuilder>,
1332    tool_requests: &mut Vec<ToolUseRequest>, assistant_text: &mut String, stop_reason: &mut Option<String>,
1333    tx: &Sender<AgentEvent>, cancel: &CancelToken,
1334) -> Result<(), String> {
1335    match event {
1336        openai::ChatSseEvent::TextDelta(text) => {
1337            assistant_text.push_str(&text);
1338            if send(tx, AgentEvent::AssistantDelta(text), cancel).is_none() {
1339                return Err("cancelled".to_string());
1340            }
1341        }
1342        openai::ChatSseEvent::ReasoningDelta(text) => {
1343            if send(tx, AgentEvent::ReasoningDelta(text), cancel).is_none() {
1344                return Err("cancelled".to_string());
1345            }
1346        }
1347        openai::ChatSseEvent::ToolCallStart { index, id, name } => {
1348            tool_blocks.insert(index, ChatToolCallBuilder { id, name, arguments_json: String::new() });
1349        }
1350        openai::ChatSseEvent::ToolCallArgumentsDelta { index, arguments } => {
1351            let block = tool_blocks.entry(index).or_insert_with(|| ChatToolCallBuilder {
1352                id: format!("call_{index}"),
1353                name: String::new(),
1354                arguments_json: String::new(),
1355            });
1356            block.arguments_json.push_str(&arguments);
1357        }
1358        openai::ChatSseEvent::FinishReason(reason) => {
1359            *stop_reason = Some(reason.clone());
1360            if reason == "tool_calls" {
1361                let finished = std::mem::take(tool_blocks);
1362                for (_, block) in finished {
1363                    if let Some(req) = block.finish() {
1364                        tool_requests.push(req);
1365                    }
1366                }
1367            }
1368        }
1369        openai::ChatSseEvent::ResponseStatus(status) => {
1370            if matches!(status.as_str(), "failed" | "cancelled" | "canceled") {
1371                return Err(format!("provider stream status: {status}"));
1372            }
1373            if send(tx, AgentEvent::Status(format!("provider: status {status}")), cancel).is_none() {
1374                return Err("cancelled".to_string());
1375            }
1376        }
1377        openai::ChatSseEvent::Error(message) => {
1378            tracing::error!(error = %message, "provider emitted SSE error");
1379            return Err(format!("provider error: {message}"));
1380        }
1381        openai::ChatSseEvent::Usage { .. } => {}
1382        openai::ChatSseEvent::UsageComponents(_) => {}
1383        openai::ChatSseEvent::Malformed(payload) => {
1384            return Err(format!("malformed provider stream payload: {payload}"));
1385        }
1386        openai::ChatSseEvent::Done | openai::ChatSseEvent::Other => {}
1387    }
1388
1389    Ok(())
1390}
1391
1392fn stream_chatgpt_codex_response(
1393    resp: Response<ureq::Body>, tx: &Sender<AgentEvent>, cancel: &CancelToken, max_tokens: u32,
1394) -> Result<ProviderTurn, String> {
1395    let reader = BufReader::new(resp.into_body().into_reader());
1396    let mut assistant_text = String::new();
1397    let mut tool_blocks: HashMap<String, ChatToolCallBuilder> = HashMap::new();
1398    let mut tool_requests = Vec::new();
1399    let mut event_count = 0usize;
1400    let mut saw_response = false;
1401    let mut stop_reason = None;
1402    let mut response_items = Vec::new();
1403    let mut usage = ProviderUsageComponents::default();
1404    tracing::info!("reading ChatGPT Codex Responses SSE stream");
1405
1406    for line_result in reader.lines() {
1407        if cancel.is_cancelled() {
1408            tracing::warn!("cancelled while reading ChatGPT Codex SSE stream");
1409            return Err("cancelled".to_string());
1410        }
1411
1412        let line = line_result.map_err(|e| {
1413            tracing::error!(error = %e, "failed reading ChatGPT Codex SSE stream");
1414            format!("stream read error: {e}")
1415        })?;
1416        if !line.starts_with("data:") {
1417            continue;
1418        }
1419
1420        if !saw_response {
1421            saw_response = true;
1422            if send(
1423                tx,
1424                AgentEvent::Status(String::from("provider: receiving ChatGPT Codex SSE")),
1425                cancel,
1426            )
1427            .is_none()
1428            {
1429                return Err("cancelled".to_string());
1430            }
1431        }
1432
1433        for data in codex::parse_responses_sse_chunk(&(line + "\n")) {
1434            event_count += 1;
1435            for event in codex::parse_responses_sse_event(&data) {
1436                match &event {
1437                    codex::ResponsesSseEvent::Usage { input_tokens, output_tokens } => {
1438                        usage.merge_snapshot(&ProviderUsageComponents::new(*input_tokens, *output_tokens));
1439                    }
1440                    codex::ResponsesSseEvent::UsageComponents(components) => usage.merge_snapshot(components),
1441                    _ => {}
1442                }
1443                if let codex::ResponsesSseEvent::OutputItem(item) = &event {
1444                    response_items.push(item.clone());
1445                }
1446                collect_chatgpt_codex_event(
1447                    event,
1448                    &mut tool_blocks,
1449                    &mut tool_requests,
1450                    &mut assistant_text,
1451                    &mut stop_reason,
1452                    tx,
1453                    cancel,
1454                )?;
1455            }
1456        }
1457    }
1458
1459    for (_, block) in tool_blocks {
1460        if let Some(req) = block.finish() {
1461            tool_requests.push(req);
1462        }
1463    }
1464
1465    if assistant_text.is_empty() && tool_requests.is_empty() {
1466        tracing::error!(
1467            event_count,
1468            "ChatGPT Codex stream ended without assistant text or tool calls"
1469        );
1470        return match stop_reason.as_deref() {
1471            Some("incomplete" | "length") => Err(format!(
1472                "provider stopped at max_tokens ({max_tokens}) before producing assistant text"
1473            )),
1474            _ => Err(format!(
1475                "provider stream ended without assistant text or tool calls ({event_count} SSE events)"
1476            )),
1477        };
1478    }
1479
1480    tracing::info!(
1481        event_count,
1482        text_chars = assistant_text.chars().count(),
1483        tool_calls = tool_requests.len(),
1484        "finished reading ChatGPT Codex SSE stream"
1485    );
1486    let _ = send(
1487        tx,
1488        AgentEvent::Status(format!(
1489            "provider: stream ended ({event_count} SSE events, {} text chars, {} tool calls)",
1490            assistant_text.chars().count(),
1491            tool_requests.len()
1492        )),
1493        cancel,
1494    );
1495
1496    Ok(ProviderTurn { tool_requests, assistant_text, stop_reason, response_items, usage: Some(usage) })
1497}
1498
1499fn collect_chatgpt_codex_event(
1500    event: codex::ResponsesSseEvent, tool_blocks: &mut HashMap<String, ChatToolCallBuilder>,
1501    tool_requests: &mut Vec<ToolUseRequest>, assistant_text: &mut String, stop_reason: &mut Option<String>,
1502    tx: &Sender<AgentEvent>, cancel: &CancelToken,
1503) -> Result<(), String> {
1504    match event {
1505        codex::ResponsesSseEvent::TextDelta(text) => {
1506            assistant_text.push_str(&text);
1507            if send(tx, AgentEvent::AssistantDelta(text), cancel).is_none() {
1508                return Err("cancelled".to_string());
1509            }
1510        }
1511        codex::ResponsesSseEvent::ReasoningDelta(text) => {
1512            if send(tx, AgentEvent::ReasoningDelta(text), cancel).is_none() {
1513                return Err("cancelled".to_string());
1514            }
1515        }
1516        codex::ResponsesSseEvent::ToolCallStart { id, call_id, name } => {
1517            tool_blocks.insert(
1518                id,
1519                ChatToolCallBuilder { id: call_id, name, arguments_json: String::new() },
1520            );
1521        }
1522        codex::ResponsesSseEvent::ToolCallArgumentsDelta { id, arguments } => {
1523            let block = tool_blocks.entry(id.clone()).or_insert_with(|| ChatToolCallBuilder {
1524                id,
1525                name: String::new(),
1526                arguments_json: String::new(),
1527            });
1528            block.arguments_json.push_str(&arguments);
1529        }
1530        codex::ResponsesSseEvent::ToolCallDone { id, call_id, name, arguments } => {
1531            let remove_id = id.clone();
1532            let block = tool_blocks.entry(id.clone()).or_insert_with(|| ChatToolCallBuilder {
1533                id: id.clone(),
1534                name: String::new(),
1535                arguments_json: String::new(),
1536            });
1537            if let Some(call_id) = call_id {
1538                block.id = call_id;
1539            }
1540            if !name.is_empty() {
1541                block.name = name;
1542            }
1543            if !arguments.is_empty() {
1544                block.arguments_json = arguments;
1545            }
1546            if let Some(block) = tool_blocks.remove(&remove_id)
1547                && let Some(req) = block.finish()
1548            {
1549                tool_requests.push(req);
1550            }
1551        }
1552        codex::ResponsesSseEvent::ResponseStatus(status) => {
1553            match status.as_str() {
1554                "completed" => *stop_reason = Some(status.clone()),
1555                "failed" | "incomplete" | "cancelled" | "canceled" => {
1556                    return Err(format!("provider stream status: {status}"));
1557                }
1558                _ => {}
1559            }
1560            if send(tx, AgentEvent::Status(format!("provider: status {status}")), cancel).is_none() {
1561                return Err("cancelled".to_string());
1562            }
1563        }
1564        codex::ResponsesSseEvent::Error(message) => {
1565            tracing::error!(error = %message, "ChatGPT Codex emitted SSE error");
1566            return Err(format!("provider error: {message}"));
1567        }
1568        codex::ResponsesSseEvent::Usage { .. } => {}
1569        codex::ResponsesSseEvent::UsageComponents(_) => {}
1570        codex::ResponsesSseEvent::OutputItem(_) => {}
1571        codex::ResponsesSseEvent::Malformed(payload) => {
1572            return Err(format!("malformed provider stream payload: {payload}"));
1573        }
1574        codex::ResponsesSseEvent::Done | codex::ResponsesSseEvent::Other => {}
1575    }
1576
1577    Ok(())
1578}
1579
1580fn log_sse_event(seq: usize, event_type: &str, data: &str) {
1581    let (content_type, delta_type, stop_reason) = summarize_sse_data(data);
1582    tracing::info!(
1583        seq,
1584        event_type,
1585        content_type = content_type.as_deref().unwrap_or(""),
1586        delta_type = delta_type.as_deref().unwrap_or(""),
1587        stop_reason = stop_reason.as_deref().unwrap_or(""),
1588        "received SSE event"
1589    );
1590}
1591
1592fn summarize_sse_data(data: &str) -> (Option<String>, Option<String>, Option<String>) {
1593    let v: serde_json::Value = serde_json::from_str(data).unwrap_or(serde_json::Value::Null);
1594    let content_type = v
1595        .get("content_block")
1596        .and_then(|cb| cb.get("type"))
1597        .and_then(|t| t.as_str())
1598        .map(|s| s.to_string());
1599    let delta_type = v
1600        .get("delta")
1601        .and_then(|d| d.get("type"))
1602        .and_then(|t| t.as_str())
1603        .map(|s| s.to_string());
1604    let stop_reason = v
1605        .get("delta")
1606        .and_then(|d| d.get("stop_reason"))
1607        .and_then(|s| s.as_str())
1608        .map(|s| s.to_string());
1609
1610    (content_type, delta_type, stop_reason)
1611}
1612
1613fn collect_anthropic_event(
1614    event_type: &str, data: &str, state: &mut AnthropicStreamState, tx: &Sender<AgentEvent>, cancel: &CancelToken,
1615) -> Result<(), String> {
1616    let sse_event = anthropic::parse_sse_event(event_type, data);
1617
1618    if let Some(usage) = extract_usage(data) {
1619        state.usage.merge_snapshot(&usage);
1620    }
1621
1622    if event_type == "content_block_start"
1623        && let Some((index, block)) = extract_tool_use_start(data)
1624    {
1625        state.tool_blocks.insert(index, block);
1626    }
1627
1628    if event_type == "content_block_start" {
1629        collect_content_block_start_text(
1630            data,
1631            &mut state.assistant_text,
1632            &mut state.provider_content_blocks,
1633            tx,
1634            cancel,
1635        )?;
1636    }
1637
1638    match &sse_event {
1639        anthropic::SseEvent::TextDelta(text) => state.assistant_text.push_str(text),
1640        anthropic::SseEvent::InputJsonDelta { index, partial_json } => {
1641            if let Some(block) = state.tool_blocks.get_mut(index) {
1642                block.input_json.push_str(partial_json);
1643            }
1644        }
1645        anthropic::SseEvent::ContentBlockStop { index } => {
1646            if let Some(index) = index
1647                && let Some(block) = state.tool_blocks.remove(index)
1648                && let Some(req) = block.finish()
1649            {
1650                state.tool_requests.push(req);
1651            }
1652        }
1653        anthropic::SseEvent::MessageDelta { stop_reason: Some(reason) } => {
1654            state.stop_reason = Some(reason.clone());
1655            tracing::info!(stop_reason = %reason, "provider message stop reason");
1656        }
1657        anthropic::SseEvent::Error(msg) => {
1658            tracing::error!(error = %msg, "provider emitted SSE error");
1659            return Err(format!("provider error: {msg}"));
1660        }
1661        _ => {}
1662    }
1663
1664    if let Some(agent_event) = anthropic::sse_to_agent_event(&sse_event)
1665        && send(tx, agent_event, cancel).is_none()
1666    {
1667        return Err("cancelled".to_string());
1668    }
1669
1670    Ok(())
1671}
1672
1673fn extract_usage(data: &str) -> Option<ProviderUsageComponents> {
1674    let v: serde_json::Value = serde_json::from_str(data).ok()?;
1675    let usage = v
1676        .get("usage")
1677        .or_else(|| v.get("message").and_then(|m| m.get("usage")))
1678        .or_else(|| v.get("delta").and_then(|d| d.get("usage")))?;
1679    let input_tokens = usage.get("input_tokens").and_then(|t| t.as_u64());
1680    let output_tokens = usage.get("output_tokens").and_then(|t| t.as_u64());
1681    let cache_read_input_tokens = usage.get("cache_read_input_tokens").and_then(|value| value.as_u64());
1682    let cache_creation_input_tokens = usage
1683        .get("cache_creation_input_tokens")
1684        .and_then(|value| value.as_u64());
1685    if input_tokens.is_none()
1686        && output_tokens.is_none()
1687        && cache_read_input_tokens.is_none()
1688        && cache_creation_input_tokens.is_none()
1689    {
1690        None
1691    } else {
1692        Some(ProviderUsageComponents {
1693            input_tokens,
1694            output_tokens,
1695            cache_read_input_tokens,
1696            cache_creation_input_tokens,
1697            reasoning_tokens: None,
1698        })
1699    }
1700}
1701
1702fn collect_content_block_start_text(
1703    data: &str, assistant_text: &mut String, provider_content_blocks: &mut Vec<String>, tx: &Sender<AgentEvent>,
1704    cancel: &CancelToken,
1705) -> Result<(), String> {
1706    let v: serde_json::Value = serde_json::from_str(data).unwrap_or(serde_json::Value::Null);
1707    let Some(block) = v.get("content_block") else {
1708        return Ok(());
1709    };
1710
1711    match block.get("type").and_then(|t| t.as_str()) {
1712        Some("text") => {
1713            let text = block.get("text").and_then(|t| t.as_str()).unwrap_or("");
1714            if !text.is_empty() {
1715                assistant_text.push_str(text);
1716                if send(tx, AgentEvent::AssistantDelta(text.to_string()), cancel).is_none() {
1717                    return Err("cancelled".to_string());
1718                }
1719            }
1720        }
1721        Some("thinking") => {
1722            let thinking = block.get("thinking").and_then(|t| t.as_str()).unwrap_or("");
1723            if !thinking.is_empty() && send(tx, AgentEvent::ReasoningDelta(thinking.to_string()), cancel).is_none() {
1724                return Err("cancelled".to_string());
1725            }
1726        }
1727        Some(other) => {
1728            provider_content_blocks.push(other.to_string());
1729            tracing::info!(content_type = other, "unhandled content block start");
1730        }
1731        None => {}
1732    }
1733
1734    Ok(())
1735}
1736
1737fn extract_tool_use_start(data: &str) -> Option<(usize, ToolUseBuilder)> {
1738    let v: serde_json::Value = serde_json::from_str(data).ok()?;
1739    let index = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
1740    let cb = v.get("content_block")?;
1741    let block_type = cb.get("type").and_then(|t| t.as_str())?;
1742    if block_type != "tool_use" {
1743        return None;
1744    }
1745    let name = cb.get("name").and_then(|n| n.as_str())?.to_string();
1746    let id = cb.get("id").and_then(|n| n.as_str()).unwrap_or("").to_string();
1747    let initial_input = cb.get("input").cloned().unwrap_or(serde_json::Value::Null);
1748    Some((
1749        index,
1750        ToolUseBuilder { id, name, initial_input, input_json: String::new() },
1751    ))
1752}
1753
1754/// Send an event, respecting cancellation.
1755///
1756/// A cancellation notification itself must still cross the channel after the
1757/// token has been set. Otherwise the UI can remain in its stopping state while
1758/// the worker exits.
1759fn send(tx: &Sender<AgentEvent>, event: AgentEvent, cancel: &CancelToken) -> Option<()> {
1760    if cancel.is_cancelled() && !matches!(event, AgentEvent::Cancelled) {
1761        let _ = tx.send(AgentEvent::Cancelled);
1762        return None;
1763    }
1764    if tx.send(event).is_err() {
1765        return None;
1766    }
1767    Some(())
1768}
1769
1770/// Sleep briefly to simulate streaming latency in the fake provider.
1771fn step() {
1772    thread::sleep(Duration::from_millis(40));
1773}
1774
1775#[cfg(test)]
1776mod tests {
1777    use super::*;
1778    use crate::app::ToolStatus;
1779    use crate::cli::WebSearchMode;
1780    use crate::providers;
1781    use crate::tools::{self, AgentRunConfig, MAX_TOOL_ITERATIONS};
1782    use std::path::{Path, PathBuf};
1783
1784    fn config() -> AgentRunConfig {
1785        AgentRunConfig::new(
1786            PathBuf::from("."),
1787            String::from("fake-agent"),
1788            WebSearchMode::DuckDuckGo,
1789        )
1790    }
1791
1792    struct MetadataErrorProvider {
1793        code: u16,
1794    }
1795
1796    impl StreamingProvider for MetadataErrorProvider {
1797        type Metadata = ();
1798
1799        fn name(&self) -> &'static str {
1800            "metadata-test"
1801        }
1802
1803        fn load_status(&self) -> String {
1804            "provider: loading metadata-test".to_string()
1805        }
1806
1807        fn request_status(&self, _model: &str) -> String {
1808            "provider: requesting metadata-test".to_string()
1809        }
1810
1811        fn from_env_or_dotenv(_root: &Path) -> providers::Result<Self> {
1812            Ok(Self { code: 401 })
1813        }
1814
1815        fn load_metadata(&self) -> providers::Result<Self::Metadata> {
1816            Err(ProviderError::Status { code: self.code, body: "metadata endpoint rejected request".to_string() })
1817        }
1818
1819        fn token_budget(&self, _model: &str, _metadata: Option<&Self::Metadata>) -> u32 {
1820            1
1821        }
1822
1823        fn serialized_request_body(
1824            &self, _model: &str, _messages: &[ProviderMessage], _request: &StreamingRequest<'_>,
1825        ) -> providers::Result<Vec<u8>> {
1826            panic!("a rejected metadata request must abort before serializing the prompt")
1827        }
1828
1829        fn send_streaming_request(
1830            &self, _model: &str, _messages: &[ProviderMessage], _request: &StreamingRequest<'_>,
1831        ) -> providers::Result<ureq::http::Response<ureq::Body>> {
1832            panic!("a rejected metadata request must abort before sending the prompt")
1833        }
1834
1835        fn stream_format(&self, _model: &str) -> providers::Result<StreamFormat> {
1836            Ok(StreamFormat::AnthropicMessages)
1837        }
1838
1839        fn request_error_message(error: &ProviderError) -> String {
1840            error.failure_message("metadata-test rate limit")
1841        }
1842
1843        fn is_retryable_request_error(error: &ProviderError) -> bool {
1844            error.is_retryable()
1845        }
1846    }
1847
1848    fn dispatch_output(req: &ToolUseRequest, root: &Path) -> tools::ToolOutput {
1849        tools::dispatch_full(req, root).0
1850    }
1851
1852    #[test]
1853    fn provider_retry_policy_and_classification_match_defaults() {
1854        assert_eq!(PROVIDER_RETRY_POLICY.max_retries, 4);
1855        assert_eq!(PROVIDER_RETRY_POLICY.delay_for_attempt(1), Duration::from_millis(2500));
1856        assert_eq!(
1857            PROVIDER_RETRY_POLICY.delay_for_attempt(4),
1858            Duration::from_millis(20_000)
1859        );
1860
1861        assert!(
1862            ProviderAttemptError::Request(providers::ProviderError::Status {
1863                code: 503,
1864                body: "temporarily unavailable".to_string(),
1865            })
1866            .is_retryable::<umans::UmansClient>()
1867        );
1868        assert!(
1869            ProviderAttemptError::Stream("stream read error: connection lost".to_string())
1870                .is_retryable::<umans::UmansClient>()
1871        );
1872        assert!(
1873            !ProviderAttemptError::Request(providers::ProviderError::Status {
1874                code: 401,
1875                body: "unauthorized".to_string()
1876            })
1877            .is_retryable::<umans::UmansClient>()
1878        );
1879        assert!(
1880            !ProviderAttemptError::Stream(
1881                "provider stopped at max_tokens (32768) before producing assistant text".to_string()
1882            )
1883            .is_retryable::<umans::UmansClient>()
1884        );
1885    }
1886
1887    #[test]
1888    fn rejected_metadata_aborts_before_sending_the_prompt() {
1889        let (_steering_tx, steering_rx) = mpsc::channel();
1890        let handle = RunHandle::provider_with_steering(config(), Vec::new(), false, steering_rx);
1891        let (tx, rx) = mpsc::channel();
1892        let cancel = CancelToken::new();
1893
1894        handle.run_provider::<MetadataErrorProvider>(&tx, &cancel);
1895
1896        let events: Vec<AgentEvent> = rx.try_iter().collect();
1897        assert!(events.iter().any(|event| {
1898            matches!(event, AgentEvent::Failed(message) if message == "authentication failed (HTTP 401)")
1899        }));
1900        assert!(!events.iter().any(|event| {
1901            matches!(event, AgentEvent::Status(message) if message.contains("fallback token budget"))
1902        }));
1903    }
1904
1905    #[test]
1906    fn unavailable_metadata_uses_the_fallback_token_budget() {
1907        let provider = MetadataErrorProvider { code: 503 };
1908        let (tx, rx) = mpsc::channel();
1909        let cancel = CancelToken::new();
1910
1911        let metadata = load_provider_metadata(&provider, "metadata-test", &tx, &cancel);
1912
1913        assert!(matches!(metadata, MetadataLoaded::Unavailable));
1914        assert_eq!(
1915            rx.try_recv().expect("fallback status"),
1916            AgentEvent::Status("provider: model metadata unavailable; using fallback token budget".to_string())
1917        );
1918    }
1919
1920    #[test]
1921    fn fake_stream_emits_expected_sequence() {
1922        let handle = RunHandle::fake(config(), String::new());
1923        let rx = handle.spawn();
1924
1925        let mut events = Vec::new();
1926        while let Ok(event) = rx.recv() {
1927            events.push(event);
1928        }
1929
1930        assert_eq!(events.first(), Some(&AgentEvent::Started));
1931        assert_eq!(events.last(), Some(&AgentEvent::Finished));
1932        assert!(events.iter().any(|e| matches!(e, AgentEvent::ReasoningDelta(_))));
1933        assert!(events.iter().any(|e| matches!(e, AgentEvent::AssistantDelta(_))));
1934        assert!(events.iter().any(|e| matches!(e, AgentEvent::ToolStarted { .. })));
1935        assert!(events.iter().any(|e| matches!(e, AgentEvent::ToolFinished { .. })));
1936    }
1937
1938    #[test]
1939    fn fake_stream_with_duckduckgo_search_emits_search_tool_event() {
1940        let mut cfg = config();
1941        cfg.search_mode = WebSearchMode::DuckDuckGo;
1942        let handle = RunHandle::fake(cfg, String::new());
1943        let rx = handle.spawn();
1944
1945        let mut events = Vec::new();
1946        while let Ok(event) = rx.recv() {
1947            events.push(event);
1948        }
1949
1950        let has_search = events
1951            .iter()
1952            .any(|e| matches!(e, AgentEvent::ToolStarted { name, .. } if name == "web_search"));
1953        assert!(has_search, "DuckDuckGo search should emit web_search tool event");
1954    }
1955
1956    #[test]
1957    fn fake_stream_with_none_search_skips_search_and_returns_assistant_text() {
1958        let mut cfg = config();
1959        cfg.search_mode = WebSearchMode::None;
1960        let handle = RunHandle::fake(cfg, String::new());
1961        let rx = handle.spawn();
1962
1963        let mut events = Vec::new();
1964        while let Ok(event) = rx.recv() {
1965            events.push(event);
1966        }
1967
1968        let has_search = events
1969            .iter()
1970            .any(|e| matches!(e, AgentEvent::ToolStarted { name, .. } if name == "web_search"));
1971        assert!(!has_search, "none search should not emit web_search tool event");
1972
1973        assert!(
1974            events.iter().any(|e| matches!(e, AgentEvent::AssistantDelta(_))),
1975            "search-disabled prompt should still return assistant text"
1976        );
1977        assert_eq!(events.last(), Some(&AgentEvent::Finished));
1978    }
1979
1980    /// Drop the receiver immediately; the thread should exit without panic.
1981    #[test]
1982    fn fake_stream_drops_cleanly_when_receiver_dropped() {
1983        let handle = RunHandle::fake(config(), String::new());
1984        let rx = handle.spawn();
1985        drop(rx);
1986    }
1987
1988    #[test]
1989    fn cancel_token_signals_cancellation() {
1990        let token = CancelToken::new();
1991        assert!(!token.is_cancelled());
1992
1993        token.cancel();
1994        assert!(token.is_cancelled());
1995    }
1996
1997    #[test]
1998    fn cancel_terminates_run_without_finishing() {
1999        let handle = RunHandle::fake(config(), String::new());
2000        handle.cancel.cancel();
2001
2002        let rx = handle.spawn();
2003        let mut events = Vec::new();
2004        while let Ok(event) = rx.recv() {
2005            events.push(event);
2006        }
2007
2008        assert!(
2009            !events.contains(&AgentEvent::Finished),
2010            "cancelled run must not finish normally"
2011        );
2012        assert!(
2013            events.contains(&AgentEvent::Cancelled),
2014            "cancelled run must notify the UI before its channel closes"
2015        );
2016    }
2017
2018    #[test]
2019    fn cancellation_notification_is_sent_after_token_is_cancelled() {
2020        let (tx, rx) = mpsc::channel();
2021        let token = CancelToken::new();
2022        token.cancel();
2023
2024        assert_eq!(send(&tx, AgentEvent::Cancelled, &token), Some(()));
2025        assert_eq!(rx.recv().expect("cancellation event"), AgentEvent::Cancelled);
2026    }
2027
2028    #[test]
2029    fn dispatch_tool_find_files_success() {
2030        let req = ToolUseRequest::new(
2031            String::from("find_files"),
2032            serde_json::json!({ "pattern": "mod.rs" }).to_string(),
2033            String::from("toolu_test"),
2034        );
2035        let output = dispatch_output(&req, Path::new("src/cli"));
2036        assert_eq!(output.status, ToolStatus::Ok);
2037        assert!(output.display.lines.iter().any(|p| p.contains("cli/mod.rs")));
2038    }
2039
2040    #[test]
2041    fn dispatch_tool_read_file_range_success() {
2042        let req = ToolUseRequest::new(
2043            String::from("read_file_range"),
2044            serde_json::json!({
2045                "path": "Cargo.toml",
2046                "start_line": 1,
2047                "end_line": 3
2048            })
2049            .to_string(),
2050            String::from("toolu_test"),
2051        );
2052        let output = dispatch_output(&req, Path::new("."));
2053        assert_eq!(output.status, ToolStatus::Ok);
2054        assert_eq!(output.display.lines.len(), 3);
2055    }
2056
2057    #[test]
2058    fn dispatch_tool_unknown_name_fails() {
2059        let req = ToolUseRequest::new(
2060            String::from("nonexistent_tool"),
2061            String::from("{}"),
2062            String::from("toolu_test"),
2063        );
2064        let output = dispatch_output(&req, Path::new("."));
2065        assert_eq!(output.status, ToolStatus::Failed);
2066        assert!(output.error.as_ref().is_some_and(|e| e.contains("unknown tool")));
2067    }
2068
2069    #[test]
2070    fn dispatch_tool_malformed_arguments_falls_back_to_defaults() {
2071        let req = ToolUseRequest::new(
2072            String::from("find_files"),
2073            String::from("not valid json"),
2074            String::from("toolu_test"),
2075        );
2076        let output = dispatch_output(&req, Path::new("src"));
2077        assert_eq!(output.status, ToolStatus::Ok);
2078    }
2079
2080    #[test]
2081    fn max_tool_iterations_is_reasonable() {
2082        let cap = MAX_TOOL_ITERATIONS;
2083        assert!(cap >= 4 && cap <= 16, "per-turn cap should be 4..=16, got {cap}");
2084    }
2085
2086    #[test]
2087    fn extract_tool_use_start_returns_none_for_text_block() {
2088        let data = r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#;
2089        assert!(extract_tool_use_start(data).is_none());
2090    }
2091
2092    #[test]
2093    fn extract_tool_use_start_returns_builder_for_tool_use_block() {
2094        let data = r#"{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"find_files","input":{"pattern":"cli"}}}"#;
2095        let (_index, block) = extract_tool_use_start(data).expect("should extract");
2096        let req = block.finish().expect("should finish");
2097        assert_eq!(req.name, "find_files");
2098        assert!(req.arguments.contains("cli"));
2099        assert_eq!(req.tool_use_id, "toolu_1");
2100    }
2101
2102    #[test]
2103    fn parse_tool_use_fixture_extracts_request_with_id() {
2104        let sse = include_str!("./providers/fixtures/tool_use_turn.sse");
2105        let chunks = anthropic::parse_sse_chunk(sse);
2106
2107        let mut tool_requests = Vec::new();
2108        let mut assistant_text = String::new();
2109        for (event_type, data) in &chunks {
2110            let sse_event = anthropic::parse_sse_event(event_type, data);
2111            if let anthropic::SseEvent::Other(ref t) = sse_event
2112                && t.starts_with("content_block_start")
2113                && let Some((_index, block)) = extract_tool_use_start(data)
2114                && let Some(req) = block.finish()
2115            {
2116                tool_requests.push(req);
2117            }
2118            if let anthropic::SseEvent::TextDelta(ref text) = sse_event {
2119                assistant_text.push_str(text);
2120            }
2121        }
2122
2123        assert_eq!(tool_requests.len(), 1);
2124        let req = &tool_requests[0];
2125        assert_eq!(req.name, "find_files");
2126        assert_eq!(req.tool_use_id, "toolu_01");
2127        assert!(req.arguments.contains("Cargo"));
2128        assert_eq!(assistant_text, "Let me look that up.");
2129    }
2130
2131    #[test]
2132    fn collect_umans_event_reconstructs_streamed_tool_input_json() {
2133        let (tx, _rx) = mpsc::channel();
2134        let cancel = CancelToken::new();
2135        let mut state = AnthropicStreamState::default();
2136
2137        state.collect(
2138            "content_block_start",
2139            r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"find_files","input":{}}}"#,
2140            &tx,
2141            &cancel,
2142        )
2143        .expect("collect event");
2144        state
2145            .collect(
2146                "content_block_delta",
2147                &serde_json::json!({
2148                    "type": "content_block_delta",
2149                    "index": 1,
2150                    "delta": {
2151                        "type": "input_json_delta",
2152                        "partial_json": "{\"pattern\""
2153                    }
2154                })
2155                .to_string(),
2156                &tx,
2157                &cancel,
2158            )
2159            .expect("collect event");
2160        state
2161            .collect(
2162                "content_block_delta",
2163                &serde_json::json!({
2164                    "type": "content_block_delta",
2165                    "index": 1,
2166                    "delta": {
2167                        "type": "input_json_delta",
2168                        "partial_json": ":\"Cargo\"}"
2169                    }
2170                })
2171                .to_string(),
2172                &tx,
2173                &cancel,
2174            )
2175            .expect("collect event");
2176        state
2177            .collect(
2178                "content_block_stop",
2179                r#"{"type":"content_block_stop","index":1}"#,
2180                &tx,
2181                &cancel,
2182            )
2183            .expect("collect event");
2184
2185        assert_eq!(state.tool_requests.len(), 1);
2186        assert_eq!(state.tool_requests[0].name, "find_files");
2187        assert_eq!(state.tool_requests[0].tool_use_id, "toolu_1");
2188        assert_eq!(state.tool_requests[0].arguments, r#"{"pattern":"Cargo"}"#);
2189    }
2190
2191    #[test]
2192    fn collect_umans_event_tracks_provider_side_content_blocks() {
2193        let (tx, _rx) = mpsc::channel();
2194        let cancel = CancelToken::new();
2195        let mut state = AnthropicStreamState::default();
2196
2197        state.collect(
2198            "content_block_start",
2199            r#"{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srv_1","name":"web_search","input":{}}}"#,
2200            &tx,
2201            &cancel,
2202        )
2203        .expect("collect event");
2204        state.collect(
2205            "content_block_start",
2206            r#"{"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","content":[]}}"#,
2207            &tx,
2208            &cancel,
2209        )
2210        .expect("collect event");
2211
2212        assert_eq!(
2213            state.provider_content_blocks,
2214            vec!["server_tool_use".to_string(), "web_search_tool_result".to_string()]
2215        );
2216        assert!(state.assistant_text.is_empty());
2217        assert!(state.tool_requests.is_empty());
2218    }
2219
2220    #[test]
2221    fn collect_openai_chat_event_maps_text_reasoning_and_usage() {
2222        let (tx, rx) = mpsc::channel();
2223        let cancel = CancelToken::new();
2224        let mut tool_blocks = HashMap::new();
2225        let mut tool_requests = Vec::new();
2226        let mut assistant_text = String::new();
2227        let mut stop_reason = None;
2228
2229        collect_openai_chat_event(
2230            openai::ChatSseEvent::TextDelta("hello".to_string()),
2231            &mut tool_blocks,
2232            &mut tool_requests,
2233            &mut assistant_text,
2234            &mut stop_reason,
2235            &tx,
2236            &cancel,
2237        )
2238        .expect("text delta");
2239        collect_openai_chat_event(
2240            openai::ChatSseEvent::ReasoningDelta("thinking".to_string()),
2241            &mut tool_blocks,
2242            &mut tool_requests,
2243            &mut assistant_text,
2244            &mut stop_reason,
2245            &tx,
2246            &cancel,
2247        )
2248        .expect("reasoning delta");
2249        collect_openai_chat_event(
2250            openai::ChatSseEvent::Usage { input_tokens: 2, output_tokens: 3 },
2251            &mut tool_blocks,
2252            &mut tool_requests,
2253            &mut assistant_text,
2254            &mut stop_reason,
2255            &tx,
2256            &cancel,
2257        )
2258        .expect("usage");
2259
2260        assert_eq!(assistant_text, "hello");
2261        let events: Vec<AgentEvent> = rx.try_iter().collect();
2262        assert!(events.contains(&AgentEvent::AssistantDelta("hello".to_string())));
2263        assert!(events.contains(&AgentEvent::ReasoningDelta("thinking".to_string())));
2264        assert!(!events.iter().any(|event| matches!(event, AgentEvent::Usage { .. })));
2265    }
2266
2267    #[test]
2268    fn collect_openai_chat_event_finishes_tool_calls_on_finish_reason() {
2269        let (tx, _rx) = mpsc::channel();
2270        let cancel = CancelToken::new();
2271        let mut tool_blocks = HashMap::new();
2272        let mut tool_requests = Vec::new();
2273        let mut assistant_text = String::new();
2274        let mut stop_reason = None;
2275
2276        collect_openai_chat_event(
2277            openai::ChatSseEvent::ToolCallStart { index: 0, id: "call_1".to_string(), name: "find_files".to_string() },
2278            &mut tool_blocks,
2279            &mut tool_requests,
2280            &mut assistant_text,
2281            &mut stop_reason,
2282            &tx,
2283            &cancel,
2284        )
2285        .expect("tool start");
2286        collect_openai_chat_event(
2287            openai::ChatSseEvent::ToolCallArgumentsDelta { index: 0, arguments: r#"{"pattern":"Cargo"}"#.to_string() },
2288            &mut tool_blocks,
2289            &mut tool_requests,
2290            &mut assistant_text,
2291            &mut stop_reason,
2292            &tx,
2293            &cancel,
2294        )
2295        .expect("tool args");
2296        collect_openai_chat_event(
2297            openai::ChatSseEvent::FinishReason("tool_calls".to_string()),
2298            &mut tool_blocks,
2299            &mut tool_requests,
2300            &mut assistant_text,
2301            &mut stop_reason,
2302            &tx,
2303            &cancel,
2304        )
2305        .expect("finish");
2306
2307        assert_eq!(stop_reason.as_deref(), Some("tool_calls"));
2308        assert_eq!(tool_requests.len(), 1);
2309        assert_eq!(tool_requests[0].name, "find_files");
2310        assert_eq!(tool_requests[0].tool_use_id, "call_1");
2311        assert_eq!(tool_requests[0].arguments, r#"{"pattern":"Cargo"}"#);
2312    }
2313
2314    #[test]
2315    fn collect_openai_chat_event_handles_status_and_failures() {
2316        let (tx, rx) = mpsc::channel();
2317        let cancel = CancelToken::new();
2318        let mut tool_blocks = HashMap::new();
2319        let mut tool_requests = Vec::new();
2320        let mut assistant_text = String::new();
2321        let mut stop_reason = None;
2322
2323        collect_openai_chat_event(
2324            openai::ChatSseEvent::ResponseStatus("queued".to_string()),
2325            &mut tool_blocks,
2326            &mut tool_requests,
2327            &mut assistant_text,
2328            &mut stop_reason,
2329            &tx,
2330            &cancel,
2331        )
2332        .expect("queued status");
2333        assert!(matches!(
2334            rx.try_recv(),
2335            Ok(AgentEvent::Status(status)) if status == "provider: status queued"
2336        ));
2337
2338        let failed = collect_openai_chat_event(
2339            openai::ChatSseEvent::ResponseStatus("failed".to_string()),
2340            &mut tool_blocks,
2341            &mut tool_requests,
2342            &mut assistant_text,
2343            &mut stop_reason,
2344            &tx,
2345            &cancel,
2346        )
2347        .expect_err("failed status should fail");
2348        assert!(failed.contains("provider stream status: failed"));
2349
2350        let backend = collect_openai_chat_event(
2351            openai::ChatSseEvent::Error("backend failed".to_string()),
2352            &mut tool_blocks,
2353            &mut tool_requests,
2354            &mut assistant_text,
2355            &mut stop_reason,
2356            &tx,
2357            &cancel,
2358        )
2359        .expect_err("backend error should fail");
2360        assert!(backend.contains("provider error: backend failed"));
2361
2362        let malformed = collect_openai_chat_event(
2363            openai::ChatSseEvent::Malformed("{bad".to_string()),
2364            &mut tool_blocks,
2365            &mut tool_requests,
2366            &mut assistant_text,
2367            &mut stop_reason,
2368            &tx,
2369            &cancel,
2370        )
2371        .expect_err("malformed payload should fail");
2372        assert!(malformed.contains("malformed provider stream payload"));
2373        assert!(!ProviderAttemptError::Stream(malformed).is_retryable::<umans::UmansClient>());
2374    }
2375
2376    #[test]
2377    fn collect_chatgpt_codex_event_maps_text_reasoning_and_usage() {
2378        let (tx, rx) = mpsc::channel();
2379        let cancel = CancelToken::new();
2380        let mut tool_blocks = HashMap::new();
2381        let mut tool_requests = Vec::new();
2382        let mut assistant_text = String::new();
2383        let mut stop_reason = None;
2384
2385        collect_chatgpt_codex_event(
2386            codex::ResponsesSseEvent::TextDelta("hello".to_string()),
2387            &mut tool_blocks,
2388            &mut tool_requests,
2389            &mut assistant_text,
2390            &mut stop_reason,
2391            &tx,
2392            &cancel,
2393        )
2394        .expect("text delta");
2395        collect_chatgpt_codex_event(
2396            codex::ResponsesSseEvent::ReasoningDelta("thinking".to_string()),
2397            &mut tool_blocks,
2398            &mut tool_requests,
2399            &mut assistant_text,
2400            &mut stop_reason,
2401            &tx,
2402            &cancel,
2403        )
2404        .expect("reasoning delta");
2405        collect_chatgpt_codex_event(
2406            codex::ResponsesSseEvent::Usage { input_tokens: 3, output_tokens: 5 },
2407            &mut tool_blocks,
2408            &mut tool_requests,
2409            &mut assistant_text,
2410            &mut stop_reason,
2411            &tx,
2412            &cancel,
2413        )
2414        .expect("usage");
2415
2416        assert_eq!(assistant_text, "hello");
2417        let events: Vec<AgentEvent> = rx.try_iter().collect();
2418        assert!(events.contains(&AgentEvent::AssistantDelta("hello".to_string())));
2419        assert!(events.contains(&AgentEvent::ReasoningDelta("thinking".to_string())));
2420        assert!(!events.iter().any(|event| matches!(event, AgentEvent::Usage { .. })));
2421    }
2422
2423    #[test]
2424    fn collect_chatgpt_codex_event_finishes_tool_calls_on_done() {
2425        let (tx, _rx) = mpsc::channel();
2426        let cancel = CancelToken::new();
2427        let mut tool_blocks = HashMap::new();
2428        let mut tool_requests = Vec::new();
2429        let mut assistant_text = String::new();
2430        let mut stop_reason = None;
2431
2432        collect_chatgpt_codex_event(
2433            codex::ResponsesSseEvent::ToolCallStart {
2434                id: "fc_1".to_string(),
2435                call_id: "call_1".to_string(),
2436                name: "find_files".to_string(),
2437            },
2438            &mut tool_blocks,
2439            &mut tool_requests,
2440            &mut assistant_text,
2441            &mut stop_reason,
2442            &tx,
2443            &cancel,
2444        )
2445        .expect("tool start");
2446        collect_chatgpt_codex_event(
2447            codex::ResponsesSseEvent::ToolCallArgumentsDelta {
2448                id: "fc_1".to_string(),
2449                arguments: r#"{"pattern":"Car"#.to_string(),
2450            },
2451            &mut tool_blocks,
2452            &mut tool_requests,
2453            &mut assistant_text,
2454            &mut stop_reason,
2455            &tx,
2456            &cancel,
2457        )
2458        .expect("tool args");
2459        collect_chatgpt_codex_event(
2460            codex::ResponsesSseEvent::ToolCallDone {
2461                id: "fc_1".to_string(),
2462                call_id: Some("call_1".to_string()),
2463                name: "find_files".to_string(),
2464                arguments: r#"{"pattern":"Cargo"}"#.to_string(),
2465            },
2466            &mut tool_blocks,
2467            &mut tool_requests,
2468            &mut assistant_text,
2469            &mut stop_reason,
2470            &tx,
2471            &cancel,
2472        )
2473        .expect("tool done");
2474
2475        assert!(tool_blocks.is_empty());
2476        assert_eq!(tool_requests.len(), 1);
2477        assert_eq!(tool_requests[0].name, "find_files");
2478        assert_eq!(tool_requests[0].tool_use_id, "call_1");
2479        assert_eq!(tool_requests[0].arguments, r#"{"pattern":"Cargo"}"#);
2480    }
2481
2482    #[test]
2483    fn collect_chatgpt_codex_event_handles_statuses_and_failures() {
2484        let (tx, rx) = mpsc::channel();
2485        let cancel = CancelToken::new();
2486        let mut tool_blocks = HashMap::new();
2487        let mut tool_requests = Vec::new();
2488        let mut assistant_text = String::new();
2489        let mut stop_reason = None;
2490
2491        collect_chatgpt_codex_event(
2492            codex::ResponsesSseEvent::ResponseStatus("queued".to_string()),
2493            &mut tool_blocks,
2494            &mut tool_requests,
2495            &mut assistant_text,
2496            &mut stop_reason,
2497            &tx,
2498            &cancel,
2499        )
2500        .expect("queued status");
2501        collect_chatgpt_codex_event(
2502            codex::ResponsesSseEvent::ResponseStatus("completed".to_string()),
2503            &mut tool_blocks,
2504            &mut tool_requests,
2505            &mut assistant_text,
2506            &mut stop_reason,
2507            &tx,
2508            &cancel,
2509        )
2510        .expect("completed status");
2511        assert_eq!(stop_reason.as_deref(), Some("completed"));
2512        let events: Vec<AgentEvent> = rx.try_iter().collect();
2513        assert!(events.contains(&AgentEvent::Status("provider: status queued".to_string())));
2514        assert!(events.contains(&AgentEvent::Status("provider: status completed".to_string())));
2515
2516        let failed = collect_chatgpt_codex_event(
2517            codex::ResponsesSseEvent::ResponseStatus("incomplete".to_string()),
2518            &mut tool_blocks,
2519            &mut tool_requests,
2520            &mut assistant_text,
2521            &mut stop_reason,
2522            &tx,
2523            &cancel,
2524        )
2525        .expect_err("incomplete status should fail");
2526        assert!(failed.contains("provider stream status: incomplete"));
2527
2528        let backend = collect_chatgpt_codex_event(
2529            codex::ResponsesSseEvent::Error("backend failed".to_string()),
2530            &mut tool_blocks,
2531            &mut tool_requests,
2532            &mut assistant_text,
2533            &mut stop_reason,
2534            &tx,
2535            &cancel,
2536        )
2537        .expect_err("backend error should fail");
2538        assert!(backend.contains("provider error: backend failed"));
2539
2540        let malformed = collect_chatgpt_codex_event(
2541            codex::ResponsesSseEvent::Malformed("{bad".to_string()),
2542            &mut tool_blocks,
2543            &mut tool_requests,
2544            &mut assistant_text,
2545            &mut stop_reason,
2546            &tx,
2547            &cancel,
2548        )
2549        .expect_err("malformed payload should fail");
2550        assert!(malformed.contains("malformed provider stream payload"));
2551        assert!(!ProviderAttemptError::Stream(malformed).is_retryable::<umans::UmansClient>());
2552    }
2553
2554    #[test]
2555    fn append_steering_messages_adds_user_messages() {
2556        let (tx, rx) = mpsc::channel();
2557        tx.send("look at tests first".to_string()).expect("send steering");
2558        drop(tx);
2559
2560        let handle = RunHandle::provider_with_steering(config(), Vec::new(), false, rx);
2561        let mut messages = Vec::new();
2562
2563        assert!(append_steering_messages(&mut messages, &handle));
2564        assert_eq!(messages.len(), 1);
2565        assert_eq!(messages[0].role, "user");
2566        assert!(messages[0].as_text().contains("[steering]"));
2567        assert!(messages[0].as_text().contains("look at tests first"));
2568    }
2569
2570    fn handle_with_permission(decision: ToolPermissionDecision) -> RunHandle {
2571        let (_tx, rx) = mpsc::channel();
2572        RunHandle::provider_with_steering(config(), Vec::new(), false, rx)
2573            .with_permission_hook(ToolPermissionHook::new(move |_request, _config, _cancel| decision))
2574    }
2575
2576    fn approve_request(name: &str, decision: ToolPermissionDecision) -> ToolPermissionDecision {
2577        let request = ToolUseRequest::new(name.to_string(), "{}".to_string(), "tool-1".to_string());
2578        let handle = handle_with_permission(decision);
2579        approve_tool_request(&request, &handle, &CancelToken::new())
2580    }
2581
2582    #[test]
2583    fn permission_hook_allows_file_write_tool() {
2584        assert_eq!(
2585            approve_request("create_file", ToolPermissionDecision::Allow),
2586            ToolPermissionDecision::Allow
2587        );
2588    }
2589
2590    #[test]
2591    fn permission_hook_rejects_file_write_tool() {
2592        assert_eq!(
2593            approve_request("replace_range", ToolPermissionDecision::Reject),
2594            ToolPermissionDecision::Reject
2595        );
2596    }
2597
2598    #[test]
2599    fn permission_hook_allows_shell_tool() {
2600        assert_eq!(
2601            approve_request("run_shell", ToolPermissionDecision::Allow),
2602            ToolPermissionDecision::Allow
2603        );
2604    }
2605
2606    #[test]
2607    fn permission_hook_rejects_shell_tool() {
2608        assert_eq!(
2609            approve_request("run_shell", ToolPermissionDecision::Reject),
2610            ToolPermissionDecision::Reject
2611        );
2612    }
2613
2614    #[test]
2615    fn permission_hook_cancels_sensitive_tool() {
2616        assert_eq!(
2617            approve_request("write_patch", ToolPermissionDecision::Cancelled),
2618            ToolPermissionDecision::Cancelled
2619        );
2620    }
2621
2622    #[test]
2623    fn read_only_tool_bypasses_permission_hook() {
2624        assert_eq!(
2625            approve_request("read_file_range", ToolPermissionDecision::Reject),
2626            ToolPermissionDecision::Allow
2627        );
2628    }
2629
2630    #[test]
2631    fn prompt_expects_workspace_write_for_file_edit_request() {
2632        assert!(prompt_expects_workspace_write(
2633            "Looking at completed work in TODO.md, can you summarize them like the completed sections?"
2634        ));
2635        assert!(prompt_expects_workspace_write("update README.md with install notes"));
2636    }
2637
2638    #[test]
2639    fn prompt_expects_workspace_write_ignores_plain_file_questions() {
2640        assert!(!prompt_expects_workspace_write("what does TODO.md contain?"));
2641        assert!(!prompt_expects_workspace_write("summarize the project architecture"));
2642    }
2643
2644    #[test]
2645    fn provider_kind_routes_open_code_prefixes_separately() {
2646        assert_eq!(
2647            ProviderKind::for_model("opencode/big-pickle"),
2648            ProviderKind::OpenCodeZen
2649        );
2650        assert_eq!(
2651            ProviderKind::for_model("opencode-go/kimi-k2.7-code"),
2652            ProviderKind::OpenCodeGo
2653        );
2654        assert_eq!(
2655            ProviderKind::for_model("chatgpt-codex/gpt-5.5"),
2656            ProviderKind::ChatGptCodex
2657        );
2658        assert_eq!(ProviderKind::for_model("big-pickle"), ProviderKind::Umans);
2659    }
2660
2661    #[test]
2662    fn dispatch_read_url_rejects_private_network() {
2663        let req = ToolUseRequest::new(
2664            String::from("read_url"),
2665            serde_json::json!({ "url": "http://127.0.0.1/secret" }).to_string(),
2666            String::from("toolu_test"),
2667        );
2668        let output = dispatch_output(&req, Path::new("."));
2669        assert_eq!(output.status, ToolStatus::Failed);
2670        assert!(output.error.as_ref().is_some_and(|e| e.contains("private network")));
2671    }
2672
2673    #[test]
2674    fn dispatch_read_url_rejects_non_public_scheme() {
2675        let req = ToolUseRequest::new(
2676            String::from("read_url"),
2677            serde_json::json!({ "url": "file:///etc/passwd" }).to_string(),
2678            String::from("toolu_test"),
2679        );
2680        let output = dispatch_output(&req, Path::new("."));
2681        assert_eq!(output.status, ToolStatus::Failed);
2682        assert!(output.error.as_ref().is_some_and(|e| e.contains("unsupported")));
2683    }
2684
2685    #[test]
2686    fn tool_definitions_include_web_search_and_read_url() {
2687        let defs = tools::tool_definitions();
2688        let names = defs.iter().map(|d| d.name.as_ref()).collect::<Vec<&str>>();
2689        assert!(names.contains(&"web_search"));
2690        assert!(names.contains(&"read_url"));
2691        assert!(names.contains(&"run_shell"), "tool catalog should include run_shell");
2692    }
2693
2694    #[test]
2695    fn dispatch_run_shell_success() {
2696        let req = ToolUseRequest::new(
2697            String::from("run_shell"),
2698            serde_json::json!({ "program": "echo", "args": ["hello"] }).to_string(),
2699            String::from("toolu_test"),
2700        );
2701        let output = dispatch_output(&req, Path::new("."));
2702        assert_eq!(output.status, ToolStatus::Ok);
2703        assert_eq!(output.name, "run_shell");
2704        assert!(output.display.lines.iter().any(|l| l.contains("hello")));
2705    }
2706
2707    #[test]
2708    fn dispatch_run_shell_failure() {
2709        let req = ToolUseRequest {
2710            name: String::from("run_shell"),
2711            arguments: serde_json::json!({ "program": "sh", "args": ["-c", "exit 1"] }).to_string(),
2712            tool_use_id: String::from("toolu_test"),
2713        };
2714        let output = dispatch_output(&req, Path::new("."));
2715        assert_eq!(output.status, ToolStatus::Failed);
2716        assert!(output.error.as_ref().is_some_and(|e| e.contains("exit 1")));
2717    }
2718
2719    #[test]
2720    fn dispatch_run_shell_missing_program_fails() {
2721        let req = ToolUseRequest::new(
2722            String::from("run_shell"),
2723            serde_json::json!({ "args": ["test"] }).to_string(),
2724            String::from("toolu_test"),
2725        );
2726        let output = dispatch_output(&req, Path::new("."));
2727        assert_eq!(output.status, ToolStatus::Failed);
2728        assert!(output.error.as_ref().is_some_and(|e| e.contains("missing")));
2729    }
2730
2731    #[test]
2732    fn agent_dispatch_cancellation_stops_run_shell() {
2733        let dir = tempfile::tempdir().expect("temp dir");
2734        let handle = RunHandle::fake(
2735            AgentRunConfig::new(dir.path().to_path_buf(), "fake-agent".to_string(), WebSearchMode::None),
2736            String::new(),
2737        );
2738        let request = ToolUseRequest::new(
2739            "run_shell".to_string(),
2740            serde_json::json!({ "argv": ["sh", "-c", "exec sleep 30"] }).to_string(),
2741            "call_1".to_string(),
2742        );
2743        let cancel = handle.cancel.clone();
2744        let canceller = cancel.clone();
2745        let stopper = std::thread::spawn(move || {
2746            std::thread::sleep(std::time::Duration::from_millis(100));
2747            canceller.cancel();
2748        });
2749        let started = std::time::Instant::now();
2750
2751        let (output, _, process) = dispatch_tool_request(&request, &handle, &cancel);
2752
2753        stopper.join().expect("cancellation thread");
2754        let process = process.expect("shell process result");
2755        assert_eq!(output.status, ToolStatus::Failed);
2756        assert_eq!(process.status, crate::tools::shell::ProcessStatus::Cancelled);
2757        assert!(started.elapsed() < std::time::Duration::from_secs(5));
2758    }
2759
2760    #[test]
2761    fn tool_display_output_includes_failure_detail_without_changing_success_output() {
2762        let success = ToolOutput::ok("find_files", vec!["src/lib.rs".to_string()]);
2763        assert_eq!(success.display_lines(), vec!["src/lib.rs"]);
2764
2765        let failure = ToolOutput::failed(
2766            "run_shell",
2767            "missing command: provide non-empty 'argv', 'command', or 'program'",
2768        );
2769        assert_eq!(
2770            failure.display_lines(),
2771            vec!["error: missing command: provide non-empty 'argv', 'command', or 'program'"]
2772        );
2773    }
2774}