Skip to main content

aether_cli/headless/
run.rs

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