1use aether_core::core::Prompt;
2use aether_core::events::{AgentMessage, Command};
3use std::io;
4use std::process::ExitCode;
5use tokio::sync::mpsc;
6
7use super::error::CliError;
8use super::{CliEventKind, RunConfig};
9use crate::output::OutputFormat;
10use crate::runtime::RuntimeBuilder;
11
12pub async fn run(config: RunConfig) -> Result<ExitCode, CliError> {
13 setup_tracing(config.verbose);
14
15 let mut spec = config.spec;
16 if let Some(system_prompt) = config.system_prompt {
17 spec.prompts.push(Prompt::text(&system_prompt));
18 }
19
20 let (agent, _mcp_snapshot) = RuntimeBuilder::from_spec(config.cwd.clone(), spec)
21 .mcp_sources(config.mcp_config_sources)
22 .oauth_credential_store(config.oauth_credential_store)
23 .build_ready(vec![])
24 .await?;
25
26 agent
27 .agent_tx
28 .send(Command::text(&config.prompt))
29 .await
30 .map_err(|e| CliError::AgentError(format!("Failed to send prompt: {e}")))?;
31
32 Ok(stream_output(agent.agent_rx, config.output, &config.events).await)
33}
34
35async fn stream_output(
36 mut rx: mpsc::Receiver<AgentMessage>,
37 format: OutputFormat,
38 events: &[CliEventKind],
39) -> ExitCode {
40 while let Some(msg) = rx.recv().await {
41 if should_emit(&msg, events)
42 && let Err(error) = print_message(format, &msg)
43 {
44 eprintln!("Failed to serialize headless event: {error}");
45 return ExitCode::FAILURE;
46 }
47
48 if matches!(msg, AgentMessage::Done) {
49 break;
50 }
51 }
52 ExitCode::SUCCESS
53}
54
55fn print_message(format: OutputFormat, msg: &AgentMessage) -> Result<(), serde_json::Error> {
56 match format {
57 OutputFormat::Text => {
58 if let Some(text) = format_text(msg) {
59 if matches!(msg, AgentMessage::Error { .. }) {
60 eprintln!("{text}");
61 } else {
62 println!("{text}");
63 }
64 }
65 }
66 OutputFormat::Pretty => println!("{}", serde_json::to_string_pretty(msg)?),
67 OutputFormat::Json => println!("{}", serde_json::to_string(msg)?),
68 }
69
70 Ok(())
71}
72
73fn should_emit(msg: &AgentMessage, include: &[CliEventKind]) -> bool {
74 let Some(kind) = event_kind(msg) else { return false };
75 include.is_empty() || include.contains(&kind)
76}
77
78fn event_kind(msg: &AgentMessage) -> Option<CliEventKind> {
79 match msg {
80 AgentMessage::Text { is_complete: true, .. } => Some(CliEventKind::Text),
81 AgentMessage::Thought { is_complete: true, .. } => Some(CliEventKind::Thought),
82 AgentMessage::ToolCall { .. } => Some(CliEventKind::ToolCall),
83 AgentMessage::ToolResult { .. } => Some(CliEventKind::ToolResult),
84 AgentMessage::ToolError { .. } => Some(CliEventKind::ToolError),
85 AgentMessage::Error { .. } => Some(CliEventKind::Error),
86 AgentMessage::Cancelled { .. } => Some(CliEventKind::Cancelled),
87 AgentMessage::AutoContinue { .. } => Some(CliEventKind::AutoContinue),
88 AgentMessage::Retrying { .. } => Some(CliEventKind::Retrying),
89 AgentMessage::ModelSwitched { .. } => Some(CliEventKind::ModelSwitched),
90 AgentMessage::ToolProgress { .. } => Some(CliEventKind::ToolProgress),
91 AgentMessage::ContextCompactionStarted { .. } => Some(CliEventKind::ContextCompactionStarted),
92 AgentMessage::ContextCompactionResult { .. } => Some(CliEventKind::ContextCompactionResult),
93 AgentMessage::ContextUsageUpdate { .. } => Some(CliEventKind::ContextUsage),
94 AgentMessage::ContextCleared => Some(CliEventKind::ContextCleared),
95 AgentMessage::Done => Some(CliEventKind::Done),
96 AgentMessage::Text { is_complete: false, .. }
97 | AgentMessage::Thought { is_complete: false, .. }
98 | AgentMessage::ToolCallUpdate { .. } => None,
99 }
100}
101
102fn format_text(msg: &AgentMessage) -> Option<String> {
103 match msg {
104 AgentMessage::Text { chunk, is_complete: true, .. } => Some(chunk.clone()),
105
106 AgentMessage::Thought { chunk, is_complete: true, .. } => Some(format!("Thought: {chunk}")),
107
108 AgentMessage::ToolCall { request, .. } => Some(format!("Tool call: {}({})", request.name, request.arguments)),
109
110 AgentMessage::ToolResult { result, .. } => Some(format!("Tool result [{}]: {}", result.name, result.result)),
111
112 AgentMessage::ToolError { error, .. } => Some(format!("Tool error [{}]: {}", error.name, error.error)),
113
114 AgentMessage::Error { message } => Some(format!("Error: {message}")),
115
116 AgentMessage::Cancelled { message } => Some(format!("Cancelled: {message}")),
117
118 AgentMessage::AutoContinue { attempt, max_attempts } => {
119 Some(format!("Continuing ({attempt}/{max_attempts})..."))
120 }
121
122 AgentMessage::Retrying { attempt, max_attempts, delay_ms, error } => {
123 Some(format!("Retrying ({attempt}/{max_attempts}) in {delay_ms}ms: {error}"))
124 }
125
126 AgentMessage::ModelSwitched { previous, new } => Some(format!("Model switched: {previous} -> {new}")),
127
128 AgentMessage::ToolProgress { request, progress, total, message } => {
129 let bar = match total {
130 Some(t) => format!("{progress}/{t}"),
131 None => format!("{progress}"),
132 };
133 let suffix = message.as_deref().map(|m| format!(" - {m}")).unwrap_or_default();
134 Some(format!("Tool progress [{}]: {bar}{suffix}", request.name))
135 }
136
137 AgentMessage::ContextCompactionStarted { message_count } => {
138 Some(format!("Context compaction started ({message_count} messages)"))
139 }
140
141 AgentMessage::ContextCompactionResult { summary, messages_removed } => {
142 Some(format!("Context compacted: {messages_removed} messages removed. {summary}"))
143 }
144
145 AgentMessage::ContextUsageUpdate { usage } => Some(format!(
146 "Tokens: {} in, {} out (total: {} in, {} out)",
147 usage.input_tokens, usage.output_tokens, usage.total_input_tokens, usage.total_output_tokens
148 )),
149
150 AgentMessage::ContextCleared => Some("Context cleared".to_string()),
151
152 AgentMessage::Done => Some("Done".to_string()),
153
154 AgentMessage::ToolCallUpdate { .. } | AgentMessage::Text { .. } | AgentMessage::Thought { .. } => None,
155 }
156}
157
158fn setup_tracing(verbose: bool) {
159 use tracing_subscriber::Layer;
160 use tracing_subscriber::filter::EnvFilter;
161 use tracing_subscriber::fmt;
162 use tracing_subscriber::layer::SubscriberExt;
163 use tracing_subscriber::util::SubscriberInitExt;
164
165 let filter = if verbose { EnvFilter::new("debug,agent=off") } else { EnvFilter::new("error,agent=off") };
166 let layer = fmt::layer().with_writer(io::stderr).with_filter(filter);
167
168 tracing_subscriber::registry().with(layer).init();
169}
170
171#[cfg(test)]
172mod tests {
173 use aether_core::events::ContextUsage;
174
175 use super::*;
176
177 #[test]
178 fn format_text_formats_complete_text() {
179 assert_eq!(format_text(&AgentMessage::text("id", "hello world", true, "m")), Some("hello world".to_string()));
180 }
181
182 #[test]
183 fn format_text_skips_incomplete_text() {
184 assert_eq!(format_text(&AgentMessage::text("id", "partial", false, "m")), None);
185 }
186
187 #[test]
188 fn format_text_formats_complete_thought() {
189 assert_eq!(
190 format_text(&AgentMessage::thought("id", "reasoning here", true, "m")),
191 Some("Thought: reasoning here".to_string())
192 );
193 }
194
195 #[test]
196 fn format_text_skips_incomplete_thought() {
197 assert_eq!(format_text(&AgentMessage::thought("id", "partial", false, "m")), None);
198 }
199
200 #[test]
201 fn format_text_formats_tool_call() {
202 let msg = AgentMessage::ToolCall {
203 request: llm::ToolCallRequest {
204 id: "tc1".to_string(),
205 name: "bash".to_string(),
206 arguments: r#"{"cmd":"ls"}"#.to_string(),
207 },
208 model_name: "test".to_string(),
209 };
210 assert_eq!(format_text(&msg), Some(r#"Tool call: bash({"cmd":"ls"})"#.to_string()));
211 }
212
213 #[test]
214 fn format_text_skips_tool_call_updates() {
215 let msg = AgentMessage::ToolCallUpdate {
216 tool_call_id: "tc1".to_string(),
217 chunk: "partial".to_string(),
218 model_name: "test".to_string(),
219 };
220 assert_eq!(format_text(&msg), None);
221 }
222
223 #[test]
224 fn format_text_formats_tool_result() {
225 assert_eq!(format_text(&tool_result_msg()), Some("Tool result [bash]: ok".to_string()));
226 }
227
228 #[test]
229 fn format_text_formats_tool_error() {
230 let msg = AgentMessage::ToolError {
231 error: llm::ToolCallError {
232 id: "tc1".to_string(),
233 name: "bash".to_string(),
234 arguments: None,
235 error: "not found".to_string(),
236 },
237 model_name: "test".to_string(),
238 };
239 assert_eq!(format_text(&msg), Some("Tool error [bash]: not found".to_string()));
240 }
241
242 #[test]
243 fn format_text_formats_error() {
244 let msg = AgentMessage::Error { message: "boom".to_string() };
245 assert_eq!(format_text(&msg), Some("Error: boom".to_string()));
246 }
247
248 #[test]
249 fn format_text_formats_cancelled() {
250 let msg = AgentMessage::Cancelled { message: "user stopped".to_string() };
251 assert_eq!(format_text(&msg), Some("Cancelled: user stopped".to_string()));
252 }
253
254 #[test]
255 fn format_text_formats_auto_continue() {
256 let msg = AgentMessage::AutoContinue { attempt: 2, max_attempts: 5 };
257 assert_eq!(format_text(&msg), Some("Continuing (2/5)...".to_string()));
258 }
259
260 #[test]
261 fn format_text_formats_model_switched() {
262 let msg = AgentMessage::ModelSwitched { previous: "old-model".to_string(), new: "new-model".to_string() };
263 assert_eq!(format_text(&msg), Some("Model switched: old-model -> new-model".to_string()));
264 }
265
266 #[test]
267 fn format_text_renders_done() {
268 assert_eq!(format_text(&AgentMessage::Done), Some("Done".to_string()));
269 }
270
271 #[test]
272 fn format_text_formats_tool_progress_with_total() {
273 let msg = tool_progress(50.0, Some(100.0), Some("halfway"));
274 assert_eq!(format_text(&msg), Some("Tool progress [bash]: 50/100 - halfway".to_string()));
275 }
276
277 #[test]
278 fn format_text_formats_tool_progress_without_total() {
279 let msg = tool_progress(42.0, None, None);
280 assert_eq!(format_text(&msg), Some("Tool progress [bash]: 42".to_string()));
281 }
282
283 #[test]
284 fn format_text_formats_context_compaction_started() {
285 let msg = AgentMessage::ContextCompactionStarted { message_count: 42 };
286 assert_eq!(format_text(&msg), Some("Context compaction started (42 messages)".to_string()));
287 }
288
289 #[test]
290 fn format_text_formats_context_compaction_result() {
291 let msg = AgentMessage::ContextCompactionResult { summary: "summary here".to_string(), messages_removed: 10 };
292 assert_eq!(format_text(&msg), Some("Context compacted: 10 messages removed. summary here".to_string()));
293 }
294
295 #[test]
296 fn format_text_formats_context_usage_update() {
297 assert_eq!(
298 format_text(&usage_update()),
299 Some("Tokens: 1500 in, 250 out (total: 5000 in, 800 out)".to_string())
300 );
301 }
302
303 #[test]
304 fn format_text_formats_context_cleared() {
305 assert_eq!(format_text(&AgentMessage::ContextCleared), Some("Context cleared".to_string()));
306 }
307
308 #[test]
309 fn event_kind_none_for_non_output_fragments() {
310 assert_eq!(event_kind(&AgentMessage::text("id", "x", false, "m")), None);
311 assert_eq!(event_kind(&AgentMessage::thought("id", "x", false, "m")), None);
312 assert_eq!(
313 event_kind(&AgentMessage::ToolCallUpdate {
314 tool_call_id: "tc1".to_string(),
315 chunk: "x".to_string(),
316 model_name: "m".to_string(),
317 }),
318 None,
319 );
320 }
321
322 #[test]
323 fn event_kind_done_is_filterable() {
324 assert_eq!(event_kind(&AgentMessage::Done), Some(CliEventKind::Done));
325 }
326
327 #[test]
328 fn should_emit_empty_filter_rejects_non_output_events() {
329 assert!(should_emit(&tool_call_msg(), &[]));
330 assert!(should_emit(&AgentMessage::Error { message: "e".to_string() }, &[]));
331 assert!(should_emit(&AgentMessage::Done, &[]));
332 assert!(!should_emit(&AgentMessage::text("id", "x", false, "m"), &[]));
333 assert!(!should_emit(
334 &AgentMessage::ToolCallUpdate {
335 tool_call_id: "tc1".to_string(),
336 chunk: "x".to_string(),
337 model_name: "m".to_string(),
338 },
339 &[],
340 ));
341 }
342
343 #[test]
344 fn should_emit_single_type_whitelist() {
345 let filter = &[CliEventKind::ToolCall];
346 assert!(should_emit(&tool_call_msg(), filter));
347 assert!(!should_emit(&tool_result_msg(), filter));
348 assert!(!should_emit(&AgentMessage::Error { message: "e".to_string() }, filter));
349 }
350
351 #[test]
352 fn should_emit_multi_type_whitelist() {
353 let filter = &[CliEventKind::ToolCall, CliEventKind::ToolResult];
354 assert!(should_emit(&tool_call_msg(), filter));
355 assert!(should_emit(&tool_result_msg(), filter));
356 assert!(!should_emit(&AgentMessage::Error { message: "e".to_string() }, filter));
357 }
358
359 #[test]
360 fn should_emit_done_respects_filter() {
361 assert!(should_emit(&AgentMessage::Done, &[CliEventKind::Done]));
362 assert!(!should_emit(&AgentMessage::Done, &[CliEventKind::ToolCall]));
363 }
364
365 #[test]
366 fn cli_event_kind_names_match_agent_message_type_tags() {
367 use clap::ValueEnum;
368
369 let samples = vec![
370 (AgentMessage::text("id", "x", true, "m"), CliEventKind::Text),
371 (AgentMessage::thought("id", "x", true, "m"), CliEventKind::Thought),
372 (tool_call_msg(), CliEventKind::ToolCall),
373 (tool_result_msg(), CliEventKind::ToolResult),
374 (
375 AgentMessage::ToolError {
376 error: llm::ToolCallError {
377 id: "tc1".to_string(),
378 name: "bash".to_string(),
379 arguments: None,
380 error: "boom".to_string(),
381 },
382 model_name: "test".to_string(),
383 },
384 CliEventKind::ToolError,
385 ),
386 (AgentMessage::Error { message: "e".to_string() }, CliEventKind::Error),
387 (AgentMessage::Cancelled { message: "c".to_string() }, CliEventKind::Cancelled),
388 (AgentMessage::AutoContinue { attempt: 1, max_attempts: 3 }, CliEventKind::AutoContinue),
389 (
390 AgentMessage::Retrying { attempt: 1, max_attempts: 3, delay_ms: 10, error: "e".to_string() },
391 CliEventKind::Retrying,
392 ),
393 (
394 AgentMessage::ModelSwitched { previous: "a".to_string(), new: "b".to_string() },
395 CliEventKind::ModelSwitched,
396 ),
397 (tool_progress(1.0, None, None), CliEventKind::ToolProgress),
398 (AgentMessage::ContextCompactionStarted { message_count: 1 }, CliEventKind::ContextCompactionStarted),
399 (
400 AgentMessage::ContextCompactionResult { summary: "s".to_string(), messages_removed: 1 },
401 CliEventKind::ContextCompactionResult,
402 ),
403 (usage_update(), CliEventKind::ContextUsage),
404 (AgentMessage::ContextCleared, CliEventKind::ContextCleared),
405 (AgentMessage::Done, CliEventKind::Done),
406 ];
407
408 for kind in CliEventKind::value_variants() {
409 assert!(samples.iter().any(|(_, k)| k == kind), "samples is missing a case for {kind:?}");
410 }
411
412 for (msg, kind) in &samples {
413 assert_eq!(event_kind(msg), Some(*kind), "event_kind disagrees for {kind:?}");
414 let tag = serde_json::to_value(msg).unwrap();
415 let tag = tag["type"].as_str().unwrap().to_string();
416 let clap_name = kind.to_possible_value().unwrap();
417 assert_eq!(tag, clap_name.get_name(), "`--events` value and serialized `type` tag diverged for {kind:?}");
418 }
419 }
420
421 #[tokio::test]
422 async fn stream_output_done_breaks_loop_under_filter() {
423 let (tx, rx) = mpsc::channel(4);
424 tx.send(AgentMessage::Done).await.unwrap();
425 let filter = vec![CliEventKind::ToolCall];
426 let code = stream_output(rx, OutputFormat::Text, &filter).await;
427 assert_eq!(code, ExitCode::SUCCESS);
428 }
429
430 fn tool_call_msg() -> AgentMessage {
431 AgentMessage::ToolCall {
432 request: llm::ToolCallRequest {
433 id: "tc1".to_string(),
434 name: "bash".to_string(),
435 arguments: "{}".to_string(),
436 },
437 model_name: "test".to_string(),
438 }
439 }
440
441 fn tool_result_msg() -> AgentMessage {
442 AgentMessage::ToolResult {
443 result: llm::ToolCallResult {
444 id: "tc1".to_string(),
445 name: "bash".to_string(),
446 arguments: "{}".to_string(),
447 result: "ok".to_string(),
448 },
449 result_meta: None,
450 model_name: "test".to_string(),
451 }
452 }
453
454 fn tool_progress(progress: f64, total: Option<f64>, message: Option<&str>) -> AgentMessage {
455 AgentMessage::ToolProgress {
456 request: llm::ToolCallRequest {
457 id: "tc1".to_string(),
458 name: "bash".to_string(),
459 arguments: "{}".to_string(),
460 },
461 progress,
462 total,
463 message: message.map(str::to_string),
464 }
465 }
466
467 fn usage_update() -> AgentMessage {
468 AgentMessage::ContextUsageUpdate {
469 usage: ContextUsage {
470 input_tokens: 1500,
471 output_tokens: 250,
472 total_input_tokens: 5000,
473 total_output_tokens: 800,
474 ..Default::default()
475 },
476 }
477 }
478}