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