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