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::McpHandle;
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_runtime.handle(), 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: &McpHandle, prompt: String) -> String {
67    let Some(slash_command) = parse_slash_command(&prompt) else {
68        return prompt;
69    };
70
71    match expand_slash_command(mcp, 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(
128            ToolEvent::Result { .. } | ToolEvent::TaskCreated { .. } | ToolEvent::TaskCompleted { .. },
129        ) => Some(CliEventKind::ToolResult),
130        AgentEvent::Tool(ToolEvent::Error { .. } | ToolEvent::TaskFailed { .. } | ToolEvent::TaskCancelled { .. }) => {
131            Some(CliEventKind::ToolError)
132        }
133        AgentEvent::Turn(TurnEvent::AutoContinue { .. }) => Some(CliEventKind::AutoContinue),
134        AgentEvent::Model(ModelEvent::Switched { .. }) => Some(CliEventKind::ModelSwitched),
135        AgentEvent::Tool(
136            ToolEvent::Progress { .. }
137            | ToolEvent::DisplayUpdate { .. }
138            | ToolEvent::SubAgentProgress { .. }
139            | ToolEvent::TaskStatus { .. },
140        ) => Some(CliEventKind::ToolProgress),
141        AgentEvent::Context(ContextEvent::CompactionStarted { .. }) => Some(CliEventKind::ContextCompactionStarted),
142        AgentEvent::Context(ContextEvent::CompactionEnded { .. }) => Some(CliEventKind::ContextCompactionEnded),
143        AgentEvent::Context(ContextEvent::CompactionResult { .. }) => Some(CliEventKind::ContextCompactionResult),
144        AgentEvent::Context(ContextEvent::UsageUpdated { .. }) => Some(CliEventKind::ContextUsage),
145        AgentEvent::SessionUsage(_) => Some(CliEventKind::SessionUsage),
146        AgentEvent::Context(ContextEvent::Cleared) => Some(CliEventKind::ContextCleared),
147        AgentEvent::Turn(TurnEvent::Started { .. }) => Some(CliEventKind::TurnStarted),
148        AgentEvent::Turn(TurnEvent::Ended { .. }) => Some(CliEventKind::TurnEnded),
149        AgentEvent::Turn(TurnEvent::RetryScheduled { .. }) => Some(CliEventKind::LlmRetryScheduled),
150        AgentEvent::Turn(TurnEvent::LlmCallStarted { .. }) => Some(CliEventKind::LlmCallStarted),
151        AgentEvent::Turn(TurnEvent::LlmCallEnded { .. }) => Some(CliEventKind::LlmCallEnded),
152        AgentEvent::Tool(ToolEvent::ExecutionStarted { .. }) => Some(CliEventKind::ToolExecutionStarted),
153        AgentEvent::Tool(ToolEvent::DefinitionsUpdated { .. }) => Some(CliEventKind::ToolDefinitionsUpdated),
154        AgentEvent::Message(
155            MessageEvent::Text { is_complete: false, .. } | MessageEvent::Thought { is_complete: false, .. },
156        )
157        | AgentEvent::Tool(ToolEvent::CallUpdate { .. }) => None,
158    }
159}
160
161fn format_text(msg: &AgentEvent) -> Option<String> {
162    match msg {
163        AgentEvent::Message(MessageEvent::Text { chunk, is_complete: true, .. }) => Some(chunk.clone()),
164
165        AgentEvent::Message(MessageEvent::Thought { chunk, is_complete: true, .. }) => {
166            Some(format!("Thought: {chunk}"))
167        }
168
169        AgentEvent::Tool(ToolEvent::Call { request, .. }) => {
170            Some(format!("Tool call: {}({})", request.name, request.arguments))
171        }
172
173        AgentEvent::Tool(ToolEvent::Result { result, .. }) => {
174            Some(format!("Tool result [{}]: {}", result.name, result.result))
175        }
176
177        AgentEvent::Tool(ToolEvent::Error { error, .. }) => {
178            Some(format!("Tool error [{}]: {}", error.name, error.error))
179        }
180
181        AgentEvent::Tool(ToolEvent::TaskStatus { request, task_id, status, status_message }) => Some(format!(
182            "Task status [{}]: {} {}{}",
183            request.name,
184            task_id,
185            status,
186            status_message.as_deref().map(|message| format!(" - {message}")).unwrap_or_default()
187        )),
188        AgentEvent::Tool(ToolEvent::TaskCreated { request, task_id, .. }) => {
189            Some(format!("Tool deferred [{}]: task {}", request.name, task_id))
190        }
191        AgentEvent::Tool(ToolEvent::TaskCompleted { request, task_id, result, .. }) => {
192            Some(format!("Background task completed [{}]: {}: {}", request.name, task_id, result.result))
193        }
194        AgentEvent::Tool(ToolEvent::TaskFailed { request, task_id, error, .. }) => {
195            Some(format!("Background task failed [{}]: {}: {}", request.name, task_id, error.error))
196        }
197        AgentEvent::Tool(ToolEvent::TaskCancelled { request, task_id, .. }) => {
198            Some(format!("Background task cancelled [{}]: {task_id}", request.name))
199        }
200        AgentEvent::Turn(TurnEvent::Ended { outcome }) => Some(match outcome {
201            TurnOutcome::Completed => "Done".to_string(),
202            TurnOutcome::Cancelled => "Cancelled".to_string(),
203            TurnOutcome::Failed { error } => format!("Error: {error}"),
204        }),
205
206        AgentEvent::Turn(TurnEvent::AutoContinue { attempt, max_attempts, .. }) => {
207            Some(format!("Continuing ({attempt}/{max_attempts})..."))
208        }
209
210        AgentEvent::Turn(event @ TurnEvent::RetryScheduled { .. }) => event
211            .retry_info()
212            .map(|retry| format!("Retrying ({}/{}) in {}ms", retry.attempt, retry.max_attempts, retry.delay_ms)),
213
214        AgentEvent::Turn(TurnEvent::LlmCallEnded {
215            outcome: LlmCallOutcome::Failed { error, will_retry: true, .. },
216            ..
217        }) => Some(format!("LLM call failed (will retry): {error}")),
218
219        AgentEvent::Model(ModelEvent::Switched { previous, new }) => {
220            Some(format!("Model switched: {previous} -> {new}"))
221        }
222
223        AgentEvent::Tool(ToolEvent::Progress { request, progress, total, message }) => {
224            let bar = match total {
225                Some(t) => format!("{progress}/{t}"),
226                None => format!("{progress}"),
227            };
228            let suffix = message.as_deref().map(|m| format!(" - {m}")).unwrap_or_default();
229            Some(format!("Tool progress [{}]: {bar}{suffix}", request.name))
230        }
231
232        AgentEvent::Tool(ToolEvent::DisplayUpdate { request, meta }) => {
233            Some(format!("Tool progress [{}]: {} - {}", request.name, meta.display.title, meta.display.value))
234        }
235
236        AgentEvent::Tool(ToolEvent::SubAgentProgress { payload, .. }) => match &payload.event {
237            AgentEvent::SessionUsage(_) => None,
238            event => {
239                format_text(event).map(|text| format!("Sub-agent {} [{}]: {text}", payload.agent_name, payload.task_id))
240            }
241        },
242
243        AgentEvent::Context(ContextEvent::CompactionStarted { message_count, .. }) => {
244            Some(format!("Context compaction started ({message_count} messages)"))
245        }
246
247        AgentEvent::Context(ContextEvent::CompactionEnded { outcome, .. }) => Some(match outcome {
248            CompactionOutcome::Completed => "Context compaction completed".to_string(),
249            CompactionOutcome::Failed { error } => format!("Context compaction failed: {error}"),
250            CompactionOutcome::Cancelled => "Context compaction cancelled".to_string(),
251        }),
252
253        AgentEvent::Context(ContextEvent::CompactionResult { summary, messages_removed, .. }) => {
254            Some(format!("Context compacted: {messages_removed} messages removed. {summary}"))
255        }
256
257        AgentEvent::Context(ContextEvent::UsageUpdated { usage }) => Some(format_context_usage(usage)),
258
259        AgentEvent::Context(ContextEvent::Cleared) => Some("Context cleared".to_string()),
260
261        AgentEvent::SessionUsage(usage) => Some(format_session_usage(usage)),
262
263        AgentEvent::Turn(
264            TurnEvent::Started { .. }
265            | TurnEvent::LlmCallStarted { .. }
266            | TurnEvent::LlmCallEnded {
267                outcome:
268                    LlmCallOutcome::Completed { .. }
269                    | LlmCallOutcome::Cancelled
270                    | LlmCallOutcome::Failed { will_retry: false, .. },
271                ..
272            },
273        )
274        | AgentEvent::Tool(
275            ToolEvent::ExecutionStarted { .. } | ToolEvent::DefinitionsUpdated { .. } | ToolEvent::CallUpdate { .. },
276        )
277        | AgentEvent::Message(MessageEvent::Text { .. } | MessageEvent::Thought { .. }) => None,
278    }
279}
280
281fn format_context_usage(usage: &llm::ContextUsage) -> String {
282    match (usage.context_limit, usage.usage_ratio) {
283        (Some(limit), Some(ratio)) => {
284            format!("Context: {} / {limit} tokens ({:.1}%)", usage.input_tokens, ratio * 100.0)
285        }
286        _ => format!("Context: {} tokens", usage.input_tokens),
287    }
288}
289
290fn format_session_usage(usage: &llm::SessionUsageEvent) -> String {
291    let call_cost =
292        usage.estimated_cost.map_or_else(|| "unknown".to_string(), |cost| format!("${:.6}", cost.total_usd));
293    let totals = &usage.totals;
294    let cumulative_cost = if totals.is_fully_priced() {
295        format!("estimated total: ${:.6}", totals.estimated_usd)
296    } else {
297        format!("known subtotal: ${:.6}, {} unpriced calls", totals.estimated_usd, totals.unpriced_calls)
298    };
299    format!(
300        "Session usage #{} [{}]: {} in, {} out (call cost: {}, cumulative: {} tokens, {})",
301        usage.sequence,
302        usage.source.agent_name,
303        usage.tokens.input_tokens,
304        usage.tokens.output_tokens,
305        call_cost,
306        totals.tokens.total_tokens(),
307        cumulative_cost,
308    )
309}
310
311fn setup_tracing(verbose: bool) {
312    use tracing_subscriber::Layer;
313    use tracing_subscriber::filter::EnvFilter;
314    use tracing_subscriber::fmt;
315    use tracing_subscriber::layer::SubscriberExt;
316    use tracing_subscriber::util::SubscriberInitExt;
317
318    let filter = if verbose { EnvFilter::new("debug,agent=off") } else { EnvFilter::new("error,agent=off") };
319    let layer = fmt::layer().with_writer(io::stderr).with_filter(filter);
320
321    tracing_subscriber::registry().with(layer).init();
322}
323
324#[cfg(test)]
325mod tests {
326    use aether_core::events::StreamState;
327
328    use super::*;
329    use llm::ContextUsage;
330
331    #[test]
332    fn format_text_formats_complete_text() {
333        assert_eq!(
334            format_text(&AgentEvent::text("id", "hello world", StreamState::Complete)),
335            Some("hello world".to_string())
336        );
337    }
338
339    #[test]
340    fn format_text_skips_incomplete_text() {
341        assert_eq!(format_text(&AgentEvent::text("id", "partial", StreamState::Partial)), None);
342    }
343
344    #[test]
345    fn format_text_formats_complete_thought() {
346        assert_eq!(
347            format_text(&AgentEvent::thought("id", "reasoning here", StreamState::Complete)),
348            Some("Thought: reasoning here".to_string())
349        );
350    }
351
352    #[test]
353    fn format_text_skips_incomplete_thought() {
354        assert_eq!(format_text(&AgentEvent::thought("id", "partial", StreamState::Partial)), None);
355    }
356
357    #[test]
358    fn format_text_formats_tool_call() {
359        let msg = AgentEvent::Tool(ToolEvent::Call {
360            request: llm::ToolCallRequest {
361                id: "tc1".to_string(),
362                name: "bash".to_string(),
363                arguments: r#"{"cmd":"ls"}"#.to_string(),
364            },
365        });
366        assert_eq!(format_text(&msg), Some(r#"Tool call: bash({"cmd":"ls"})"#.to_string()));
367    }
368
369    #[test]
370    fn format_text_skips_tool_call_updates() {
371        let msg =
372            AgentEvent::Tool(ToolEvent::CallUpdate { tool_call_id: "tc1".to_string(), chunk: "partial".to_string() });
373        assert_eq!(format_text(&msg), None);
374    }
375
376    #[test]
377    fn format_text_formats_tool_result() {
378        assert_eq!(format_text(&tool_result_msg()), Some("Tool result [bash]: ok".to_string()));
379    }
380
381    #[test]
382    fn format_text_formats_tool_error() {
383        let msg = AgentEvent::Tool(ToolEvent::Error {
384            error: llm::ToolCallError {
385                id: "tc1".to_string(),
386                name: "bash".to_string(),
387                arguments: None,
388                error: "not found".to_string(),
389            },
390        });
391        assert_eq!(format_text(&msg), Some("Tool error [bash]: not found".to_string()));
392    }
393
394    #[test]
395    fn format_text_formats_failed_turn() {
396        let msg = AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Failed { error: "boom".to_string() } });
397        assert_eq!(format_text(&msg), Some("Error: boom".to_string()));
398    }
399
400    #[test]
401    fn format_text_formats_cancelled_turn() {
402        let msg = AgentEvent::turn_ended(TurnOutcome::Cancelled);
403        assert_eq!(format_text(&msg), Some("Cancelled".to_string()));
404    }
405
406    #[test]
407    fn format_text_formats_retry_schedule() {
408        let msg = retry_scheduled(1, 10);
409        assert_eq!(format_text(&msg), Some("Retrying (1/3) in 10ms".to_string()));
410    }
411
412    #[test]
413    fn format_text_formats_llm_call_failure_that_will_retry() {
414        let msg = AgentEvent::Turn(TurnEvent::LlmCallEnded {
415            purpose: llm::LlmCallPurpose::Chat,
416            outcome: LlmCallOutcome::failed("overloaded", true),
417        });
418        assert_eq!(format_text(&msg), Some("LLM call failed (will retry): overloaded".to_string()));
419    }
420
421    #[test]
422    fn format_text_skips_terminal_llm_call_failure() {
423        let msg = AgentEvent::Turn(TurnEvent::LlmCallEnded {
424            purpose: llm::LlmCallPurpose::Chat,
425            outcome: LlmCallOutcome::failed("boom", false),
426        });
427        assert_eq!(format_text(&msg), None);
428    }
429
430    #[test]
431    fn format_text_skips_first_call_start() {
432        assert_eq!(format_text(&llm_call_started(0)), None);
433    }
434
435    #[test]
436    fn format_text_formats_auto_continue() {
437        let msg = AgentEvent::Turn(TurnEvent::AutoContinue {
438            attempt: 2,
439            max_attempts: 5,
440            message_id: llm::MessageId::new(),
441            content: vec![],
442        });
443        assert_eq!(format_text(&msg), Some("Continuing (2/5)...".to_string()));
444    }
445
446    #[test]
447    fn format_text_formats_model_switched() {
448        let msg =
449            AgentEvent::Model(ModelEvent::Switched { previous: "old-model".to_string(), new: "new-model".to_string() });
450        assert_eq!(format_text(&msg), Some("Model switched: old-model -> new-model".to_string()));
451    }
452
453    #[test]
454    fn format_text_renders_completed_turn() {
455        assert_eq!(format_text(&AgentEvent::turn_ended(TurnOutcome::Completed)), Some("Done".to_string()));
456    }
457
458    #[test]
459    fn format_text_formats_tool_progress_with_total() {
460        let msg = tool_progress(50.0, Some(100.0), Some("halfway"));
461        assert_eq!(format_text(&msg), Some("Tool progress [bash]: 50/100 - halfway".to_string()));
462    }
463
464    #[test]
465    fn format_text_formats_tool_progress_without_total() {
466        let msg = tool_progress(42.0, None, None);
467        assert_eq!(format_text(&msg), Some("Tool progress [bash]: 42".to_string()));
468    }
469
470    #[test]
471    fn format_text_formats_context_compaction_started() {
472        let msg = AgentEvent::Context(ContextEvent::CompactionStarted {
473            compaction_id: "compaction".into(),
474            message_count: 42,
475        });
476        assert_eq!(format_text(&msg), Some("Context compaction started (42 messages)".to_string()));
477    }
478
479    #[test]
480    fn format_text_formats_context_compaction_result() {
481        let msg = AgentEvent::Context(ContextEvent::CompactionResult {
482            compaction_id: "compaction".into(),
483            message_id: llm::MessageId::new(),
484            summary: "summary here".to_string(),
485            messages_removed: 10,
486        });
487        assert_eq!(format_text(&msg), Some("Context compacted: 10 messages removed. summary here".to_string()));
488    }
489
490    #[test]
491    fn format_text_formats_context_usage_update() {
492        assert_eq!(format_text(&usage_update()), Some("Context: 100000 / 200000 tokens (50.0%)".to_string()));
493    }
494
495    #[test]
496    fn format_text_formats_context_cleared() {
497        assert_eq!(format_text(&AgentEvent::Context(ContextEvent::Cleared)), Some("Context cleared".to_string()));
498    }
499
500    #[test]
501    fn event_kind_none_for_non_output_fragments() {
502        assert_eq!(event_kind(&AgentEvent::text("id", "x", StreamState::Partial)), None);
503        assert_eq!(event_kind(&AgentEvent::thought("id", "x", StreamState::Partial)), None);
504        assert_eq!(
505            event_kind(&AgentEvent::Tool(ToolEvent::CallUpdate {
506                tool_call_id: "tc1".to_string(),
507                chunk: "x".to_string(),
508            })),
509            None,
510        );
511    }
512
513    #[test]
514    fn event_kind_turn_ended_is_filterable() {
515        assert_eq!(event_kind(&AgentEvent::turn_ended(TurnOutcome::Completed)), Some(CliEventKind::TurnEnded));
516    }
517
518    #[test]
519    fn should_emit_empty_filter_rejects_non_output_events() {
520        assert!(should_emit(&tool_call_msg(), &[]));
521        assert!(should_emit(
522            &AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Failed { error: "e".to_string() } }),
523            &[]
524        ));
525        assert!(should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), &[]));
526        assert!(!should_emit(&AgentEvent::text("id", "x", StreamState::Partial), &[]));
527        assert!(!should_emit(
528            &AgentEvent::Tool(ToolEvent::CallUpdate { tool_call_id: "tc1".to_string(), chunk: "x".to_string() }),
529            &[],
530        ));
531    }
532
533    #[test]
534    fn should_emit_single_type_whitelist() {
535        let filter = &[CliEventKind::ToolCall];
536        assert!(should_emit(&tool_call_msg(), filter));
537        assert!(!should_emit(&tool_result_msg(), filter));
538        assert!(!should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), filter));
539    }
540
541    #[test]
542    fn should_emit_multi_type_whitelist() {
543        let filter = &[CliEventKind::ToolCall, CliEventKind::ToolResult];
544        assert!(should_emit(&tool_call_msg(), filter));
545        assert!(should_emit(&tool_result_msg(), filter));
546        assert!(!should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), filter));
547    }
548
549    #[test]
550    fn should_emit_turn_ended_respects_filter() {
551        let msg = AgentEvent::turn_ended(TurnOutcome::Completed);
552        assert!(should_emit(&msg, &[CliEventKind::TurnEnded]));
553        assert!(!should_emit(&msg, &[CliEventKind::ToolCall]));
554    }
555
556    #[test]
557    fn event_kind_covers_every_cli_event_kind() {
558        use clap::ValueEnum;
559
560        let samples = vec![
561            (AgentEvent::text("id", "x", StreamState::Complete), CliEventKind::Text),
562            (AgentEvent::thought("id", "x", StreamState::Complete), CliEventKind::Thought),
563            (tool_call_msg(), CliEventKind::ToolCall),
564            (tool_result_msg(), CliEventKind::ToolResult),
565            (
566                AgentEvent::Tool(ToolEvent::Error {
567                    error: llm::ToolCallError {
568                        id: "tc1".to_string(),
569                        name: "bash".to_string(),
570                        arguments: None,
571                        error: "boom".to_string(),
572                    },
573                }),
574                CliEventKind::ToolError,
575            ),
576            (
577                AgentEvent::Turn(TurnEvent::AutoContinue {
578                    attempt: 1,
579                    max_attempts: 3,
580                    message_id: llm::MessageId::new(),
581                    content: vec![],
582                }),
583                CliEventKind::AutoContinue,
584            ),
585            (
586                AgentEvent::Model(ModelEvent::Switched { previous: "a".to_string(), new: "b".to_string() }),
587                CliEventKind::ModelSwitched,
588            ),
589            (tool_progress(1.0, None, None), CliEventKind::ToolProgress),
590            (
591                AgentEvent::Context(ContextEvent::CompactionStarted {
592                    compaction_id: "compaction".into(),
593                    message_count: 1,
594                }),
595                CliEventKind::ContextCompactionStarted,
596            ),
597            (
598                AgentEvent::Context(ContextEvent::CompactionEnded {
599                    compaction_id: "compaction".into(),
600                    outcome: CompactionOutcome::Completed,
601                }),
602                CliEventKind::ContextCompactionEnded,
603            ),
604            (
605                AgentEvent::Context(ContextEvent::CompactionResult {
606                    compaction_id: "compaction".into(),
607                    message_id: llm::MessageId::new(),
608                    summary: "s".to_string(),
609                    messages_removed: 1,
610                }),
611                CliEventKind::ContextCompactionResult,
612            ),
613            (usage_update(), CliEventKind::ContextUsage),
614            (
615                AgentEvent::SessionUsage(llm::testing::session_usage_event(1, llm::TokenUsage::new(1, 1))),
616                CliEventKind::SessionUsage,
617            ),
618            (AgentEvent::Context(ContextEvent::Cleared), CliEventKind::ContextCleared),
619            (AgentEvent::Turn(TurnEvent::Started { content: vec![] }), CliEventKind::TurnStarted),
620            (AgentEvent::turn_ended(TurnOutcome::Completed), CliEventKind::TurnEnded),
621            (retry_scheduled(1, 10), CliEventKind::LlmRetryScheduled),
622            (llm_call_started(0), CliEventKind::LlmCallStarted),
623            (
624                AgentEvent::Turn(TurnEvent::LlmCallEnded {
625                    purpose: llm::LlmCallPurpose::Chat,
626                    outcome: aether_core::events::LlmCallOutcome::Cancelled,
627                }),
628                CliEventKind::LlmCallEnded,
629            ),
630            (
631                AgentEvent::Tool(ToolEvent::ExecutionStarted {
632                    tool_id: "tc1".to_string(),
633                    tool_name: "bash".to_string(),
634                }),
635                CliEventKind::ToolExecutionStarted,
636            ),
637            (AgentEvent::Tool(ToolEvent::DefinitionsUpdated { tools: vec![] }), CliEventKind::ToolDefinitionsUpdated),
638        ];
639
640        for kind in CliEventKind::value_variants() {
641            assert!(samples.iter().any(|(_, k)| k == kind), "samples is missing a case for {kind:?}");
642        }
643
644        for (msg, kind) in &samples {
645            assert_eq!(event_kind(msg), Some(*kind), "event_kind disagrees for {kind:?}");
646        }
647    }
648
649    #[tokio::test]
650    async fn stream_output_turn_ended_breaks_loop_under_filter() {
651        let (tx, rx) = mpsc::channel(4);
652        tx.send(AgentEvent::turn_ended(TurnOutcome::Completed)).await.unwrap();
653        let filter = vec![CliEventKind::ToolCall];
654        let code = stream_output(rx, OutputFormat::Text, &filter).await;
655        assert_eq!(code, ExitCode::SUCCESS);
656    }
657
658    #[tokio::test]
659    async fn stream_output_failed_turn_exits_with_failure() {
660        let (tx, rx) = mpsc::channel(4);
661        tx.send(AgentEvent::turn_ended(TurnOutcome::Failed { error: "boom".to_string() })).await.unwrap();
662        let code = stream_output(rx, OutputFormat::Text, &[]).await;
663        assert_eq!(code, ExitCode::FAILURE);
664    }
665
666    fn tool_call_msg() -> AgentEvent {
667        AgentEvent::Tool(ToolEvent::Call {
668            request: llm::ToolCallRequest {
669                id: "tc1".to_string(),
670                name: "bash".to_string(),
671                arguments: "{}".to_string(),
672            },
673        })
674    }
675
676    fn tool_result_msg() -> AgentEvent {
677        AgentEvent::Tool(ToolEvent::Result {
678            result: llm::ToolCallResult {
679                id: "tc1".to_string(),
680                name: "bash".to_string(),
681                arguments: "{}".to_string(),
682                result: "ok".to_string(),
683            },
684            result_meta: None,
685        })
686    }
687
688    fn tool_progress(progress: f64, total: Option<f64>, message: Option<&str>) -> AgentEvent {
689        AgentEvent::Tool(ToolEvent::Progress {
690            request: llm::ToolCallRequest {
691                id: "tc1".to_string(),
692                name: "bash".to_string(),
693                arguments: "{}".to_string(),
694            },
695            progress,
696            total,
697            message: message.map(str::to_string),
698        })
699    }
700
701    fn retry_scheduled(attempt: u32, delay_ms: u64) -> AgentEvent {
702        AgentEvent::Turn(TurnEvent::RetryScheduled {
703            purpose: llm::LlmCallPurpose::Chat,
704            attempt,
705            max_attempts: 3,
706            delay_ms,
707        })
708    }
709
710    fn llm_call_started(attempt: u32) -> AgentEvent {
711        AgentEvent::Turn(TurnEvent::LlmCallStarted {
712            purpose: llm::LlmCallPurpose::Chat,
713            model: llm::ModelIdentity::default(),
714            display_name: "test".to_string(),
715            attempt,
716            max_attempts: 3,
717        })
718    }
719
720    fn usage_update() -> AgentEvent {
721        AgentEvent::Context(ContextEvent::UsageUpdated {
722            usage: ContextUsage {
723                input_tokens: 100_000.into(),
724                context_limit: Some(200_000.into()),
725                usage_ratio: Some(0.5),
726            },
727        })
728    }
729}