Skip to main content

aether_cli/headless/
run.rs

1use aether_core::core::{AgentDeps, Prompt};
2use aether_core::events::{
3    AgentEvent, Command, CompactionOutcome, ContextEvent, LlmCallOutcome, MessageEvent, ModelEvent, ToolEvent,
4    TurnEvent, TurnOutcome,
5};
6use aether_core::mcp::run_mcp_task::McpCommand;
7use aether_telemetry::TelemetryRuntime;
8use std::io;
9use std::process::ExitCode;
10use std::sync::Arc;
11use tokio::sync::mpsc;
12use tracing::error;
13
14use crate::telemetry::build_telemetry_runtime;
15
16use super::error::CliError;
17use super::{CliEventKind, RunConfig};
18use crate::output::OutputFormat;
19use crate::runtime::RuntimeBuilder;
20use crate::slash_commands::{expand_slash_command, parse_slash_command};
21
22pub async fn run(config: RunConfig) -> Result<ExitCode, CliError> {
23    setup_tracing(config.verbose);
24
25    let telemetry = build_telemetry_runtime(config.telemetry.as_ref())?;
26    let result = run_agent(config, telemetry.clone()).await;
27
28    if let Some(telemetry) = telemetry {
29        telemetry.shutdown_or_log();
30    }
31    result
32}
33
34async fn run_agent(config: RunConfig, telemetry: Option<Arc<TelemetryRuntime>>) -> Result<ExitCode, CliError> {
35    let mut spec = config.spec;
36    if let Some(system_prompt) = config.system_prompt {
37        spec.prompts.push(Prompt::text(&system_prompt));
38    }
39
40    let deps =
41        AgentDeps::new(config.oauth_credential_store, telemetry.as_ref().map(|runtime| runtime.observer_factory()));
42    let (agent, _mcp_snapshot) = RuntimeBuilder::from_spec(config.cwd.clone(), spec)
43        .mcp_sources(config.mcp_config_sources)
44        .agent_deps(deps)
45        .build_ready(vec![])
46        .await?;
47
48    let prompt = expand_prompt(&agent.mcp_tx, config.prompt).await;
49
50    agent
51        .agent_tx
52        .send(Command::text(&prompt))
53        .await
54        .map_err(|e| CliError::AgentError(format!("Failed to send prompt: {e}")))?;
55
56    let exit_code = stream_output(agent.agent_rx, config.output, &config.events).await;
57
58    drop(agent.agent_tx);
59    agent.agent_handle.await_completion().await;
60
61    Ok(exit_code)
62}
63
64async fn expand_prompt(mcp_tx: &mpsc::Sender<McpCommand>, prompt: String) -> String {
65    let Some(slash_command) = parse_slash_command(&prompt) else {
66        return prompt;
67    };
68
69    match expand_slash_command(mcp_tx, slash_command.command_name, slash_command.args_text).await {
70        Ok(expanded) => expanded,
71        Err(error) => {
72            error!("Failed to expand slash command: {error}");
73            prompt
74        }
75    }
76}
77
78async fn stream_output(mut rx: mpsc::Receiver<AgentEvent>, format: OutputFormat, events: &[CliEventKind]) -> ExitCode {
79    while let Some(msg) = rx.recv().await {
80        if should_emit(&msg, events)
81            && let Err(error) = print_message(format, &msg)
82        {
83            eprintln!("Failed to serialize headless event: {error}");
84            return ExitCode::FAILURE;
85        }
86
87        if let Some(outcome) = msg.turn_outcome() {
88            return match outcome {
89                TurnOutcome::Failed { .. } => ExitCode::FAILURE,
90                TurnOutcome::Completed | TurnOutcome::Cancelled => ExitCode::SUCCESS,
91            };
92        }
93    }
94    ExitCode::SUCCESS
95}
96
97fn print_message(format: OutputFormat, msg: &AgentEvent) -> Result<(), serde_json::Error> {
98    match format {
99        OutputFormat::Text => {
100            if let Some(text) = format_text(msg) {
101                if matches!(msg, AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Failed { .. } })) {
102                    eprintln!("{text}");
103                } else {
104                    println!("{text}");
105                }
106            }
107        }
108        OutputFormat::Pretty => println!("{}", serde_json::to_string_pretty(msg)?),
109        OutputFormat::Json => println!("{}", serde_json::to_string(msg)?),
110    }
111
112    Ok(())
113}
114
115fn should_emit(msg: &AgentEvent, include: &[CliEventKind]) -> bool {
116    let Some(kind) = event_kind(msg) else { return false };
117    include.is_empty() || include.contains(&kind)
118}
119
120fn event_kind(msg: &AgentEvent) -> Option<CliEventKind> {
121    match msg {
122        AgentEvent::Message(MessageEvent::Text { is_complete: true, .. }) => Some(CliEventKind::Text),
123        AgentEvent::Message(MessageEvent::Thought { is_complete: true, .. }) => Some(CliEventKind::Thought),
124        AgentEvent::Tool(ToolEvent::Call { .. }) => Some(CliEventKind::ToolCall),
125        AgentEvent::Tool(ToolEvent::Result { .. }) => Some(CliEventKind::ToolResult),
126        AgentEvent::Tool(ToolEvent::Error { .. }) => Some(CliEventKind::ToolError),
127        AgentEvent::Turn(TurnEvent::AutoContinue { .. }) => Some(CliEventKind::AutoContinue),
128        AgentEvent::Model(ModelEvent::Switched { .. }) => Some(CliEventKind::ModelSwitched),
129        AgentEvent::Tool(ToolEvent::Progress { .. }) => Some(CliEventKind::ToolProgress),
130        AgentEvent::Context(ContextEvent::CompactionStarted { .. }) => Some(CliEventKind::ContextCompactionStarted),
131        AgentEvent::Context(ContextEvent::CompactionEnded { .. }) => Some(CliEventKind::ContextCompactionEnded),
132        AgentEvent::Context(ContextEvent::CompactionResult { .. }) => Some(CliEventKind::ContextCompactionResult),
133        AgentEvent::Context(ContextEvent::UsageUpdated { .. }) => Some(CliEventKind::ContextUsage),
134        AgentEvent::Context(ContextEvent::Cleared) => Some(CliEventKind::ContextCleared),
135        AgentEvent::Turn(TurnEvent::Started { .. }) => Some(CliEventKind::TurnStarted),
136        AgentEvent::Turn(TurnEvent::Ended { .. }) => Some(CliEventKind::TurnEnded),
137        AgentEvent::Turn(TurnEvent::RetryScheduled { .. }) => Some(CliEventKind::LlmRetryScheduled),
138        AgentEvent::Turn(TurnEvent::LlmCallStarted { .. }) => Some(CliEventKind::LlmCallStarted),
139        AgentEvent::Turn(TurnEvent::LlmCallEnded { .. }) => Some(CliEventKind::LlmCallEnded),
140        AgentEvent::Tool(ToolEvent::ExecutionStarted { .. }) => Some(CliEventKind::ToolExecutionStarted),
141        AgentEvent::Tool(ToolEvent::DefinitionsUpdated { .. }) => Some(CliEventKind::ToolDefinitionsUpdated),
142        AgentEvent::Message(
143            MessageEvent::Text { is_complete: false, .. } | MessageEvent::Thought { is_complete: false, .. },
144        )
145        | AgentEvent::Tool(ToolEvent::CallUpdate { .. }) => None,
146    }
147}
148
149fn format_text(msg: &AgentEvent) -> Option<String> {
150    match msg {
151        AgentEvent::Message(MessageEvent::Text { chunk, is_complete: true, .. }) => Some(chunk.clone()),
152
153        AgentEvent::Message(MessageEvent::Thought { chunk, is_complete: true, .. }) => {
154            Some(format!("Thought: {chunk}"))
155        }
156
157        AgentEvent::Tool(ToolEvent::Call { request, .. }) => {
158            Some(format!("Tool call: {}({})", request.name, request.arguments))
159        }
160
161        AgentEvent::Tool(ToolEvent::Result { result, .. }) => {
162            Some(format!("Tool result [{}]: {}", result.name, result.result))
163        }
164
165        AgentEvent::Tool(ToolEvent::Error { error, .. }) => {
166            Some(format!("Tool error [{}]: {}", error.name, error.error))
167        }
168
169        AgentEvent::Turn(TurnEvent::Ended { outcome }) => Some(match outcome {
170            TurnOutcome::Completed => "Done".to_string(),
171            TurnOutcome::Cancelled => "Cancelled".to_string(),
172            TurnOutcome::Failed { error } => format!("Error: {error}"),
173        }),
174
175        AgentEvent::Turn(TurnEvent::AutoContinue { attempt, max_attempts }) => {
176            Some(format!("Continuing ({attempt}/{max_attempts})..."))
177        }
178
179        AgentEvent::Turn(event @ TurnEvent::RetryScheduled { .. }) => event
180            .retry_info()
181            .map(|retry| format!("Retrying ({}/{}) in {}ms", retry.attempt, retry.max_attempts, retry.delay_ms)),
182
183        AgentEvent::Turn(TurnEvent::LlmCallEnded {
184            outcome: LlmCallOutcome::Failed { error, will_retry: true },
185            ..
186        }) => Some(format!("LLM call failed (will retry): {error}")),
187
188        AgentEvent::Model(ModelEvent::Switched { previous, new }) => {
189            Some(format!("Model switched: {previous} -> {new}"))
190        }
191
192        AgentEvent::Tool(ToolEvent::Progress { request, progress, total, message }) => {
193            let bar = match total {
194                Some(t) => format!("{progress}/{t}"),
195                None => format!("{progress}"),
196            };
197            let suffix = message.as_deref().map(|m| format!(" - {m}")).unwrap_or_default();
198            Some(format!("Tool progress [{}]: {bar}{suffix}", request.name))
199        }
200
201        AgentEvent::Context(ContextEvent::CompactionStarted { message_count }) => {
202            Some(format!("Context compaction started ({message_count} messages)"))
203        }
204
205        AgentEvent::Context(ContextEvent::CompactionEnded { outcome }) => Some(match outcome {
206            CompactionOutcome::Completed => "Context compaction completed".to_string(),
207            CompactionOutcome::Failed { error } => format!("Context compaction failed: {error}"),
208            CompactionOutcome::Cancelled => "Context compaction cancelled".to_string(),
209        }),
210
211        AgentEvent::Context(ContextEvent::CompactionResult { summary, messages_removed }) => {
212            Some(format!("Context compacted: {messages_removed} messages removed. {summary}"))
213        }
214
215        AgentEvent::Context(ContextEvent::UsageUpdated { usage }) => Some(format!(
216            "Tokens: {} in, {} out (total: {} in, {} out)",
217            usage.input_tokens, usage.output_tokens, usage.total_input_tokens, usage.total_output_tokens
218        )),
219
220        AgentEvent::Context(ContextEvent::Cleared) => Some("Context cleared".to_string()),
221
222        AgentEvent::Turn(
223            TurnEvent::Started { .. }
224            | TurnEvent::LlmCallStarted { .. }
225            | TurnEvent::LlmCallEnded {
226                outcome:
227                    LlmCallOutcome::Completed { .. }
228                    | LlmCallOutcome::Cancelled
229                    | LlmCallOutcome::Failed { will_retry: false, .. },
230                ..
231            },
232        )
233        | AgentEvent::Tool(
234            ToolEvent::ExecutionStarted { .. } | ToolEvent::DefinitionsUpdated { .. } | ToolEvent::CallUpdate { .. },
235        )
236        | AgentEvent::Message(MessageEvent::Text { .. } | MessageEvent::Thought { .. }) => None,
237    }
238}
239
240fn setup_tracing(verbose: bool) {
241    use tracing_subscriber::Layer;
242    use tracing_subscriber::filter::EnvFilter;
243    use tracing_subscriber::fmt;
244    use tracing_subscriber::layer::SubscriberExt;
245    use tracing_subscriber::util::SubscriberInitExt;
246
247    let filter = if verbose { EnvFilter::new("debug,agent=off") } else { EnvFilter::new("error,agent=off") };
248    let layer = fmt::layer().with_writer(io::stderr).with_filter(filter);
249
250    tracing_subscriber::registry().with(layer).init();
251}
252
253#[cfg(test)]
254mod tests {
255    use aether_core::events::ContextUsage;
256
257    use super::*;
258
259    #[test]
260    fn format_text_formats_complete_text() {
261        assert_eq!(format_text(&AgentEvent::text("id", "hello world", true)), Some("hello world".to_string()));
262    }
263
264    #[test]
265    fn format_text_skips_incomplete_text() {
266        assert_eq!(format_text(&AgentEvent::text("id", "partial", false)), None);
267    }
268
269    #[test]
270    fn format_text_formats_complete_thought() {
271        assert_eq!(
272            format_text(&AgentEvent::thought("id", "reasoning here", true)),
273            Some("Thought: reasoning here".to_string())
274        );
275    }
276
277    #[test]
278    fn format_text_skips_incomplete_thought() {
279        assert_eq!(format_text(&AgentEvent::thought("id", "partial", false)), None);
280    }
281
282    #[test]
283    fn format_text_formats_tool_call() {
284        let msg = AgentEvent::Tool(ToolEvent::Call {
285            request: llm::ToolCallRequest {
286                id: "tc1".to_string(),
287                name: "bash".to_string(),
288                arguments: r#"{"cmd":"ls"}"#.to_string(),
289            },
290        });
291        assert_eq!(format_text(&msg), Some(r#"Tool call: bash({"cmd":"ls"})"#.to_string()));
292    }
293
294    #[test]
295    fn format_text_skips_tool_call_updates() {
296        let msg =
297            AgentEvent::Tool(ToolEvent::CallUpdate { tool_call_id: "tc1".to_string(), chunk: "partial".to_string() });
298        assert_eq!(format_text(&msg), None);
299    }
300
301    #[test]
302    fn format_text_formats_tool_result() {
303        assert_eq!(format_text(&tool_result_msg()), Some("Tool result [bash]: ok".to_string()));
304    }
305
306    #[test]
307    fn format_text_formats_tool_error() {
308        let msg = AgentEvent::Tool(ToolEvent::Error {
309            error: llm::ToolCallError {
310                id: "tc1".to_string(),
311                name: "bash".to_string(),
312                arguments: None,
313                error: "not found".to_string(),
314            },
315        });
316        assert_eq!(format_text(&msg), Some("Tool error [bash]: not found".to_string()));
317    }
318
319    #[test]
320    fn format_text_formats_failed_turn() {
321        let msg = AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Failed { error: "boom".to_string() } });
322        assert_eq!(format_text(&msg), Some("Error: boom".to_string()));
323    }
324
325    #[test]
326    fn format_text_formats_cancelled_turn() {
327        let msg = AgentEvent::turn_ended(TurnOutcome::Cancelled);
328        assert_eq!(format_text(&msg), Some("Cancelled".to_string()));
329    }
330
331    #[test]
332    fn format_text_formats_retry_schedule() {
333        let msg = retry_scheduled(1, 10);
334        assert_eq!(format_text(&msg), Some("Retrying (1/3) in 10ms".to_string()));
335    }
336
337    #[test]
338    fn format_text_formats_llm_call_failure_that_will_retry() {
339        let msg = AgentEvent::Turn(TurnEvent::LlmCallEnded {
340            purpose: aether_core::events::LlmCallPurpose::Chat,
341            outcome: LlmCallOutcome::Failed { error: "overloaded".to_string(), will_retry: true },
342        });
343        assert_eq!(format_text(&msg), Some("LLM call failed (will retry): overloaded".to_string()));
344    }
345
346    #[test]
347    fn format_text_skips_terminal_llm_call_failure() {
348        let msg = AgentEvent::Turn(TurnEvent::LlmCallEnded {
349            purpose: aether_core::events::LlmCallPurpose::Chat,
350            outcome: LlmCallOutcome::Failed { error: "boom".to_string(), will_retry: false },
351        });
352        assert_eq!(format_text(&msg), None);
353    }
354
355    #[test]
356    fn format_text_skips_first_call_start() {
357        assert_eq!(format_text(&llm_call_started(0)), None);
358    }
359
360    #[test]
361    fn format_text_formats_auto_continue() {
362        let msg = AgentEvent::Turn(TurnEvent::AutoContinue { attempt: 2, max_attempts: 5 });
363        assert_eq!(format_text(&msg), Some("Continuing (2/5)...".to_string()));
364    }
365
366    #[test]
367    fn format_text_formats_model_switched() {
368        let msg =
369            AgentEvent::Model(ModelEvent::Switched { previous: "old-model".to_string(), new: "new-model".to_string() });
370        assert_eq!(format_text(&msg), Some("Model switched: old-model -> new-model".to_string()));
371    }
372
373    #[test]
374    fn format_text_renders_completed_turn() {
375        assert_eq!(format_text(&AgentEvent::turn_ended(TurnOutcome::Completed)), Some("Done".to_string()));
376    }
377
378    #[test]
379    fn format_text_formats_tool_progress_with_total() {
380        let msg = tool_progress(50.0, Some(100.0), Some("halfway"));
381        assert_eq!(format_text(&msg), Some("Tool progress [bash]: 50/100 - halfway".to_string()));
382    }
383
384    #[test]
385    fn format_text_formats_tool_progress_without_total() {
386        let msg = tool_progress(42.0, None, None);
387        assert_eq!(format_text(&msg), Some("Tool progress [bash]: 42".to_string()));
388    }
389
390    #[test]
391    fn format_text_formats_context_compaction_started() {
392        let msg = AgentEvent::Context(ContextEvent::CompactionStarted { message_count: 42 });
393        assert_eq!(format_text(&msg), Some("Context compaction started (42 messages)".to_string()));
394    }
395
396    #[test]
397    fn format_text_formats_context_compaction_result() {
398        let msg = AgentEvent::Context(ContextEvent::CompactionResult {
399            summary: "summary here".to_string(),
400            messages_removed: 10,
401        });
402        assert_eq!(format_text(&msg), Some("Context compacted: 10 messages removed. summary here".to_string()));
403    }
404
405    #[test]
406    fn format_text_formats_context_usage_update() {
407        assert_eq!(
408            format_text(&usage_update()),
409            Some("Tokens: 1500 in, 250 out (total: 5000 in, 800 out)".to_string())
410        );
411    }
412
413    #[test]
414    fn format_text_formats_context_cleared() {
415        assert_eq!(format_text(&AgentEvent::Context(ContextEvent::Cleared)), Some("Context cleared".to_string()));
416    }
417
418    #[test]
419    fn event_kind_none_for_non_output_fragments() {
420        assert_eq!(event_kind(&AgentEvent::text("id", "x", false)), None);
421        assert_eq!(event_kind(&AgentEvent::thought("id", "x", false)), None);
422        assert_eq!(
423            event_kind(&AgentEvent::Tool(ToolEvent::CallUpdate {
424                tool_call_id: "tc1".to_string(),
425                chunk: "x".to_string(),
426            })),
427            None,
428        );
429    }
430
431    #[test]
432    fn event_kind_turn_ended_is_filterable() {
433        assert_eq!(event_kind(&AgentEvent::turn_ended(TurnOutcome::Completed)), Some(CliEventKind::TurnEnded));
434    }
435
436    #[test]
437    fn should_emit_empty_filter_rejects_non_output_events() {
438        assert!(should_emit(&tool_call_msg(), &[]));
439        assert!(should_emit(
440            &AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Failed { error: "e".to_string() } }),
441            &[]
442        ));
443        assert!(should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), &[]));
444        assert!(!should_emit(&AgentEvent::text("id", "x", false), &[]));
445        assert!(!should_emit(
446            &AgentEvent::Tool(ToolEvent::CallUpdate { tool_call_id: "tc1".to_string(), chunk: "x".to_string() }),
447            &[],
448        ));
449    }
450
451    #[test]
452    fn should_emit_single_type_whitelist() {
453        let filter = &[CliEventKind::ToolCall];
454        assert!(should_emit(&tool_call_msg(), filter));
455        assert!(!should_emit(&tool_result_msg(), filter));
456        assert!(!should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), filter));
457    }
458
459    #[test]
460    fn should_emit_multi_type_whitelist() {
461        let filter = &[CliEventKind::ToolCall, CliEventKind::ToolResult];
462        assert!(should_emit(&tool_call_msg(), filter));
463        assert!(should_emit(&tool_result_msg(), filter));
464        assert!(!should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), filter));
465    }
466
467    #[test]
468    fn should_emit_turn_ended_respects_filter() {
469        let msg = AgentEvent::turn_ended(TurnOutcome::Completed);
470        assert!(should_emit(&msg, &[CliEventKind::TurnEnded]));
471        assert!(!should_emit(&msg, &[CliEventKind::ToolCall]));
472    }
473
474    #[test]
475    fn event_kind_covers_every_cli_event_kind() {
476        use clap::ValueEnum;
477
478        let samples = vec![
479            (AgentEvent::text("id", "x", true), CliEventKind::Text),
480            (AgentEvent::thought("id", "x", true), CliEventKind::Thought),
481            (tool_call_msg(), CliEventKind::ToolCall),
482            (tool_result_msg(), CliEventKind::ToolResult),
483            (
484                AgentEvent::Tool(ToolEvent::Error {
485                    error: llm::ToolCallError {
486                        id: "tc1".to_string(),
487                        name: "bash".to_string(),
488                        arguments: None,
489                        error: "boom".to_string(),
490                    },
491                }),
492                CliEventKind::ToolError,
493            ),
494            (AgentEvent::Turn(TurnEvent::AutoContinue { attempt: 1, max_attempts: 3 }), CliEventKind::AutoContinue),
495            (
496                AgentEvent::Model(ModelEvent::Switched { previous: "a".to_string(), new: "b".to_string() }),
497                CliEventKind::ModelSwitched,
498            ),
499            (tool_progress(1.0, None, None), CliEventKind::ToolProgress),
500            (
501                AgentEvent::Context(ContextEvent::CompactionStarted { message_count: 1 }),
502                CliEventKind::ContextCompactionStarted,
503            ),
504            (
505                AgentEvent::Context(ContextEvent::CompactionEnded { outcome: CompactionOutcome::Completed }),
506                CliEventKind::ContextCompactionEnded,
507            ),
508            (
509                AgentEvent::Context(ContextEvent::CompactionResult { summary: "s".to_string(), messages_removed: 1 }),
510                CliEventKind::ContextCompactionResult,
511            ),
512            (usage_update(), CliEventKind::ContextUsage),
513            (AgentEvent::Context(ContextEvent::Cleared), CliEventKind::ContextCleared),
514            (AgentEvent::Turn(TurnEvent::Started { content: vec![] }), CliEventKind::TurnStarted),
515            (AgentEvent::turn_ended(TurnOutcome::Completed), CliEventKind::TurnEnded),
516            (retry_scheduled(1, 10), CliEventKind::LlmRetryScheduled),
517            (llm_call_started(0), CliEventKind::LlmCallStarted),
518            (
519                AgentEvent::Turn(TurnEvent::LlmCallEnded {
520                    purpose: aether_core::events::LlmCallPurpose::Chat,
521                    outcome: aether_core::events::LlmCallOutcome::Cancelled,
522                }),
523                CliEventKind::LlmCallEnded,
524            ),
525            (
526                AgentEvent::Tool(ToolEvent::ExecutionStarted {
527                    tool_id: "tc1".to_string(),
528                    tool_name: "bash".to_string(),
529                }),
530                CliEventKind::ToolExecutionStarted,
531            ),
532            (AgentEvent::Tool(ToolEvent::DefinitionsUpdated { tools: vec![] }), CliEventKind::ToolDefinitionsUpdated),
533        ];
534
535        for kind in CliEventKind::value_variants() {
536            assert!(samples.iter().any(|(_, k)| k == kind), "samples is missing a case for {kind:?}");
537        }
538
539        for (msg, kind) in &samples {
540            assert_eq!(event_kind(msg), Some(*kind), "event_kind disagrees for {kind:?}");
541        }
542    }
543
544    #[tokio::test]
545    async fn stream_output_turn_ended_breaks_loop_under_filter() {
546        let (tx, rx) = mpsc::channel(4);
547        tx.send(AgentEvent::turn_ended(TurnOutcome::Completed)).await.unwrap();
548        let filter = vec![CliEventKind::ToolCall];
549        let code = stream_output(rx, OutputFormat::Text, &filter).await;
550        assert_eq!(code, ExitCode::SUCCESS);
551    }
552
553    #[tokio::test]
554    async fn stream_output_failed_turn_exits_with_failure() {
555        let (tx, rx) = mpsc::channel(4);
556        tx.send(AgentEvent::turn_ended(TurnOutcome::Failed { error: "boom".to_string() })).await.unwrap();
557        let code = stream_output(rx, OutputFormat::Text, &[]).await;
558        assert_eq!(code, ExitCode::FAILURE);
559    }
560
561    fn tool_call_msg() -> AgentEvent {
562        AgentEvent::Tool(ToolEvent::Call {
563            request: llm::ToolCallRequest {
564                id: "tc1".to_string(),
565                name: "bash".to_string(),
566                arguments: "{}".to_string(),
567            },
568        })
569    }
570
571    fn tool_result_msg() -> AgentEvent {
572        AgentEvent::Tool(ToolEvent::Result {
573            result: llm::ToolCallResult {
574                id: "tc1".to_string(),
575                name: "bash".to_string(),
576                arguments: "{}".to_string(),
577                result: "ok".to_string(),
578            },
579            result_meta: None,
580        })
581    }
582
583    fn tool_progress(progress: f64, total: Option<f64>, message: Option<&str>) -> AgentEvent {
584        AgentEvent::Tool(ToolEvent::Progress {
585            request: llm::ToolCallRequest {
586                id: "tc1".to_string(),
587                name: "bash".to_string(),
588                arguments: "{}".to_string(),
589            },
590            progress,
591            total,
592            message: message.map(str::to_string),
593        })
594    }
595
596    fn retry_scheduled(attempt: u32, delay_ms: u64) -> AgentEvent {
597        AgentEvent::Turn(TurnEvent::RetryScheduled {
598            purpose: aether_core::events::LlmCallPurpose::Chat,
599            attempt,
600            max_attempts: 3,
601            delay_ms,
602        })
603    }
604
605    fn llm_call_started(attempt: u32) -> AgentEvent {
606        AgentEvent::Turn(TurnEvent::LlmCallStarted {
607            purpose: aether_core::events::LlmCallPurpose::Chat,
608            provider: None,
609            model: None,
610            display_name: "test".to_string(),
611            attempt,
612            max_attempts: 3,
613        })
614    }
615
616    fn usage_update() -> AgentEvent {
617        AgentEvent::Context(ContextEvent::UsageUpdated {
618            usage: ContextUsage {
619                input_tokens: 1500,
620                output_tokens: 250,
621                total_input_tokens: 5000,
622                total_output_tokens: 800,
623                ..Default::default()
624            },
625        })
626    }
627}