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