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}