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}