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