Skip to main content

kura_cli/commands/
analysis.rs

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