1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum ProviderKind {
53 Fake,
55 Umans,
57 OpenCodeGo,
59 OpenCodeZen,
61 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
130pub type ToolPermissionHook = thndrs_agent::ToolPermissionHook<AgentRunConfig>;
132
133pub 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#[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 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 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 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 pub fn with_permission_hook(mut self, hook: ToolPermissionHook) -> Self {
245 self.permission_hook = Some(hook);
246 self
247 }
248
249 pub fn with_execution_hook(mut self, hook: ToolExecutionHook) -> Self {
251 self.execution_hook = Some(hook);
252 self
253 }
254
255 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 #[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 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
751pub 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 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
1101fn 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
1754fn 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
1770fn 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 #[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}