Skip to main content

kura_cli/commands/
analysis.rs

1use std::time::{Duration, Instant};
2
3use clap::Subcommand;
4use serde::Deserialize;
5use serde_json::{Value, json};
6use tokio::time::sleep;
7use uuid::Uuid;
8
9use crate::util::{api_request, client, exit_error, read_json_from_file};
10
11const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_DEFAULT: u64 = 90_000;
12const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MIN: u64 = 1_000;
13const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MAX: u64 = 300_000;
14const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_DEFAULT: u64 = 1_000;
15const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MIN: u64 = 100;
16const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MAX: u64 = 5_000;
17
18#[derive(Subcommand)]
19pub enum AnalysisCommands {
20    /// Queue and wait (bounded) for deep analysis via /v1/analysis/jobs/run
21    Run {
22        /// Full JSON request payload (use '-' for stdin)
23        #[arg(long, conflicts_with_all = ["objective", "objective_text", "horizon_days", "focus"])]
24        request_file: Option<String>,
25        /// Free-text analysis objective (preferred for one-off agent calls)
26        #[arg(long, short = 'o', conflicts_with = "objective_text")]
27        objective: Option<String>,
28        /// Free-text analysis objective (positional alias for --objective)
29        #[arg(value_name = "OBJECTIVE", conflicts_with = "objective")]
30        objective_text: Option<String>,
31        /// Horizon in days (defaults server-side to 90)
32        #[arg(long = "horizon-days", alias = "days")]
33        horizon_days: Option<i32>,
34        /// Focus hints (repeatable). Example: --focus lower_body --focus power
35        #[arg(long)]
36        focus: Vec<String>,
37        /// Override server-side initial wait timeout in milliseconds (server clamps to safe bounds)
38        #[arg(long)]
39        wait_timeout_ms: Option<u64>,
40        /// Total CLI wait budget including timeout fallback polling
41        #[arg(long)]
42        overall_timeout_ms: Option<u64>,
43        /// Poll interval used after server-side timeout fallback kicks in
44        #[arg(long)]
45        poll_interval_ms: Option<u64>,
46    },
47    /// Queue a new async analysis job via /v1/analysis/jobs
48    Create {
49        /// Full JSON request payload (use '-' for stdin)
50        #[arg(long)]
51        request_file: String,
52    },
53    /// Fetch analysis job status by id
54    Status {
55        /// Analysis job UUID
56        #[arg(long)]
57        job_id: Uuid,
58    },
59}
60
61pub async fn run(api_url: &str, token: Option<&str>, command: AnalysisCommands) -> i32 {
62    match command {
63        AnalysisCommands::Run {
64            request_file,
65            objective,
66            objective_text,
67            horizon_days,
68            focus,
69            wait_timeout_ms,
70            overall_timeout_ms,
71            poll_interval_ms,
72        } => {
73            run_blocking(
74                api_url,
75                token,
76                request_file.as_deref(),
77                objective,
78                objective_text,
79                horizon_days,
80                focus,
81                wait_timeout_ms,
82                overall_timeout_ms,
83                poll_interval_ms,
84            )
85            .await
86        }
87        AnalysisCommands::Create { request_file } => create(api_url, token, &request_file).await,
88        AnalysisCommands::Status { job_id } => status(api_url, token, job_id).await,
89    }
90}
91
92async fn run_blocking(
93    api_url: &str,
94    token: Option<&str>,
95    request_file: Option<&str>,
96    objective_flag: Option<String>,
97    objective_positional: Option<String>,
98    horizon_days: Option<i32>,
99    focus: Vec<String>,
100    wait_timeout_ms: Option<u64>,
101    overall_timeout_ms: Option<u64>,
102    poll_interval_ms: Option<u64>,
103) -> i32 {
104    let base_body = build_run_request_body(
105        request_file,
106        objective_flag,
107        objective_positional,
108        horizon_days,
109        focus,
110    )
111    .unwrap_or_else(|e| {
112        exit_error(
113            &e,
114            Some(
115                "Use `kura analysis run --objective \"...\"` (or positional objective) for user-facing analyses, or `--request-file payload.json` for full JSON requests.",
116            ),
117        )
118    });
119    let body = apply_wait_timeout_override(base_body, wait_timeout_ms).unwrap_or_else(|e| {
120        exit_error(
121            &e,
122            Some("Analysis run request must be a JSON object with objective/horizon/focus fields."),
123        )
124    });
125
126    let overall_timeout_ms = clamp_cli_overall_timeout_ms(overall_timeout_ms);
127    let poll_interval_ms = clamp_cli_poll_interval_ms(poll_interval_ms);
128    let started = Instant::now();
129    let legacy_create_body = strip_run_only_fields(body.clone());
130
131    let (status, run_body) = request_json(
132        api_url,
133        reqwest::Method::POST,
134        "/v1/analysis/jobs/run",
135        token,
136        Some(body),
137    )
138    .await
139    .unwrap_or_else(|e| exit_error(&e, Some("Check API availability/auth and retry.")));
140
141    if !is_success_status(status) {
142        if is_run_endpoint_unsupported_status(status) {
143            return run_blocking_via_legacy_async_fallback(
144                api_url,
145                token,
146                legacy_create_body,
147                overall_timeout_ms,
148                poll_interval_ms,
149                started,
150            )
151            .await;
152        }
153        return print_json_response(status, &run_body);
154    }
155
156    let Some(run_response) = parse_cli_run_response(&run_body) else {
157        return print_json_response(status, &run_body);
158    };
159
160    if !run_response_needs_cli_poll_fallback(&run_response) {
161        return print_json_response(status, &run_body);
162    }
163
164    let mut latest_job = run_body.get("job").cloned().unwrap_or(Value::Null);
165    let mut polls = 0u32;
166    let mut last_retry_after_ms =
167        clamp_cli_poll_interval_ms(run_response.retry_after_ms.or(Some(poll_interval_ms)));
168    let job_id = run_response.job.job_id;
169    let path = format!("/v1/analysis/jobs/{job_id}");
170
171    loop {
172        let elapsed_total_ms = elapsed_ms(started);
173        if elapsed_total_ms >= overall_timeout_ms {
174            let timeout_output = build_cli_poll_fallback_output(
175                latest_job,
176                elapsed_total_ms,
177                false,
178                true,
179                Some(last_retry_after_ms),
180                polls,
181                overall_timeout_ms,
182                run_response.mode.as_deref(),
183                "server_run_timeout_poll",
184            );
185            return print_json_response(200, &timeout_output);
186        }
187
188        let remaining_ms = overall_timeout_ms.saturating_sub(elapsed_total_ms);
189        let sleep_ms = min_u64(last_retry_after_ms, remaining_ms);
190        sleep(Duration::from_millis(sleep_ms)).await;
191
192        let (poll_status, poll_body) =
193            request_json(api_url, reqwest::Method::GET, &path, token, None)
194                .await
195                .unwrap_or_else(|e| {
196                    exit_error(
197                        &e,
198                        Some(
199                            "Blocking analysis fallback polling failed. Retry `kura analysis status --job-id <id>` in the same session if needed.",
200                        ),
201                    )
202                });
203
204        if !is_success_status(poll_status) {
205            return print_json_response(poll_status, &poll_body);
206        }
207
208        polls = polls.saturating_add(1);
209        latest_job = poll_body.clone();
210
211        if let Some(job_status) = parse_cli_job_status(&poll_body) {
212            if analysis_job_status_is_terminal(&job_status.status) {
213                let final_output = build_cli_poll_fallback_output(
214                    poll_body,
215                    elapsed_ms(started),
216                    true,
217                    false,
218                    None,
219                    polls,
220                    overall_timeout_ms,
221                    run_response.mode.as_deref(),
222                    "server_run_timeout_poll",
223                );
224                return print_json_response(200, &final_output);
225            }
226        } else {
227            // If shape is unexpected, surface the payload instead of masking it.
228            return print_json_response(200, &poll_body);
229        }
230
231        last_retry_after_ms = poll_interval_ms;
232    }
233}
234
235async fn run_blocking_via_legacy_async_fallback(
236    api_url: &str,
237    token: Option<&str>,
238    create_body: Value,
239    overall_timeout_ms: u64,
240    poll_interval_ms: u64,
241    started: Instant,
242) -> i32 {
243    let (create_status, create_resp_body) = request_json(
244        api_url,
245        reqwest::Method::POST,
246        "/v1/analysis/jobs",
247        token,
248        Some(create_body),
249    )
250    .await
251    .unwrap_or_else(|e| {
252        exit_error(
253            &e,
254            Some("Legacy analysis fallback failed to create a job. Check API availability/auth."),
255        )
256    });
257
258    if !is_success_status(create_status) {
259        return print_json_response(create_status, &create_resp_body);
260    }
261
262    let Some(create_resp) = parse_cli_create_job_response(&create_resp_body) else {
263        return print_json_response(create_status, &create_resp_body);
264    };
265
266    let mut latest_job = Value::Null;
267    let mut polls = 0u32;
268    let path = format!("/v1/analysis/jobs/{}", create_resp.job_id);
269
270    loop {
271        let elapsed_total_ms = elapsed_ms(started);
272        if elapsed_total_ms >= overall_timeout_ms {
273            let timeout_output = build_cli_poll_fallback_output(
274                if latest_job.is_null() {
275                    create_resp_body.clone()
276                } else {
277                    latest_job
278                },
279                elapsed_total_ms,
280                false,
281                true,
282                Some(poll_interval_ms),
283                polls,
284                overall_timeout_ms,
285                Some("legacy_async_create"),
286                "legacy_server_async_create",
287            );
288            return print_json_response(200, &timeout_output);
289        }
290
291        let remaining_ms = overall_timeout_ms.saturating_sub(elapsed_total_ms);
292        sleep(Duration::from_millis(min_u64(
293            poll_interval_ms,
294            remaining_ms,
295        )))
296        .await;
297
298        let (poll_status, poll_body) =
299            request_json(api_url, reqwest::Method::GET, &path, token, None)
300                .await
301                .unwrap_or_else(|e| {
302                    exit_error(
303                        &e,
304                        Some(
305                            "Legacy analysis fallback polling failed. Retry `kura analysis status --job-id <id>` if needed.",
306                        ),
307                    )
308                });
309
310        if !is_success_status(poll_status) {
311            return print_json_response(poll_status, &poll_body);
312        }
313
314        polls = polls.saturating_add(1);
315        latest_job = poll_body.clone();
316
317        if let Some(job_status) = parse_cli_job_status(&poll_body) {
318            if analysis_job_status_is_terminal(&job_status.status) {
319                let final_output = build_cli_poll_fallback_output(
320                    poll_body,
321                    elapsed_ms(started),
322                    true,
323                    false,
324                    None,
325                    polls,
326                    overall_timeout_ms,
327                    Some("legacy_async_create"),
328                    "legacy_server_async_create",
329                );
330                return print_json_response(200, &final_output);
331            }
332        } else {
333            return print_json_response(200, &poll_body);
334        }
335    }
336}
337
338async fn create(api_url: &str, token: Option<&str>, request_file: &str) -> i32 {
339    let body = match read_json_from_file(request_file) {
340        Ok(v) => v,
341        Err(e) => {
342            crate::util::exit_error(&e, Some("Provide a valid JSON analysis request payload."))
343        }
344    };
345
346    api_request(
347        api_url,
348        reqwest::Method::POST,
349        "/v1/analysis/jobs",
350        token,
351        Some(body),
352        &[],
353        &[],
354        false,
355        false,
356    )
357    .await
358}
359
360async fn status(api_url: &str, token: Option<&str>, job_id: Uuid) -> i32 {
361    let path = format!("/v1/analysis/jobs/{job_id}");
362    api_request(
363        api_url,
364        reqwest::Method::GET,
365        &path,
366        token,
367        None,
368        &[],
369        &[],
370        false,
371        false,
372    )
373    .await
374}
375
376fn build_run_request_body(
377    request_file: Option<&str>,
378    objective_flag: Option<String>,
379    objective_positional: Option<String>,
380    horizon_days: Option<i32>,
381    focus: Vec<String>,
382) -> Result<Value, String> {
383    if let Some(path) = request_file {
384        if objective_flag.is_some() || objective_positional.is_some() || horizon_days.is_some() {
385            return Err(
386                "`--request-file` cannot be combined with inline objective/horizon flags"
387                    .to_string(),
388            );
389        }
390        return read_json_from_file(path);
391    }
392
393    let objective = choose_inline_objective(objective_flag, objective_positional)?;
394    let objective = objective
395        .ok_or_else(|| "Missing analysis objective. Provide `--objective \"...\"`, positional OBJECTIVE, or `--request-file`.".to_string())?;
396
397    let normalized_focus = normalize_focus_flags(focus);
398    let mut body = json!({ "objective": objective });
399    let obj = body
400        .as_object_mut()
401        .ok_or_else(|| "analysis request body must be a JSON object".to_string())?;
402    if let Some(days) = horizon_days {
403        obj.insert("horizon_days".to_string(), json!(days));
404    }
405    if !normalized_focus.is_empty() {
406        obj.insert("focus".to_string(), json!(normalized_focus));
407    }
408    Ok(body)
409}
410
411fn choose_inline_objective(
412    objective_flag: Option<String>,
413    objective_positional: Option<String>,
414) -> Result<Option<String>, String> {
415    if objective_flag.is_some() && objective_positional.is_some() {
416        return Err(
417            "Provide the objective either as positional text or via `--objective`, not both."
418                .to_string(),
419        );
420    }
421    Ok(objective_flag
422        .or(objective_positional)
423        .map(|s| s.trim().to_string())
424        .filter(|s| !s.is_empty()))
425}
426
427fn normalize_focus_flags(values: Vec<String>) -> Vec<String> {
428    let mut out = Vec::new();
429    for value in values {
430        let normalized = value.trim();
431        if normalized.is_empty() {
432            continue;
433        }
434        let normalized = normalized.to_string();
435        if !out.contains(&normalized) {
436            out.push(normalized);
437        }
438    }
439    out
440}
441
442fn apply_wait_timeout_override(
443    mut body: Value,
444    wait_timeout_ms: Option<u64>,
445) -> Result<Value, String> {
446    if let Some(timeout_ms) = wait_timeout_ms {
447        let obj = body
448            .as_object_mut()
449            .ok_or_else(|| "analysis request body must be a JSON object".to_string())?;
450        obj.insert("wait_timeout_ms".to_string(), json!(timeout_ms));
451    }
452    Ok(body)
453}
454
455#[derive(Debug, Deserialize)]
456struct CliRunAnalysisResponse {
457    #[allow(dead_code)]
458    mode: Option<String>,
459    terminal: bool,
460    timed_out: bool,
461    #[serde(default)]
462    retry_after_ms: Option<u64>,
463    job: CliJobStatusRef,
464}
465
466#[derive(Debug, Deserialize)]
467struct CliJobStatusRef {
468    job_id: Uuid,
469    status: String,
470}
471
472#[derive(Debug, Deserialize)]
473struct CliCreateAnalysisJobResponse {
474    job_id: Uuid,
475}
476
477#[derive(Debug, Deserialize)]
478struct CliJobStatusEnvelope {
479    status: String,
480}
481
482fn parse_cli_run_response(value: &Value) -> Option<CliRunAnalysisResponse> {
483    serde_json::from_value(value.clone()).ok()
484}
485
486fn parse_cli_job_status(value: &Value) -> Option<CliJobStatusEnvelope> {
487    serde_json::from_value(value.clone()).ok()
488}
489
490fn parse_cli_create_job_response(value: &Value) -> Option<CliCreateAnalysisJobResponse> {
491    serde_json::from_value(value.clone()).ok()
492}
493
494fn run_response_needs_cli_poll_fallback(response: &CliRunAnalysisResponse) -> bool {
495    response.timed_out
496        && !response.terminal
497        && !analysis_job_status_is_terminal(&response.job.status)
498}
499
500fn analysis_job_status_is_terminal(status: &str) -> bool {
501    matches!(status, "completed" | "failed")
502}
503
504fn is_run_endpoint_unsupported_status(status: u16) -> bool {
505    matches!(status, 404 | 405)
506}
507
508fn strip_run_only_fields(mut body: Value) -> Value {
509    if let Some(obj) = body.as_object_mut() {
510        obj.remove("wait_timeout_ms");
511    }
512    body
513}
514
515fn clamp_cli_overall_timeout_ms(timeout_ms: Option<u64>) -> u64 {
516    timeout_ms
517        .unwrap_or(CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_DEFAULT)
518        .clamp(
519            CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MIN,
520            CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MAX,
521        )
522}
523
524fn clamp_cli_poll_interval_ms(timeout_ms: Option<u64>) -> u64 {
525    timeout_ms
526        .unwrap_or(CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_DEFAULT)
527        .clamp(
528            CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MIN,
529            CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MAX,
530        )
531}
532
533fn elapsed_ms(started: Instant) -> u64 {
534    let ms = started.elapsed().as_millis();
535    if ms > u128::from(u64::MAX) {
536        u64::MAX
537    } else {
538        ms as u64
539    }
540}
541
542fn min_u64(a: u64, b: u64) -> u64 {
543    if a < b { a } else { b }
544}
545
546fn build_cli_poll_fallback_output(
547    job: Value,
548    waited_ms: u64,
549    terminal: bool,
550    timed_out: bool,
551    retry_after_ms: Option<u64>,
552    polls: u32,
553    overall_timeout_ms: u64,
554    initial_mode: Option<&str>,
555    fallback_kind: &str,
556) -> Value {
557    let mut out = json!({
558        "mode": if terminal {
559            format!("blocking_cli_poll_fallback_completed:{fallback_kind}")
560        } else {
561            format!("blocking_cli_poll_fallback_timeout:{fallback_kind}")
562        },
563        "terminal": terminal,
564        "timed_out": timed_out,
565        "waited_ms": waited_ms,
566        "retry_after_ms": retry_after_ms,
567        "job": job,
568        "cli_fallback": {
569            "used": true,
570            "kind": fallback_kind,
571            "polls": polls,
572            "overall_timeout_ms": overall_timeout_ms,
573            "initial_mode": initial_mode,
574        }
575    });
576    if retry_after_ms.is_none() {
577        if let Some(obj) = out.as_object_mut() {
578            obj.insert("retry_after_ms".to_string(), Value::Null);
579        }
580    }
581    out
582}
583
584async fn request_json(
585    api_url: &str,
586    method: reqwest::Method,
587    path: &str,
588    token: Option<&str>,
589    body: Option<Value>,
590) -> Result<(u16, Value), String> {
591    let url = reqwest::Url::parse(&format!("{api_url}{path}"))
592        .map_err(|e| format!("Invalid URL: {api_url}{path}: {e}"))?;
593
594    let mut req = client().request(method, url);
595    if let Some(t) = token {
596        req = req.header("Authorization", format!("Bearer {t}"));
597    }
598    if let Some(b) = body {
599        req = req.json(&b);
600    }
601
602    let resp = req.send().await.map_err(|e| format!("{e}"))?;
603    let status = resp.status().as_u16();
604    let body: Value = match resp.bytes().await {
605        Ok(bytes) => {
606            if bytes.is_empty() {
607                Value::Null
608            } else {
609                serde_json::from_slice(&bytes)
610                    .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).to_string()))
611            }
612        }
613        Err(e) => json!({"raw_error": format!("Failed to read response body: {e}")}),
614    };
615    Ok((status, body))
616}
617
618fn is_success_status(status: u16) -> bool {
619    (200..=299).contains(&status)
620}
621
622fn http_status_exit_code(status: u16) -> i32 {
623    match status {
624        200..=299 => 0,
625        400..=499 => 1,
626        _ => 2,
627    }
628}
629
630fn print_json_response(status: u16, body: &Value) -> i32 {
631    let exit_code = http_status_exit_code(status);
632    let formatted = serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string());
633    if exit_code == 0 {
634        println!("{formatted}");
635    } else {
636        eprintln!("{formatted}");
637    }
638    exit_code
639}
640
641#[cfg(test)]
642mod tests {
643    use super::{
644        analysis_job_status_is_terminal, apply_wait_timeout_override, build_run_request_body,
645        clamp_cli_overall_timeout_ms, clamp_cli_poll_interval_ms,
646        is_run_endpoint_unsupported_status, parse_cli_job_status,
647        run_response_needs_cli_poll_fallback, strip_run_only_fields,
648    };
649    use serde_json::json;
650
651    #[test]
652    fn apply_wait_timeout_override_merges_field_into_request_object() {
653        let body = json!({
654            "objective": "trend of readiness",
655            "horizon_days": 90
656        });
657        let patched = apply_wait_timeout_override(body, Some(2500)).unwrap();
658        assert_eq!(patched["wait_timeout_ms"], json!(2500));
659        assert_eq!(patched["objective"], json!("trend of readiness"));
660    }
661
662    #[test]
663    fn apply_wait_timeout_override_rejects_non_object_when_override_present() {
664        let err = apply_wait_timeout_override(json!(["bad"]), Some(1000)).unwrap_err();
665        assert!(err.contains("JSON object"));
666    }
667
668    #[test]
669    fn build_run_request_body_accepts_inline_objective_and_flags() {
670        let body = build_run_request_body(
671            None,
672            Some("trend of plyometric quality".to_string()),
673            None,
674            Some(90),
675            vec![
676                "plyo".to_string(),
677                "  lower_body ".to_string(),
678                "".to_string(),
679            ],
680        )
681        .unwrap();
682        assert_eq!(body["objective"], json!("trend of plyometric quality"));
683        assert_eq!(body["horizon_days"], json!(90));
684        assert_eq!(body["focus"], json!(["plyo", "lower_body"]));
685    }
686
687    #[test]
688    fn build_run_request_body_supports_positional_objective() {
689        let body = build_run_request_body(
690            None,
691            None,
692            Some("trend of sleep quality".to_string()),
693            None,
694            vec![],
695        )
696        .unwrap();
697        assert_eq!(body["objective"], json!("trend of sleep quality"));
698        assert!(body.get("horizon_days").is_none());
699    }
700
701    #[test]
702    fn build_run_request_body_rejects_missing_input() {
703        let err = build_run_request_body(None, None, None, None, vec![]).unwrap_err();
704        assert!(err.contains("Missing analysis objective"));
705    }
706
707    #[test]
708    fn build_run_request_body_rejects_duplicate_inline_objective_sources() {
709        let err = build_run_request_body(
710            None,
711            Some("a".to_string()),
712            Some("b".to_string()),
713            None,
714            vec![],
715        )
716        .unwrap_err();
717        assert!(err.contains("either as positional"));
718    }
719
720    #[test]
721    fn clamp_cli_timeouts_apply_bounds() {
722        assert_eq!(clamp_cli_overall_timeout_ms(None), 90_000);
723        assert_eq!(clamp_cli_overall_timeout_ms(Some(1)), 1_000);
724        assert_eq!(clamp_cli_overall_timeout_ms(Some(999_999)), 300_000);
725        assert_eq!(clamp_cli_poll_interval_ms(None), 1_000);
726        assert_eq!(clamp_cli_poll_interval_ms(Some(1)), 100);
727        assert_eq!(clamp_cli_poll_interval_ms(Some(50_000)), 5_000);
728    }
729
730    #[test]
731    fn analysis_terminal_status_matches_api_contract() {
732        assert!(analysis_job_status_is_terminal("completed"));
733        assert!(analysis_job_status_is_terminal("failed"));
734        assert!(!analysis_job_status_is_terminal("queued"));
735        assert!(!analysis_job_status_is_terminal("processing"));
736    }
737
738    #[test]
739    fn run_response_fallback_detection_requires_timeout_and_non_terminal_job() {
740        let timed_out = serde_json::from_value(json!({
741            "terminal": false,
742            "timed_out": true,
743            "retry_after_ms": 500,
744            "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "queued" }
745        }))
746        .unwrap();
747        assert!(run_response_needs_cli_poll_fallback(&timed_out));
748
749        let completed = serde_json::from_value(json!({
750            "terminal": true,
751            "timed_out": false,
752            "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "completed" }
753        }))
754        .unwrap();
755        assert!(!run_response_needs_cli_poll_fallback(&completed));
756    }
757
758    #[test]
759    fn parse_cli_job_status_reads_status_field() {
760        let parsed = parse_cli_job_status(&json!({
761            "job_id": "00000000-0000-0000-0000-000000000000",
762            "status": "queued"
763        }))
764        .unwrap();
765        assert_eq!(parsed.status, "queued");
766    }
767
768    #[test]
769    fn run_endpoint_unsupported_status_detection_matches_legacy_fallback_cases() {
770        assert!(is_run_endpoint_unsupported_status(404));
771        assert!(is_run_endpoint_unsupported_status(405));
772        assert!(!is_run_endpoint_unsupported_status(400));
773        assert!(!is_run_endpoint_unsupported_status(401));
774        assert!(!is_run_endpoint_unsupported_status(500));
775    }
776
777    #[test]
778    fn strip_run_only_fields_removes_wait_timeout_for_legacy_create_fallback() {
779        let body = json!({
780            "objective": "trend of readiness",
781            "horizon_days": 90,
782            "wait_timeout_ms": 2000
783        });
784        let stripped = strip_run_only_fields(body);
785        assert!(stripped.get("wait_timeout_ms").is_none());
786        assert_eq!(stripped["objective"], json!("trend of readiness"));
787    }
788}