Skip to main content

apollo/
agent_http.rs

1use std::net::SocketAddr;
2use std::sync::Arc;
3
4use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
5use axum::extract::State;
6use axum::http::StatusCode;
7use axum::response::IntoResponse;
8use axum::routing::{get, post};
9use axum::{Json, Router};
10use serde::{Deserialize, Serialize};
11use tokio::sync::mpsc;
12
13use crate::agent::stream::AgentStreamEvent;
14use crate::agent::AgentRunner;
15use crate::channels::http_inject::HttpInjectChannel;
16use crate::channels::IncomingMessage;
17
18#[derive(Debug, Deserialize)]
19pub struct ChatRequestBody {
20    pub message: String,
21    #[serde(default = "default_chat_id")]
22    pub chat_id: String,
23}
24
25fn default_chat_id() -> String {
26    "embed".into()
27}
28
29#[derive(Debug, Serialize)]
30pub struct ChatResponseBody {
31    pub response: String,
32}
33
34/// Everything a client needs to render a status bar.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct StateBody {
37    pub model: String,
38    pub provider: String,
39    pub engine: String,
40    pub mode: String,
41    pub cost_usd: f64,
42    pub total_tokens: usize,
43    pub call_count: usize,
44    /// Input tokens of the most recent model call — what the agent actually
45    /// sent as context on its last turn.
46    pub context_tokens: usize,
47    /// apollo's own compaction budget (`agent.max_context_chars`) expressed in
48    /// tokens at four characters per token. It is the threshold the status bar
49    /// should measure against, not the provider's hard window.
50    pub context_window: usize,
51    pub context_pct: u8,
52    /// Stored messages for the chat this state was asked about — `chat_id` on
53    /// the query string, or the default chat when it is absent.
54    pub message_count: usize,
55}
56
57#[derive(Debug, Deserialize)]
58pub struct ModelRequestBody {
59    pub model: String,
60}
61
62#[derive(Debug, Deserialize)]
63pub struct ChatIdRequestBody {
64    #[serde(default = "default_chat_id")]
65    pub chat_id: String,
66}
67
68#[derive(Debug, Deserialize)]
69pub struct ChatIdQuery {
70    #[serde(default = "default_chat_id")]
71    pub chat_id: String,
72}
73
74fn mode_name(mode: &crate::agent::mode::AgentMode) -> &'static str {
75    use crate::agent::mode::AgentMode;
76    match mode {
77        AgentMode::Auto => "auto",
78        AgentMode::BypassPermissions => "bypass",
79        AgentMode::Coding { .. } => "coding",
80        AgentMode::Swarm { .. } => "swarm",
81    }
82}
83
84fn context_percent(used: usize, window: usize) -> u8 {
85    if window == 0 {
86        return 0;
87    }
88    ((used * 100) / window).min(100) as u8
89}
90
91/// How many stored messages to count before giving up.
92///
93/// A count is a status-bar decoration; reading an unbounded conversation to
94/// produce it is not worth the query.
95const MESSAGE_COUNT_CAP: usize = 10_000;
96
97pub async fn build_state(runner: &AgentRunner, chat_id: &str) -> StateBody {
98    let summary = runner.get_cost_summary().await;
99    let message_count = runner
100        .memory()
101        .get_conversation_history(chat_id, MESSAGE_COUNT_CAP)
102        .await
103        .map(|history| history.len())
104        .unwrap_or(0);
105    let context_tokens = runner
106        .cost_tracker()
107        .history(1)
108        .await
109        .last()
110        .map(|record| record.input_tokens)
111        .unwrap_or(0);
112    let context_window = runner.agent_config.max_context_chars / 4;
113    StateBody {
114        model: runner.get_model(),
115        provider: runner.provider_name().to_string(),
116        engine: "rx4".into(),
117        mode: mode_name(&runner.get_mode()).into(),
118        cost_usd: summary.total_cost,
119        total_tokens: summary.total_tokens,
120        call_count: summary.call_count,
121        context_tokens,
122        context_window,
123        context_pct: context_percent(context_tokens, context_window),
124        message_count,
125    }
126}
127
128pub async fn chat_once(
129    runner: &AgentRunner,
130    message: &str,
131    chat_id: &str,
132) -> anyhow::Result<String> {
133    let msg = IncomingMessage {
134        id: uuid::Uuid::new_v4().to_string(),
135        sender_id: "http".into(),
136        sender_name: Some("HTTP".into()),
137        chat_id: chat_id.to_string(),
138        text: message.to_string(),
139        is_group: false,
140        reply_to: None,
141        timestamp: chrono::Utc::now(),
142    };
143    let channel = HttpInjectChannel::new();
144    runner.handle_message(&msg, &channel).await
145}
146
147pub fn http_listen_addr() -> SocketAddr {
148    let port = std::env::var("APOLLO_HTTP_PORT")
149        .ok()
150        .and_then(|p| p.parse().ok())
151        .unwrap_or(31338);
152    SocketAddr::from(([127, 0, 0, 1], port))
153}
154
155/// Reject anything that arrives with an `Origin` header.
156///
157/// This endpoint drives the agent, which has shell, edit and file tools over
158/// the workspace. Loopback is not a boundary against a browser: any page the
159/// user visits can reach 127.0.0.1, and WebSocket upgrades ignore the
160/// same-origin policy entirely, so CORS alone would not cover
161/// `/v1/chat/stream`.
162///
163/// This runs alongside the bearer token in `check_auth`, not instead of it.
164/// The token is the actual authentication; refusing `Origin` additionally
165/// means a page that somehow learned the token still cannot use it from a
166/// browser.
167///
168/// Native clients (the TUI, apollo-ui, curl) never send `Origin`; browsers
169/// always do and cannot forge its absence. Refusing the header therefore blocks
170/// web-page-driven access on both the POST and the WebSocket path without
171/// affecting any legitimate caller.
172fn reject_browser_origin(headers: &axum::http::HeaderMap) -> Result<(), StatusCode> {
173    if headers.contains_key(axum::http::header::ORIGIN) {
174        tracing::warn!("rejected apollo HTTP request carrying an Origin header");
175        return Err(StatusCode::FORBIDDEN);
176    }
177    Ok(())
178}
179
180static HTTP_TOKEN: std::sync::OnceLock<String> = std::sync::OnceLock::new();
181
182/// Where the shared bearer token lives.
183///
184/// Per-user rather than per-workspace, because the listen port is per-user
185/// too: one running agent serves whatever workspace it was started in, and a
186/// client has no way to know which that was.
187pub fn token_path() -> Option<std::path::PathBuf> {
188    let home = std::env::var_os("HOME").filter(|h| !h.is_empty())?;
189    Some(std::path::PathBuf::from(home).join(".apollo/http-token"))
190}
191
192/// Load the bearer token, generating and persisting one on first run.
193///
194/// `APOLLO_HTTP_TOKEN` wins when set, so a supervisor can inject the same
195/// value into the server and its clients without touching disk.
196pub fn load_or_create_token() -> anyhow::Result<String> {
197    if let Ok(token) = std::env::var("APOLLO_HTTP_TOKEN") {
198        if !token.trim().is_empty() {
199            return Ok(token.trim().to_string());
200        }
201    }
202
203    let path = token_path().ok_or_else(|| anyhow::anyhow!("cannot locate HOME for token file"))?;
204    if let Ok(existing) = std::fs::read_to_string(&path) {
205        if !existing.trim().is_empty() {
206            return Ok(existing.trim().to_string());
207        }
208    }
209
210    let token = format!(
211        "{}{}",
212        uuid::Uuid::new_v4().simple(),
213        uuid::Uuid::new_v4().simple()
214    );
215    if let Some(parent) = path.parent() {
216        std::fs::create_dir_all(parent)?;
217    }
218    // Readable only by the owner — anyone who can read it can drive the agent.
219    crate::fs_secure::write_secret_file(&path, &token)?;
220    tracing::info!("wrote a new apollo HTTP token to {}", path.display());
221    Ok(token)
222}
223
224/// Compare in constant time, so a caller cannot learn the token byte by byte
225/// from how long a rejection takes.
226fn secret_eq(a: &str, b: &str) -> bool {
227    let (a, b) = (a.as_bytes(), b.as_bytes());
228    if a.len() != b.len() {
229        return false;
230    }
231    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
232}
233
234fn check_auth(headers: &axum::http::HeaderMap) -> Result<(), StatusCode> {
235    let Some(expected) = HTTP_TOKEN.get() else {
236        // No token was established, so the server cannot authenticate anyone.
237        // Refuse rather than serving the agent openly.
238        tracing::error!("apollo HTTP has no token configured; refusing request");
239        return Err(StatusCode::SERVICE_UNAVAILABLE);
240    };
241    verify_token(expected, headers)
242}
243
244fn verify_token(expected: &str, headers: &axum::http::HeaderMap) -> Result<(), StatusCode> {
245    let presented = headers
246        .get(axum::http::header::AUTHORIZATION)
247        .and_then(|v| v.to_str().ok())
248        .and_then(|v| v.strip_prefix("Bearer "))
249        .unwrap_or_default();
250
251    if secret_eq(presented.trim(), expected) {
252        Ok(())
253    } else {
254        tracing::warn!("rejected apollo HTTP request with a missing or invalid token");
255        Err(StatusCode::UNAUTHORIZED)
256    }
257}
258
259/// Both gates every agent-driving route must pass.
260fn authorize(headers: &axum::http::HeaderMap) -> Result<(), StatusCode> {
261    reject_browser_origin(headers)?;
262    check_auth(headers)
263}
264
265/// Start the HTTP server.
266///
267/// The `JoinHandle` is returned rather than dropped: `with_graceful_shutdown`
268/// only drains in-flight requests if something awaits the task. Without this
269/// the runtime is dropped as soon as `main` returns and connections are cut
270/// mid-response.
271#[must_use = "await this handle on shutdown, or in-flight requests are cut off"]
272pub fn spawn_http_server(runner: Arc<AgentRunner>) -> tokio::task::JoinHandle<()> {
273    let addr = http_listen_addr();
274    match load_or_create_token() {
275        Ok(token) => {
276            let _ = HTTP_TOKEN.set(token);
277        }
278        Err(e) => {
279            tracing::error!("apollo http token: {e}; /v1/chat will refuse all requests");
280        }
281    }
282    tokio::spawn(async move {
283        let app = Router::new()
284            .route("/health", get(|| async { "ok" }))
285            .route("/v1/chat", post(chat_handler))
286            .route("/v1/chat/stream", get(ws_chat_upgrade))
287            .route("/v1/state", get(state_handler))
288            .route("/v1/model", post(model_handler))
289            .route("/v1/clear", post(clear_handler))
290            .route("/shutdown", post(shutdown_handler))
291            .with_state(runner);
292        let listener = match tokio::net::TcpListener::bind(addr).await {
293            Ok(l) => l,
294            Err(e) => {
295                tracing::error!("apollo http bind {}: {}", addr, e);
296                return;
297            }
298        };
299        tracing::info!(
300            "apollo agent HTTP http://{}/v1/chat · WS /v1/chat/stream",
301            addr
302        );
303        if let Err(e) = axum::serve(listener, app)
304            .with_graceful_shutdown(wait_for_shutdown())
305            .await
306        {
307            tracing::error!("apollo http server: {}", e);
308        }
309    })
310}
311
312/// How long shutdown waits for in-flight HTTP requests to finish. Bounded, so
313/// one genuinely stuck request cannot hold the process open forever.
314pub const DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
315
316/// Wait for the server task to finish draining, up to `DRAIN_TIMEOUT`.
317pub async fn drain_http_server(handle: tokio::task::JoinHandle<()>) {
318    match tokio::time::timeout(DRAIN_TIMEOUT, handle).await {
319        Ok(Ok(())) => {}
320        Ok(Err(e)) => tracing::warn!("apollo http server task ended abnormally: {e}"),
321        Err(_) => tracing::warn!(
322            "apollo http server did not finish draining within {}s; exiting anyway",
323            DRAIN_TIMEOUT.as_secs()
324        ),
325    }
326}
327
328/// Signalled by `/shutdown`. The process unwinds from `main` so destructors
329/// run — RocksDB in particular needs its handle dropped to flush the WAL and
330/// close cleanly.
331static SHUTDOWN: tokio::sync::Notify = tokio::sync::Notify::const_new();
332static SHUTTING_DOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
333
334/// Resolves once a shutdown has been requested over HTTP.
335pub async fn wait_for_shutdown() {
336    loop {
337        // Register before re-reading the flag, so a signal raised between the
338        // check and the await cannot be missed.
339        let notified = SHUTDOWN.notified();
340        tokio::pin!(notified);
341        notified.as_mut().enable();
342        if SHUTTING_DOWN.load(std::sync::atomic::Ordering::SeqCst) {
343            return;
344        }
345        notified.await;
346    }
347}
348
349/// Request shutdown (also used by tests).
350pub fn request_shutdown() {
351    SHUTTING_DOWN.store(true, std::sync::atomic::Ordering::SeqCst);
352    SHUTDOWN.notify_waiters();
353}
354
355/// Stop a detached server started by `apollo tui`.
356///
357/// Authenticated like the chat routes: whoever can drive the agent can also
358/// stop it, and nobody else can.
359async fn shutdown_handler(headers: axum::http::HeaderMap) -> Result<&'static str, StatusCode> {
360    authorize(&headers)?;
361    tracing::info!("shutdown requested over HTTP");
362    // Graceful: axum finishes in-flight requests (including this response),
363    // then `main` unwinds so RocksDB and friends are dropped properly.
364    request_shutdown();
365    Ok("stopping")
366}
367
368async fn chat_handler(
369    State(runner): State<Arc<AgentRunner>>,
370    headers: axum::http::HeaderMap,
371    Json(body): Json<ChatRequestBody>,
372) -> Result<Json<ChatResponseBody>, StatusCode> {
373    authorize(&headers)?;
374    if body.message.trim().is_empty() {
375        return Err(StatusCode::BAD_REQUEST);
376    }
377    match chat_once(&runner, body.message.trim(), &body.chat_id).await {
378        Ok(response) => Ok(Json(ChatResponseBody { response })),
379        Err(e) => {
380            tracing::error!("http chat: {}", e);
381            Err(StatusCode::INTERNAL_SERVER_ERROR)
382        }
383    }
384}
385
386async fn state_handler(
387    State(runner): State<Arc<AgentRunner>>,
388    axum::extract::Query(query): axum::extract::Query<ChatIdQuery>,
389    headers: axum::http::HeaderMap,
390) -> Result<Json<StateBody>, StatusCode> {
391    authorize(&headers)?;
392    Ok(Json(build_state(&runner, &query.chat_id).await))
393}
394
395async fn model_handler(
396    State(runner): State<Arc<AgentRunner>>,
397    axum::extract::Query(query): axum::extract::Query<ChatIdQuery>,
398    headers: axum::http::HeaderMap,
399    Json(body): Json<ModelRequestBody>,
400) -> Result<Json<StateBody>, StatusCode> {
401    authorize(&headers)?;
402    let model = body.model.trim();
403    if model.is_empty() {
404        return Err(StatusCode::BAD_REQUEST);
405    }
406    if model == "default" || model == "reset" {
407        runner.reset_model();
408    } else {
409        runner.set_model(model);
410    }
411    tracing::info!("model switched over HTTP to {}", runner.get_model());
412    Ok(Json(build_state(&runner, &query.chat_id).await))
413}
414
415/// There is no `/v1/compact`: compaction in apollo is turn-local.
416///
417/// rx4 auto-compacts the in-flight message buffer when it exceeds
418/// `agent.auto_compact_after` estimated tokens; it never touches the
419/// conversation store, and stored history is re-read as the last
420/// `max_history_messages` rows on every turn. Compacting a chat server-side
421/// would therefore change nothing the next turn observes.
422#[derive(Debug, Serialize)]
423pub struct ClearedBody {
424    pub cleared: usize,
425    pub chat_id: String,
426}
427
428async fn clear_handler(
429    State(runner): State<Arc<AgentRunner>>,
430    headers: axum::http::HeaderMap,
431    Json(body): Json<ChatIdRequestBody>,
432) -> Result<Json<ClearedBody>, StatusCode> {
433    authorize(&headers)?;
434    let memory = runner.memory();
435    let cleared = memory
436        .get_conversation_history(&body.chat_id, MESSAGE_COUNT_CAP)
437        .await
438        .map(|history| history.len())
439        .unwrap_or(0);
440    if let Err(e) = memory.clear_conversation(&body.chat_id).await {
441        tracing::error!("http clear {}: {}", body.chat_id, e);
442        return Err(StatusCode::INTERNAL_SERVER_ERROR);
443    }
444    tracing::info!("cleared {} messages from {}", cleared, body.chat_id);
445    Ok(Json(ClearedBody {
446        cleared,
447        chat_id: body.chat_id,
448    }))
449}
450
451async fn ws_chat_upgrade(
452    ws: WebSocketUpgrade,
453    State(runner): State<Arc<AgentRunner>>,
454    headers: axum::http::HeaderMap,
455) -> axum::response::Response {
456    if let Err(status) = authorize(&headers) {
457        return status.into_response();
458    }
459    ws.on_upgrade(move |socket| handle_ws_chat(socket, runner))
460}
461
462async fn handle_ws_chat(mut socket: WebSocket, runner: Arc<AgentRunner>) {
463    let Some(Ok(Message::Text(text))) = socket.recv().await else {
464        return;
465    };
466    let Ok(body) = serde_json::from_str::<ChatRequestBody>(&text) else {
467        let _ = socket
468            .send(Message::text(
469                serde_json::json!({"type":"error","message":"invalid JSON"}).to_string(),
470            ))
471            .await;
472        return;
473    };
474    if body.message.trim().is_empty() {
475        let _ = socket
476            .send(Message::text(
477                serde_json::json!({"type":"error","message":"empty message"}).to_string(),
478            ))
479            .await;
480        return;
481    }
482
483    let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<AgentStreamEvent>();
484    let runner_bg = Arc::clone(&runner);
485    let message = body.message.trim().to_string();
486    let chat_id = body.chat_id.clone();
487    // Per-turn sink: a global one would cross-wire concurrent connections.
488    let mut chat_task = tokio::spawn(async move {
489        crate::agent::stream::with_turn_sink(Some(stream_tx), async {
490            chat_once(&runner_bg, &message, &chat_id).await
491        })
492        .await
493    });
494
495    // Once the client is gone we stop sending but keep draining, so the
496    // receiver outlives the still-running chat task.
497    let mut client_gone = false;
498
499    loop {
500        tokio::select! {
501            Some(ev) = stream_rx.recv() => {
502                if !client_gone {
503                    if let Ok(json) = serde_json::to_string(&ev) {
504                        if socket.send(Message::text(json)).await.is_err() {
505                            client_gone = true;
506                        }
507                    }
508                }
509            }
510            result = &mut chat_task => {
511                match result {
512                    Ok(Ok(response)) => {
513                        let payload = serde_json::to_string(&AgentStreamEvent::Done {
514                            response: response.clone(),
515                        })
516                        .unwrap_or_else(|_| {
517                            serde_json::json!({"type":"done","response": response}).to_string()
518                        });
519                        let _ = socket.send(Message::text(payload)).await;
520                    }
521                    Ok(Err(e)) => {
522                        let payload = serde_json::to_string(&AgentStreamEvent::Error {
523                            message: e.to_string(),
524                        })
525                        .unwrap_or_else(|_| {
526                            serde_json::json!({"type":"error","message": e.to_string()}).to_string()
527                        });
528                        let _ = socket.send(Message::text(payload)).await;
529                    }
530                    Err(e) => {
531                        let payload = serde_json::json!({"type":"error","message": e.to_string()});
532                        let _ = socket.send(Message::text(payload.to_string())).await;
533                    }
534                }
535                break;
536            }
537        }
538    }
539
540    while let Ok(ev) = stream_rx.try_recv() {
541        if let Ok(json) = serde_json::to_string(&ev) {
542            let _ = socket.send(Message::text(json)).await;
543        }
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use axum::http::{header, HeaderMap, HeaderValue};
551
552    fn headers(pairs: &[(header::HeaderName, &str)]) -> HeaderMap {
553        let mut map = HeaderMap::new();
554        for (name, value) in pairs {
555            map.insert(name.clone(), HeaderValue::from_str(value).unwrap());
556        }
557        map
558    }
559
560    #[test]
561    fn secret_eq_matches_only_identical_secrets() {
562        assert!(secret_eq("abc", "abc"));
563        assert!(!secret_eq("abc", "abd"));
564        assert!(!secret_eq("abc", "ab"));
565        assert!(!secret_eq("", "abc"));
566        assert!(secret_eq("", ""));
567    }
568
569    #[test]
570    fn a_correct_bearer_token_is_accepted() {
571        let h = headers(&[(header::AUTHORIZATION, "Bearer s3cret")]);
572        assert!(verify_token("s3cret", &h).is_ok());
573    }
574
575    #[test]
576    fn a_wrong_missing_or_malformed_token_is_rejected() {
577        for h in [
578            headers(&[(header::AUTHORIZATION, "Bearer wrong")]),
579            headers(&[(header::AUTHORIZATION, "s3cret")]),
580            headers(&[(header::AUTHORIZATION, "Basic s3cret")]),
581            headers(&[]),
582        ] {
583            assert_eq!(verify_token("s3cret", &h), Err(StatusCode::UNAUTHORIZED));
584        }
585    }
586
587    #[test]
588    fn an_origin_header_is_refused_even_with_a_valid_token() {
589        let h = headers(&[
590            (header::AUTHORIZATION, "Bearer s3cret"),
591            (header::ORIGIN, "https://evil.example"),
592        ]);
593        assert_eq!(reject_browser_origin(&h), Err(StatusCode::FORBIDDEN));
594    }
595
596    #[test]
597    fn requests_without_an_origin_pass_the_browser_check() {
598        assert!(reject_browser_origin(&headers(&[])).is_ok());
599    }
600
601    #[test]
602    fn a_model_request_needs_only_the_model_field() {
603        let body: ModelRequestBody = serde_json::from_str(r#"{"model":"claude-opus-4"}"#).unwrap();
604        assert_eq!(body.model, "claude-opus-4");
605        assert!(serde_json::from_str::<ModelRequestBody>("{}").is_err());
606    }
607
608    #[test]
609    fn a_chat_id_request_defaults_to_the_embed_chat() {
610        let body: ChatIdRequestBody = serde_json::from_str("{}").unwrap();
611        assert_eq!(body.chat_id, "embed");
612        let body: ChatIdRequestBody = serde_json::from_str(r#"{"chat_id":"tui"}"#).unwrap();
613        assert_eq!(body.chat_id, "tui");
614    }
615
616    #[test]
617    fn state_round_trips_through_json_with_every_field() {
618        let state = StateBody {
619            model: "claude-sonnet-4-5".into(),
620            provider: "anthropic".into(),
621            engine: "rx4".into(),
622            mode: "auto".into(),
623            cost_usd: 0.25,
624            total_tokens: 1234,
625            call_count: 3,
626            context_tokens: 8000,
627            context_window: 32_000,
628            context_pct: 25,
629            message_count: 12,
630        };
631        let json = serde_json::to_string(&state).unwrap();
632        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
633        for key in [
634            "model",
635            "provider",
636            "engine",
637            "mode",
638            "cost_usd",
639            "total_tokens",
640            "call_count",
641            "context_tokens",
642            "context_window",
643            "context_pct",
644            "message_count",
645        ] {
646            assert!(value.get(key).is_some(), "missing field: {key}");
647        }
648        assert_eq!(serde_json::from_str::<StateBody>(&json).unwrap(), state);
649    }
650
651    #[test]
652    fn context_percent_saturates_and_survives_a_zero_window() {
653        assert_eq!(context_percent(0, 1000), 0);
654        assert_eq!(context_percent(700, 1000), 70);
655        assert_eq!(context_percent(5000, 1000), 100);
656        assert_eq!(context_percent(500, 0), 0);
657    }
658
659    #[test]
660    fn every_agent_mode_has_a_stable_name() {
661        use crate::agent::mode::AgentMode;
662        assert_eq!(mode_name(&AgentMode::Auto), "auto");
663        assert_eq!(mode_name(&AgentMode::BypassPermissions), "bypass");
664        assert_eq!(
665            mode_name(&AgentMode::Coding {
666                plan_approval: false,
667                project_path: None,
668            }),
669            "coding"
670        );
671        assert_eq!(mode_name(&AgentMode::Swarm { parallelism: 2 }), "swarm");
672    }
673}