Skip to main content

kura_cli/commands/
agent.rs

1use chrono::Utc;
2use clap::{Args, Subcommand, ValueEnum};
3use serde_json::{Value, json};
4use uuid::Uuid;
5
6use crate::util::{
7    api_request, exit_error, print_json_stderr, print_json_stdout, raw_api_request,
8    read_json_from_file,
9};
10
11#[derive(Subcommand)]
12pub enum AgentCommands {
13    /// Get negotiated agent capabilities manifest
14    Capabilities,
15    /// Get the focused logging bootstrap contract or one intent-native logging recipe
16    LoggingBootstrap {
17        /// Optional intent recipe id (for example: log_conversation)
18        #[arg(long)]
19        intent: Option<String>,
20    },
21    /// Get agent context bundle (system + user profile + key dimensions)
22    Context {
23        /// Max exercise_progression projections to include (default: 5)
24        #[arg(long)]
25        exercise_limit: Option<u32>,
26        /// Max strength_inference projections to include (default: 5)
27        #[arg(long)]
28        strength_limit: Option<u32>,
29        /// Max custom projections to include (default: 10)
30        #[arg(long)]
31        custom_limit: Option<u32>,
32        /// Optional task intent used for context ranking (e.g. "dunk progression")
33        #[arg(long)]
34        task_intent: Option<String>,
35        /// Include deployment-static system config in response payload (default: API default=true)
36        #[arg(long)]
37        include_system: Option<bool>,
38        /// Optional response token budget hint (min 400, max 12000)
39        #[arg(long)]
40        budget_tokens: Option<u32>,
41    },
42    /// Get deterministic section index for startup + targeted follow-up reads
43    SectionIndex {
44        /// Max exercise_progression projections to include (default: 5)
45        #[arg(long)]
46        exercise_limit: Option<u32>,
47        /// Max strength_inference projections to include (default: 5)
48        #[arg(long)]
49        strength_limit: Option<u32>,
50        /// Max custom projections to include (default: 10)
51        #[arg(long)]
52        custom_limit: Option<u32>,
53        /// Optional task intent used for startup section derivation
54        #[arg(long)]
55        task_intent: Option<String>,
56        /// Include deployment-static system config in response payload (default: API default=true)
57        #[arg(long)]
58        include_system: Option<bool>,
59        /// Optional response token budget hint (min 400, max 12000)
60        #[arg(long)]
61        budget_tokens: Option<u32>,
62    },
63    /// Fetch exactly one context section (optionally paged and field-projected)
64    SectionFetch {
65        /// Stable section id from section-index
66        #[arg(long)]
67        section: String,
68        /// Optional page size for paged sections (1..200)
69        #[arg(long)]
70        limit: Option<u32>,
71        /// Optional opaque cursor for paged sections
72        #[arg(long)]
73        cursor: Option<String>,
74        /// Optional comma-separated top-level fields to project
75        #[arg(long)]
76        fields: Option<String>,
77        /// Optional task intent for startup section derivation
78        #[arg(long)]
79        task_intent: Option<String>,
80    },
81    /// Validate a draft answer against the authoritative date-bound coaching serving view
82    AnswerAdmissibility {
83        /// Current user request, preferably passed verbatim
84        #[arg(long)]
85        task_intent: String,
86        /// Draft user-facing answer to validate
87        #[arg(long)]
88        draft_answer: String,
89    },
90    /// Evidence lineage operations
91    Evidence {
92        #[command(subcommand)]
93        command: AgentEvidenceCommands,
94    },
95    /// Set user save-confirmation preference (persist-intent override)
96    SetSaveConfirmationMode {
97        /// auto | always | never
98        #[arg(value_enum)]
99        mode: SaveConfirmationMode,
100    },
101    /// Resolve visualization policy/output for a task intent
102    ResolveVisualization(ResolveVisualizationArgs),
103    /// Direct agent API access under /v1/agent/*
104    #[command(hide = true)]
105    Request(AgentRequestArgs),
106}
107
108#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
109pub enum SaveConfirmationMode {
110    Auto,
111    Always,
112    Never,
113}
114
115#[cfg(test)]
116#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
117pub enum SessionCompletionStatus {
118    Ongoing,
119    CompletedInBatch,
120}
121
122#[cfg(test)]
123impl SessionCompletionStatus {
124    fn as_str(self) -> &'static str {
125        match self {
126            SessionCompletionStatus::Ongoing => "ongoing",
127            SessionCompletionStatus::CompletedInBatch => "completed_in_batch",
128        }
129    }
130}
131
132impl SaveConfirmationMode {
133    fn as_str(self) -> &'static str {
134        match self {
135            SaveConfirmationMode::Auto => "auto",
136            SaveConfirmationMode::Always => "always",
137            SaveConfirmationMode::Never => "never",
138        }
139    }
140}
141
142#[derive(Subcommand)]
143pub enum AgentEvidenceCommands {
144    /// Explain lineage claims for one persisted event
145    Event {
146        /// Target event UUID
147        #[arg(long)]
148        event_id: Uuid,
149    },
150}
151
152#[derive(Args)]
153pub struct AgentRequestArgs {
154    /// HTTP method (GET, POST, PUT, DELETE, PATCH)
155    pub method: String,
156
157    /// Agent path: relative (e.g. context) or absolute (/v1/agent/context)
158    pub path: String,
159
160    /// Request body as JSON string
161    #[arg(long, short = 'd')]
162    pub data: Option<String>,
163
164    /// Read request body from file (use '-' for stdin)
165    #[arg(long, short = 'f', conflicts_with = "data")]
166    pub data_file: Option<String>,
167
168    /// Query parameters (repeatable: key=value)
169    #[arg(long, short = 'q')]
170    pub query: Vec<String>,
171
172    /// Extra headers (repeatable: Key:Value)
173    #[arg(long, short = 'H')]
174    pub header: Vec<String>,
175
176    /// Skip pretty-printing (raw JSON for piping)
177    #[arg(long)]
178    pub raw: bool,
179
180    /// Include HTTP status and headers in response wrapper
181    #[arg(long, short = 'i')]
182    pub include: bool,
183}
184
185#[derive(Args)]
186pub struct ResolveVisualizationArgs {
187    /// Full request payload JSON file for /v1/agent/visualization/resolve
188    #[arg(long, conflicts_with = "task_intent")]
189    pub request_file: Option<String>,
190
191    /// Task intent (required unless --request-file is used)
192    #[arg(long, required_unless_present = "request_file")]
193    pub task_intent: Option<String>,
194
195    /// auto | always | never
196    #[arg(long)]
197    pub user_preference_override: Option<String>,
198
199    /// low | medium | high
200    #[arg(long)]
201    pub complexity_hint: Option<String>,
202
203    /// Allow rich rendering formats when true (default: true)
204    #[arg(long, default_value_t = true)]
205    pub allow_rich_rendering: bool,
206
207    /// Optional visualization_spec JSON file
208    #[arg(long)]
209    pub spec_file: Option<String>,
210
211    /// Optional telemetry session id
212    #[arg(long)]
213    pub telemetry_session_id: Option<String>,
214}
215
216pub async fn run(api_url: &str, token: Option<&str>, command: AgentCommands) -> i32 {
217    match command {
218        AgentCommands::Capabilities => capabilities(api_url, token).await,
219        AgentCommands::LoggingBootstrap { intent } => {
220            logging_bootstrap(api_url, token, intent).await
221        }
222        AgentCommands::Context {
223            exercise_limit,
224            strength_limit,
225            custom_limit,
226            task_intent,
227            include_system,
228            budget_tokens,
229        } => {
230            context(
231                api_url,
232                token,
233                exercise_limit,
234                strength_limit,
235                custom_limit,
236                task_intent,
237                include_system,
238                budget_tokens,
239            )
240            .await
241        }
242        AgentCommands::SectionIndex {
243            exercise_limit,
244            strength_limit,
245            custom_limit,
246            task_intent,
247            include_system,
248            budget_tokens,
249        } => {
250            section_index(
251                api_url,
252                token,
253                exercise_limit,
254                strength_limit,
255                custom_limit,
256                task_intent,
257                include_system,
258                budget_tokens,
259            )
260            .await
261        }
262        AgentCommands::SectionFetch {
263            section,
264            limit,
265            cursor,
266            fields,
267            task_intent,
268        } => section_fetch(api_url, token, section, limit, cursor, fields, task_intent).await,
269        AgentCommands::AnswerAdmissibility {
270            task_intent,
271            draft_answer,
272        } => answer_admissibility(api_url, token, task_intent, draft_answer).await,
273        AgentCommands::Evidence { command } => match command {
274            AgentEvidenceCommands::Event { event_id } => {
275                evidence_event(api_url, token, event_id).await
276            }
277        },
278        AgentCommands::SetSaveConfirmationMode { mode } => {
279            set_save_confirmation_mode(api_url, token, mode).await
280        }
281        AgentCommands::ResolveVisualization(args) => {
282            resolve_visualization(api_url, token, args).await
283        }
284        AgentCommands::Request(args) => request(api_url, token, args).await,
285    }
286}
287
288async fn capabilities(api_url: &str, token: Option<&str>) -> i32 {
289    api_request(
290        api_url,
291        reqwest::Method::GET,
292        "/v1/agent/capabilities",
293        token,
294        None,
295        &[],
296        &[],
297        false,
298        false,
299    )
300    .await
301}
302
303fn normalize_logging_bootstrap_intent(intent: &str) -> Option<String> {
304    let normalized = intent.trim().to_ascii_lowercase();
305    if normalized.is_empty() {
306        None
307    } else {
308        Some(normalized)
309    }
310}
311
312fn extract_logging_bootstrap_contract(capabilities: &Value) -> Option<Value> {
313    capabilities
314        .pointer("/task_bootstrap_contracts/logging")
315        .cloned()
316        .filter(|value| value.is_object())
317}
318
319fn available_logging_bootstrap_intents(contract: &Value) -> Vec<String> {
320    let Some(recipes) = contract.get("intent_recipes").and_then(Value::as_array) else {
321        return Vec::new();
322    };
323    recipes
324        .iter()
325        .filter_map(|recipe| recipe.get("intent_id").and_then(Value::as_str))
326        .map(str::to_string)
327        .collect()
328}
329
330fn build_logging_bootstrap_output(contract: &Value, intent: Option<&str>) -> Result<Value, Value> {
331    let Some(intent) = intent else {
332        return Ok(contract.clone());
333    };
334    let Some(normalized_intent) = normalize_logging_bootstrap_intent(intent) else {
335        return Err(json!({
336            "error": "usage_error",
337            "message": "--intent must not be empty",
338        }));
339    };
340    let recipes = contract
341        .get("intent_recipes")
342        .and_then(Value::as_array)
343        .cloned()
344        .unwrap_or_default();
345    let Some(recipe) = recipes.into_iter().find(|recipe| {
346        recipe
347            .get("intent_id")
348            .and_then(Value::as_str)
349            .map(|value| value.eq_ignore_ascii_case(&normalized_intent))
350            .unwrap_or(false)
351    }) else {
352        return Err(json!({
353            "error": "usage_error",
354            "message": format!("Unknown logging bootstrap intent: {intent}"),
355            "available_intents": available_logging_bootstrap_intents(contract),
356        }));
357    };
358    Ok(json!({
359        "schema_version": contract.get("schema_version").cloned().unwrap_or(Value::Null),
360        "task_family": contract.get("task_family").cloned().unwrap_or(Value::Null),
361        "bootstrap_surface": contract.get("bootstrap_surface").cloned().unwrap_or(Value::Null),
362        "intent_recipe": recipe,
363        "save_states": contract.get("save_states").cloned().unwrap_or_else(|| json!([])),
364        "upgrade_hints": contract.get("upgrade_hints").cloned().unwrap_or_else(|| json!([])),
365        "integrity_guards": contract.get("integrity_guards").cloned().unwrap_or_else(|| json!([])),
366    }))
367}
368
369#[cfg(test)]
370#[derive(Debug, Clone, PartialEq, Eq)]
371struct ResumeClarificationPrompt {
372    prompt_id: Uuid,
373    scope_kind: String,
374    accepted_resolution_fields: Vec<String>,
375}
376
377async fn logging_bootstrap(api_url: &str, token: Option<&str>, intent: Option<String>) -> i32 {
378    let (status, body) = raw_api_request(
379        api_url,
380        reqwest::Method::GET,
381        "/v1/agent/capabilities",
382        token,
383    )
384    .await
385    .unwrap_or_else(|error| {
386        exit_error(
387            &format!("Failed to fetch /v1/agent/capabilities for logging bootstrap: {error}"),
388            Some("Retry once the API is reachable, or fall back to `kura agent capabilities`."),
389        )
390    });
391
392    if !(200..=299).contains(&status) {
393        print_json_stderr(&body);
394        return if (400..500).contains(&status) { 1 } else { 2 };
395    }
396
397    let Some(contract) = extract_logging_bootstrap_contract(&body) else {
398        exit_error(
399            "agent capabilities response is missing task_bootstrap_contracts.logging",
400            Some("Retry after `kura agent capabilities` succeeds, or inspect the full manifest."),
401        );
402    };
403
404    match build_logging_bootstrap_output(&contract, intent.as_deref()) {
405        Ok(output) => {
406            print_json_stdout(&output);
407            0
408        }
409        Err(error) => {
410            print_json_stderr(&error);
411            4
412        }
413    }
414}
415
416pub async fn context(
417    api_url: &str,
418    token: Option<&str>,
419    exercise_limit: Option<u32>,
420    strength_limit: Option<u32>,
421    custom_limit: Option<u32>,
422    task_intent: Option<String>,
423    include_system: Option<bool>,
424    budget_tokens: Option<u32>,
425) -> i32 {
426    let query = build_context_query(
427        exercise_limit,
428        strength_limit,
429        custom_limit,
430        task_intent,
431        include_system,
432        budget_tokens,
433    );
434
435    api_request(
436        api_url,
437        reqwest::Method::GET,
438        "/v1/agent/context",
439        token,
440        None,
441        &query,
442        &[],
443        false,
444        false,
445    )
446    .await
447}
448
449pub async fn section_index(
450    api_url: &str,
451    token: Option<&str>,
452    exercise_limit: Option<u32>,
453    strength_limit: Option<u32>,
454    custom_limit: Option<u32>,
455    task_intent: Option<String>,
456    include_system: Option<bool>,
457    budget_tokens: Option<u32>,
458) -> i32 {
459    let query = build_context_query(
460        exercise_limit,
461        strength_limit,
462        custom_limit,
463        task_intent,
464        include_system,
465        budget_tokens,
466    );
467    api_request(
468        api_url,
469        reqwest::Method::GET,
470        "/v1/agent/context/section-index",
471        token,
472        None,
473        &query,
474        &[],
475        false,
476        false,
477    )
478    .await
479}
480
481pub async fn section_fetch(
482    api_url: &str,
483    token: Option<&str>,
484    section: String,
485    limit: Option<u32>,
486    cursor: Option<String>,
487    fields: Option<String>,
488    task_intent: Option<String>,
489) -> i32 {
490    let query = build_section_fetch_query(section, limit, cursor, fields, task_intent);
491    api_request(
492        api_url,
493        reqwest::Method::GET,
494        "/v1/agent/context/section-fetch",
495        token,
496        None,
497        &query,
498        &[],
499        false,
500        false,
501    )
502    .await
503}
504
505pub async fn answer_admissibility(
506    api_url: &str,
507    token: Option<&str>,
508    task_intent: String,
509    draft_answer: String,
510) -> i32 {
511    let body = json!({
512        "task_intent": task_intent,
513        "draft_answer": draft_answer,
514    });
515    api_request(
516        api_url,
517        reqwest::Method::POST,
518        "/v1/agent/answer-admissibility",
519        token,
520        Some(body),
521        &[],
522        &[],
523        false,
524        false,
525    )
526    .await
527}
528
529async fn evidence_event(api_url: &str, token: Option<&str>, event_id: Uuid) -> i32 {
530    let path = format!("/v1/agent/evidence/event/{event_id}");
531    api_request(
532        api_url,
533        reqwest::Method::GET,
534        &path,
535        token,
536        None,
537        &[],
538        &[],
539        false,
540        false,
541    )
542    .await
543}
544
545async fn set_save_confirmation_mode(
546    api_url: &str,
547    token: Option<&str>,
548    mode: SaveConfirmationMode,
549) -> i32 {
550    let body = json!({
551        "timestamp": Utc::now().to_rfc3339(),
552        "event_type": "preference.set",
553        "data": {
554            "key": "save_confirmation_mode",
555            "value": mode.as_str(),
556        },
557        "metadata": {
558            "source": "cli",
559            "agent": "kura-cli",
560            "idempotency_key": Uuid::now_v7().to_string(),
561        }
562    });
563    api_request(
564        api_url,
565        reqwest::Method::POST,
566        "/v1/events",
567        token,
568        Some(body),
569        &[],
570        &[],
571        false,
572        false,
573    )
574    .await
575}
576
577async fn request(api_url: &str, token: Option<&str>, args: AgentRequestArgs) -> i32 {
578    let method = parse_method(&args.method);
579    let path = normalize_agent_path(&args.path);
580    if is_blocked_agent_request_path(&path, &method) {
581        exit_error(
582            "Direct agent-request detours for ordinary workout logging are blocked in the CLI.",
583            Some(
584                "Use `kura record_activity` for activity logging instead of raw detours into old training routes.",
585            ),
586        );
587    }
588    let query = parse_query_pairs(&args.query);
589    let headers = parse_headers(&args.header);
590    let body = resolve_body(args.data.as_deref(), args.data_file.as_deref());
591
592    api_request(
593        api_url,
594        method,
595        &path,
596        token,
597        body,
598        &query,
599        &headers,
600        args.raw,
601        args.include,
602    )
603    .await
604}
605
606async fn resolve_visualization(
607    api_url: &str,
608    token: Option<&str>,
609    args: ResolveVisualizationArgs,
610) -> i32 {
611    let body = if let Some(file) = args.request_file.as_deref() {
612        match read_json_from_file(file) {
613            Ok(v) => v,
614            Err(e) => exit_error(
615                &e,
616                Some("Provide a valid JSON payload for /v1/agent/visualization/resolve."),
617            ),
618        }
619    } else {
620        let task_intent = match args.task_intent {
621            Some(intent) if !intent.trim().is_empty() => intent,
622            _ => exit_error(
623                "task_intent is required unless --request-file is used.",
624                Some("Use --task-intent or provide --request-file."),
625            ),
626        };
627
628        let mut body = json!({
629            "task_intent": task_intent,
630            "allow_rich_rendering": args.allow_rich_rendering
631        });
632        if let Some(mode) = args.user_preference_override {
633            body["user_preference_override"] = json!(mode);
634        }
635        if let Some(complexity) = args.complexity_hint {
636            body["complexity_hint"] = json!(complexity);
637        }
638        if let Some(session_id) = args.telemetry_session_id {
639            body["telemetry_session_id"] = json!(session_id);
640        }
641        if let Some(spec_file) = args.spec_file.as_deref() {
642            let spec = match read_json_from_file(spec_file) {
643                Ok(v) => v,
644                Err(e) => exit_error(&e, Some("Provide a valid JSON visualization_spec payload.")),
645            };
646            body["visualization_spec"] = spec;
647        }
648        body
649    };
650
651    api_request(
652        api_url,
653        reqwest::Method::POST,
654        "/v1/agent/visualization/resolve",
655        token,
656        Some(body),
657        &[],
658        &[],
659        false,
660        false,
661    )
662    .await
663}
664
665fn parse_method(raw: &str) -> reqwest::Method {
666    match raw.to_uppercase().as_str() {
667        "GET" => reqwest::Method::GET,
668        "POST" => reqwest::Method::POST,
669        "PUT" => reqwest::Method::PUT,
670        "DELETE" => reqwest::Method::DELETE,
671        "PATCH" => reqwest::Method::PATCH,
672        "HEAD" => reqwest::Method::HEAD,
673        "OPTIONS" => reqwest::Method::OPTIONS,
674        other => exit_error(
675            &format!("Unknown HTTP method: {other}"),
676            Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
677        ),
678    }
679}
680
681fn normalize_agent_path(raw: &str) -> String {
682    let trimmed = raw.trim();
683    if trimmed.is_empty() {
684        exit_error(
685            "Agent path must not be empty.",
686            Some("Use relative path like 'context' or absolute path '/v1/agent/context'."),
687        );
688    }
689
690    if trimmed.starts_with("/v1/agent") {
691        return trimmed.to_string();
692    }
693    if trimmed.starts_with("v1/agent") {
694        return format!("/{trimmed}");
695    }
696    if trimmed.starts_with('/') {
697        exit_error(
698            &format!("Invalid agent path '{trimmed}'."),
699            Some(
700                "`kura agent request` only supports /v1/agent/* paths. Use public task commands instead of legacy structured detours.",
701            ),
702        );
703    }
704
705    format!("/v1/agent/{}", trimmed.trim_start_matches('/'))
706}
707
708fn is_blocked_agent_request_path(path: &str, method: &reqwest::Method) -> bool {
709    if *method != reqwest::Method::POST {
710        return false;
711    }
712
713    matches!(
714        path.trim().to_ascii_lowercase().as_str(),
715        "/v1/agent/exercise-resolve" | "/v4/agent/write-event" | "/v4/agent/write-correction"
716    ) || path.trim().to_ascii_lowercase().starts_with("/v3/agent/")
717}
718
719fn parse_query_pairs(raw: &[String]) -> Vec<(String, String)> {
720    raw.iter()
721        .map(|entry| {
722            entry.split_once('=').map_or_else(
723                || {
724                    exit_error(
725                        &format!("Invalid query parameter: '{entry}'"),
726                        Some("Format: key=value, e.g. --query event_type=meal.logged"),
727                    )
728                },
729                |(k, v)| (k.to_string(), v.to_string()),
730            )
731        })
732        .collect()
733}
734
735fn build_context_query(
736    exercise_limit: Option<u32>,
737    strength_limit: Option<u32>,
738    custom_limit: Option<u32>,
739    task_intent: Option<String>,
740    include_system: Option<bool>,
741    budget_tokens: Option<u32>,
742) -> Vec<(String, String)> {
743    let mut query = Vec::new();
744    if let Some(v) = exercise_limit {
745        query.push(("exercise_limit".to_string(), v.to_string()));
746    }
747    if let Some(v) = strength_limit {
748        query.push(("strength_limit".to_string(), v.to_string()));
749    }
750    if let Some(v) = custom_limit {
751        query.push(("custom_limit".to_string(), v.to_string()));
752    }
753    if let Some(v) = task_intent {
754        query.push(("task_intent".to_string(), v));
755    }
756    if let Some(v) = include_system {
757        query.push(("include_system".to_string(), v.to_string()));
758    }
759    if let Some(v) = budget_tokens {
760        query.push(("budget_tokens".to_string(), v.to_string()));
761    }
762    query
763}
764
765fn build_section_fetch_query(
766    section: String,
767    limit: Option<u32>,
768    cursor: Option<String>,
769    fields: Option<String>,
770    task_intent: Option<String>,
771) -> Vec<(String, String)> {
772    let section = section.trim();
773    if section.is_empty() {
774        exit_error(
775            "section must not be empty",
776            Some("Provide --section using an id from /v1/agent/context/section-index"),
777        );
778    }
779    let mut query = vec![("section".to_string(), section.to_string())];
780    if let Some(v) = limit {
781        query.push(("limit".to_string(), v.to_string()));
782    }
783    if let Some(v) = cursor {
784        query.push(("cursor".to_string(), v));
785    }
786    if let Some(v) = fields {
787        query.push(("fields".to_string(), v));
788    }
789    if let Some(v) = task_intent {
790        query.push(("task_intent".to_string(), v));
791    }
792    query
793}
794
795fn parse_headers(raw: &[String]) -> Vec<(String, String)> {
796    raw.iter()
797        .map(|entry| {
798            entry.split_once(':').map_or_else(
799                || {
800                    exit_error(
801                        &format!("Invalid header: '{entry}'"),
802                        Some("Format: Key:Value, e.g. --header Content-Type:application/json"),
803                    )
804                },
805                |(k, v)| (k.trim().to_string(), v.trim().to_string()),
806            )
807        })
808        .collect()
809}
810
811fn resolve_body(data: Option<&str>, data_file: Option<&str>) -> Option<serde_json::Value> {
812    if let Some(raw) = data {
813        match serde_json::from_str(raw) {
814            Ok(v) => return Some(v),
815            Err(e) => exit_error(
816                &format!("Invalid JSON in --data: {e}"),
817                Some("Provide valid JSON string"),
818            ),
819        }
820    }
821
822    if let Some(file) = data_file {
823        return match read_json_from_file(file) {
824            Ok(v) => Some(v),
825            Err(e) => exit_error(&e, Some("Provide a valid JSON file or use '-' for stdin")),
826        };
827    }
828
829    None
830}
831
832#[cfg(test)]
833fn unwrap_resume_payload<'a>(payload: &'a Value) -> &'a Value {
834    if payload
835        .get("schema_version")
836        .and_then(Value::as_str)
837        .is_some_and(|value| value == "write_preflight.v1")
838    {
839        return payload;
840    }
841    if let Some(body) = payload.get("body") {
842        return unwrap_resume_payload(body);
843    }
844    if let Some(received) = payload.get("received") {
845        return unwrap_resume_payload(received);
846    }
847    payload
848}
849
850#[cfg(test)]
851fn extract_resume_clarification_prompts(payload: &Value) -> Vec<ResumeClarificationPrompt> {
852    let root = unwrap_resume_payload(payload);
853    root.get("blockers")
854        .and_then(Value::as_array)
855        .into_iter()
856        .flatten()
857        .filter_map(|blocker| blocker.get("details"))
858        .filter_map(|details| details.get("clarification_prompts"))
859        .filter_map(Value::as_array)
860        .flatten()
861        .filter_map(|prompt| {
862            let prompt_id = prompt.get("prompt_id")?.as_str()?;
863            let prompt_id = Uuid::parse_str(prompt_id).ok()?;
864            let scope_kind = prompt.get("scope_kind")?.as_str()?.trim().to_string();
865            let accepted_resolution_fields = prompt
866                .get("accepted_resolution_fields")
867                .and_then(Value::as_array)
868                .map(|fields| {
869                    fields
870                        .iter()
871                        .filter_map(Value::as_str)
872                        .map(str::trim)
873                        .filter(|field| !field.is_empty())
874                        .map(str::to_string)
875                        .collect::<Vec<_>>()
876                })
877                .unwrap_or_default();
878            Some(ResumeClarificationPrompt {
879                prompt_id,
880                scope_kind,
881                accepted_resolution_fields,
882            })
883        })
884        .collect()
885}
886
887#[cfg(test)]
888fn select_resume_clarification_prompt(
889    prompts: &[ResumeClarificationPrompt],
890    explicit_prompt_id: Option<Uuid>,
891) -> Result<ResumeClarificationPrompt, String> {
892    if prompts.is_empty() {
893        return Err(
894            "resume_file does not contain a clarification_required blocker with clarification_prompts"
895                .to_string(),
896        );
897    }
898
899    if let Some(prompt_id) = explicit_prompt_id {
900        return prompts
901            .iter()
902            .find(|prompt| prompt.prompt_id == prompt_id)
903            .cloned()
904            .ok_or_else(|| {
905                format!("resume_file does not contain clarification prompt {prompt_id}")
906            });
907    }
908
909    if prompts.len() == 1 {
910        return Ok(prompts[0].clone());
911    }
912
913    Err(
914        "resume_file contains multiple clarification prompts; provide --clarification-prompt-id"
915            .to_string(),
916    )
917}
918
919#[cfg(test)]
920fn build_confirmation_payload(
921    schema_version: &str,
922    confirmation_token: &str,
923    docs_hint: &str,
924) -> serde_json::Value {
925    let token = confirmation_token.trim();
926    if token.is_empty() {
927        exit_error(
928            &format!("{schema_version} confirmation token must not be empty"),
929            Some(docs_hint),
930        );
931    }
932    json!({
933        "schema_version": schema_version,
934        "confirmed": true,
935        "confirmed_at": Utc::now().to_rfc3339(),
936        "confirmation_token": token,
937    })
938}
939
940#[cfg(test)]
941fn build_non_trivial_confirmation_from_token(confirmation_token: &str) -> serde_json::Value {
942    build_confirmation_payload(
943        "non_trivial_confirmation.v1",
944        confirmation_token,
945        "Use the confirmation token from claim_guard.non_trivial_confirmation_challenge.",
946    )
947}
948
949#[cfg(test)]
950fn build_high_impact_confirmation_from_token(confirmation_token: &str) -> serde_json::Value {
951    build_confirmation_payload(
952        "high_impact_confirmation.v1",
953        confirmation_token,
954        "Use the confirmation token from the prior high-impact confirm-first response.",
955    )
956}
957
958#[cfg(test)]
959const PLAN_UPDATE_VOLUME_DELTA_HIGH_IMPACT_ABS_GTE: f64 = 15.0;
960#[cfg(test)]
961const PLAN_UPDATE_INTENSITY_DELTA_HIGH_IMPACT_ABS_GTE: f64 = 10.0;
962#[cfg(test)]
963const PLAN_UPDATE_FREQUENCY_DELTA_HIGH_IMPACT_ABS_GTE: f64 = 2.0;
964#[cfg(test)]
965const PLAN_UPDATE_DURATION_DELTA_WEEKS_HIGH_IMPACT_ABS_GTE: f64 = 2.0;
966#[cfg(test)]
967const SCHEDULE_EXCEPTION_LOW_IMPACT_MAX_RANGE_DAYS: i64 = 14;
968
969#[cfg(test)]
970fn normalized_event_type(event: &Value) -> Option<String> {
971    event
972        .get("event_type")
973        .and_then(Value::as_str)
974        .map(str::trim)
975        .filter(|value| !value.is_empty())
976        .map(|value| value.to_lowercase())
977}
978
979#[cfg(test)]
980fn is_always_high_impact_event_type(event_type: &str) -> bool {
981    matches!(
982        event_type.trim().to_lowercase().as_str(),
983        "training_plan.created"
984            | "training_plan.archived"
985            | "projection_rule.created"
986            | "projection_rule.archived"
987            | "weight_target.set"
988            | "sleep_target.set"
989            | "nutrition_target.set"
990            | "workflow.profile_completion.closed"
991            | "workflow.profile_completion.override_granted"
992            | "workflow.profile_completion.aborted"
993            | "workflow.profile_completion.restarted"
994    )
995}
996
997#[cfg(test)]
998fn read_abs_f64(value: Option<&Value>) -> Option<f64> {
999    let raw = value?;
1000    if let Some(number) = raw.as_f64() {
1001        return Some(number.abs());
1002    }
1003    if let Some(number) = raw.as_i64() {
1004        return Some((number as f64).abs());
1005    }
1006    if let Some(number) = raw.as_u64() {
1007        return Some((number as f64).abs());
1008    }
1009    raw.as_str()
1010        .and_then(|text| text.trim().parse::<f64>().ok())
1011        .map(f64::abs)
1012}
1013
1014#[cfg(test)]
1015fn read_plan_delta_abs(data: &Value, keys: &[&str]) -> Option<f64> {
1016    for key in keys {
1017        if let Some(number) = read_abs_f64(data.get(*key)) {
1018            return Some(number);
1019        }
1020        if let Some(number) = read_abs_f64(data.get("delta").and_then(|delta| delta.get(*key))) {
1021            return Some(number);
1022        }
1023    }
1024    None
1025}
1026
1027#[cfg(test)]
1028fn read_bool_like(value: Option<&Value>) -> Option<bool> {
1029    let raw = value?;
1030    if let Some(boolean) = raw.as_bool() {
1031        return Some(boolean);
1032    }
1033    if let Some(number) = raw.as_i64() {
1034        return match number {
1035            0 => Some(false),
1036            1 => Some(true),
1037            _ => None,
1038        };
1039    }
1040    raw.as_str()
1041        .and_then(|text| match text.trim().to_lowercase().as_str() {
1042            "true" | "yes" | "ja" | "1" | "on" | "active" => Some(true),
1043            "false" | "no" | "nein" | "0" | "off" | "inactive" => Some(false),
1044            _ => None,
1045        })
1046}
1047
1048#[cfg(test)]
1049fn parse_local_date_value(value: Option<&Value>) -> Option<chrono::NaiveDate> {
1050    value
1051        .and_then(Value::as_str)
1052        .map(str::trim)
1053        .filter(|value| !value.is_empty())
1054        .and_then(|value| chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").ok())
1055}
1056
1057#[cfg(test)]
1058fn selector_has_explicit_occurrence_anchor(selector: &Value) -> bool {
1059    selector
1060        .get("occurrence_id")
1061        .and_then(Value::as_str)
1062        .map(str::trim)
1063        .filter(|value| !value.is_empty())
1064        .is_some()
1065        || selector
1066            .get("occurrence_ids")
1067            .and_then(Value::as_array)
1068            .map(|values| {
1069                values.iter().any(|value| {
1070                    value
1071                        .as_str()
1072                        .map(str::trim)
1073                        .filter(|raw| !raw.is_empty())
1074                        .is_some()
1075                })
1076            })
1077            .unwrap_or(false)
1078}
1079
1080#[cfg(test)]
1081fn selector_has_bounded_temporal_anchor(selector: &Value) -> bool {
1082    if selector_has_explicit_occurrence_anchor(selector) {
1083        return true;
1084    }
1085    if parse_local_date_value(selector.get("local_date").or_else(|| selector.get("date"))).is_some()
1086    {
1087        return true;
1088    }
1089    if selector
1090        .get("local_dates")
1091        .and_then(Value::as_array)
1092        .map(|values| {
1093            values
1094                .iter()
1095                .any(|value| parse_local_date_value(Some(value)).is_some())
1096        })
1097        .unwrap_or(false)
1098    {
1099        return true;
1100    }
1101    if parse_local_date_value(selector.get("week_of")).is_some() {
1102        return true;
1103    }
1104
1105    let date_range = selector
1106        .get("date_range")
1107        .or_else(|| selector.get("between"))
1108        .unwrap_or(&Value::Null);
1109    let start = parse_local_date_value(date_range.get("start").or_else(|| date_range.get("from")));
1110    let end = parse_local_date_value(date_range.get("end").or_else(|| date_range.get("to")));
1111    match (start, end) {
1112        (Some(start), Some(end)) if end >= start => {
1113            (end - start).num_days() <= SCHEDULE_EXCEPTION_LOW_IMPACT_MAX_RANGE_DAYS
1114        }
1115        _ => false,
1116    }
1117}
1118
1119#[cfg(test)]
1120fn schedule_exception_scope_is_high_impact(data: &Value) -> bool {
1121    let scope_value = data
1122        .get("change_scope")
1123        .or_else(|| data.get("update_scope"))
1124        .or_else(|| {
1125            data.get("scope")
1126                .and_then(|scope| scope.get("change_scope"))
1127        })
1128        .or_else(|| data.get("scope").and_then(|scope| scope.get("scope")))
1129        .and_then(Value::as_str)
1130        .map(|raw| raw.trim().to_lowercase());
1131    if matches!(
1132        scope_value.as_deref(),
1133        Some(
1134            "bulk"
1135                | "future_block"
1136                | "full_rewrite"
1137                | "template_rewrite"
1138                | "replace_future_schedule"
1139                | "mesocycle_reset"
1140                | "phase_shift"
1141        )
1142    ) {
1143        return true;
1144    }
1145
1146    for key in ["days_affected", "occurrences_affected"] {
1147        if read_abs_f64(data.get("scope").and_then(|scope| scope.get(key))).unwrap_or(0.0)
1148            > SCHEDULE_EXCEPTION_LOW_IMPACT_MAX_RANGE_DAYS as f64
1149        {
1150            return true;
1151        }
1152    }
1153    if read_abs_f64(
1154        data.get("scope")
1155            .and_then(|scope| scope.get("weeks_affected")),
1156    )
1157    .unwrap_or(0.0)
1158        > 2.0
1159    {
1160        return true;
1161    }
1162    false
1163}
1164
1165#[cfg(test)]
1166fn training_schedule_exception_is_high_impact(event_type: &str, data: &Value) -> bool {
1167    if read_bool_like(data.get("requires_explicit_confirmation")).unwrap_or(false)
1168        || read_bool_like(data.get("rewrite_template")).unwrap_or(false)
1169        || read_bool_like(data.get("replace_future_schedule")).unwrap_or(false)
1170        || read_bool_like(data.get("replace_entire_weekly_template")).unwrap_or(false)
1171        || read_bool_like(data.get("clear_all")).unwrap_or(false)
1172        || schedule_exception_scope_is_high_impact(data)
1173    {
1174        return true;
1175    }
1176
1177    match event_type {
1178        "training_schedule.exception.cleared" => false,
1179        "training_schedule.exception.upsert" => {
1180            let selector = data.get("selector").unwrap_or(&Value::Null);
1181            !selector_has_bounded_temporal_anchor(selector)
1182        }
1183        _ => true,
1184    }
1185}
1186
1187#[cfg(test)]
1188fn training_plan_update_is_high_impact(data: &Value) -> bool {
1189    let scope = data
1190        .get("change_scope")
1191        .or_else(|| data.get("update_scope"))
1192        .and_then(Value::as_str)
1193        .map(|raw| raw.trim().to_lowercase());
1194    if matches!(
1195        scope.as_deref(),
1196        Some(
1197            "full_rewrite" | "structural" | "major_adjustment" | "mesocycle_reset" | "phase_shift"
1198        )
1199    ) {
1200        return true;
1201    }
1202
1203    if data
1204        .get("replace_entire_plan")
1205        .and_then(Value::as_bool)
1206        .unwrap_or(false)
1207        || data
1208            .get("archive_previous_plan")
1209            .and_then(Value::as_bool)
1210            .unwrap_or(false)
1211        || data
1212            .get("requires_explicit_confirmation")
1213            .and_then(Value::as_bool)
1214            .unwrap_or(false)
1215    {
1216        return true;
1217    }
1218
1219    let volume_delta = read_plan_delta_abs(
1220        data,
1221        &[
1222            "volume_delta_pct",
1223            "planned_volume_delta_pct",
1224            "total_volume_delta_pct",
1225        ],
1226    )
1227    .unwrap_or(0.0);
1228    if volume_delta >= PLAN_UPDATE_VOLUME_DELTA_HIGH_IMPACT_ABS_GTE {
1229        return true;
1230    }
1231
1232    let intensity_delta = read_plan_delta_abs(
1233        data,
1234        &[
1235            "intensity_delta_pct",
1236            "rir_delta",
1237            "rpe_delta",
1238            "effort_delta_pct",
1239        ],
1240    )
1241    .unwrap_or(0.0);
1242    if intensity_delta >= PLAN_UPDATE_INTENSITY_DELTA_HIGH_IMPACT_ABS_GTE {
1243        return true;
1244    }
1245
1246    let frequency_delta = read_plan_delta_abs(
1247        data,
1248        &["frequency_delta_per_week", "sessions_per_week_delta"],
1249    )
1250    .unwrap_or(0.0);
1251    if frequency_delta >= PLAN_UPDATE_FREQUENCY_DELTA_HIGH_IMPACT_ABS_GTE {
1252        return true;
1253    }
1254
1255    let duration_delta = read_plan_delta_abs(
1256        data,
1257        &["cycle_length_weeks_delta", "plan_duration_weeks_delta"],
1258    )
1259    .unwrap_or(0.0);
1260    duration_delta >= PLAN_UPDATE_DURATION_DELTA_WEEKS_HIGH_IMPACT_ABS_GTE
1261}
1262
1263#[cfg(test)]
1264fn is_high_impact_event(event: &Value) -> bool {
1265    let Some(event_type) = normalized_event_type(event) else {
1266        return false;
1267    };
1268    if event_type == "training_plan.updated" {
1269        return event
1270            .get("data")
1271            .is_some_and(training_plan_update_is_high_impact);
1272    }
1273    if event_type == "training_schedule.exception.upsert"
1274        || event_type == "training_schedule.exception.cleared"
1275    {
1276        return event
1277            .get("data")
1278            .is_some_and(|data| training_schedule_exception_is_high_impact(&event_type, data));
1279    }
1280    is_always_high_impact_event_type(&event_type)
1281}
1282
1283#[cfg(test)]
1284fn has_high_impact_events(events: &[Value]) -> bool {
1285    events.iter().any(is_high_impact_event)
1286}
1287
1288#[cfg(test)]
1289fn extract_temporal_basis_from_context_body(body: &Value) -> Option<Value> {
1290    body.pointer("/meta/temporal_basis")
1291        .cloned()
1292        .filter(|value| value.is_object())
1293}
1294
1295#[cfg(test)]
1296fn build_default_intent_handshake(
1297    events: &[serde_json::Value],
1298    intent_goal: Option<&str>,
1299    temporal_basis: serde_json::Value,
1300) -> serde_json::Value {
1301    let event_types: Vec<String> = events.iter().filter_map(normalized_event_type).collect();
1302    let planned_action = if event_types.is_empty() {
1303        "apply high-impact structured write update".to_string()
1304    } else {
1305        format!("write events: {}", event_types.join(", "))
1306    };
1307
1308    json!({
1309        "schema_version": "intent_handshake.v1",
1310        "goal": intent_goal.unwrap_or("execute requested high-impact write safely"),
1311        "planned_action": planned_action,
1312        "assumptions": ["context and request intent are current"],
1313        "non_goals": ["no unrelated writes outside current task scope"],
1314        "impact_class": "high_impact_write",
1315        "success_criteria": "structured write returns verification and claim_guard for this action",
1316        "created_at": chrono::Utc::now().to_rfc3339(),
1317        "handshake_id": format!("cli-hs-{}", Uuid::now_v7()),
1318        "temporal_basis": temporal_basis,
1319    })
1320}
1321
1322#[cfg(test)]
1323fn parse_targets(raw_targets: &[String]) -> Vec<serde_json::Value> {
1324    raw_targets
1325        .iter()
1326        .map(|raw| {
1327            let (projection_type, key) = raw.split_once(':').unwrap_or_else(|| {
1328                exit_error(
1329                    &format!("Invalid --target '{raw}'"),
1330                    Some("Use format projection_type:key, e.g. profile:overview"),
1331                )
1332            });
1333            let projection_type = projection_type.trim();
1334            let key = key.trim();
1335            if projection_type.is_empty() || key.is_empty() {
1336                exit_error(
1337                    &format!("Invalid --target '{raw}'"),
1338                    Some("projection_type and key must both be non-empty."),
1339                );
1340            }
1341            json!({
1342                "projection_type": projection_type,
1343                "key": key,
1344            })
1345        })
1346        .collect()
1347}
1348
1349#[cfg(test)]
1350fn extract_events_array(events_payload: serde_json::Value) -> Vec<serde_json::Value> {
1351    if let Some(events) = events_payload.as_array() {
1352        return events.to_vec();
1353    }
1354    if let Some(events) = events_payload
1355        .get("events")
1356        .and_then(|value| value.as_array())
1357    {
1358        return events.to_vec();
1359    }
1360    exit_error(
1361        "events payload must be an array or object with events array",
1362        Some("Example: --events-file events.json where file is [{...}] or {\"events\": [{...}]}"),
1363    );
1364}
1365
1366#[cfg(test)]
1367fn build_family_write_request(
1368    events: Vec<serde_json::Value>,
1369    parsed_targets: Vec<serde_json::Value>,
1370    verify_timeout_ms: Option<u64>,
1371    intent_handshake: Option<serde_json::Value>,
1372    high_impact_confirmation: Option<serde_json::Value>,
1373    non_trivial_confirmation: Option<serde_json::Value>,
1374    session_status: Option<SessionCompletionStatus>,
1375) -> serde_json::Value {
1376    let mut request = json!({
1377        "events": events,
1378        "read_after_write_targets": parsed_targets,
1379    });
1380    if let Some(timeout) = verify_timeout_ms {
1381        request["verify_timeout_ms"] = json!(timeout);
1382    }
1383    if let Some(intent_handshake) = intent_handshake {
1384        request["intent_handshake"] = intent_handshake;
1385    }
1386    if let Some(high_impact_confirmation) = high_impact_confirmation {
1387        request["high_impact_confirmation"] = high_impact_confirmation;
1388    }
1389    if let Some(non_trivial_confirmation) = non_trivial_confirmation {
1390        request["non_trivial_confirmation"] = non_trivial_confirmation;
1391    }
1392    if let Some(session_status) = session_status {
1393        request["session_completion"] = json!({
1394            "schema_version": "training_session_completion.v1",
1395            "status": session_status.as_str(),
1396        });
1397    }
1398    request
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403    use super::{
1404        ResumeClarificationPrompt, SaveConfirmationMode, SessionCompletionStatus,
1405        build_context_query, build_default_intent_handshake, build_family_write_request,
1406        build_high_impact_confirmation_from_token, build_logging_bootstrap_output,
1407        build_non_trivial_confirmation_from_token, build_section_fetch_query, extract_events_array,
1408        extract_logging_bootstrap_contract, extract_resume_clarification_prompts,
1409        extract_temporal_basis_from_context_body, has_high_impact_events,
1410        is_blocked_agent_request_path, normalize_agent_path, parse_method, parse_targets,
1411        select_resume_clarification_prompt,
1412    };
1413    use serde_json::json;
1414    use uuid::Uuid;
1415
1416    #[test]
1417    fn normalize_agent_path_accepts_relative_path() {
1418        assert_eq!(
1419            normalize_agent_path("evidence/event/abc"),
1420            "/v1/agent/evidence/event/abc"
1421        );
1422    }
1423
1424    #[test]
1425    fn normalize_agent_path_accepts_absolute_agent_path() {
1426        assert_eq!(
1427            normalize_agent_path("/v1/agent/context"),
1428            "/v1/agent/context"
1429        );
1430    }
1431
1432    #[test]
1433    fn extract_logging_bootstrap_contract_reads_logging_node() {
1434        let capabilities = json!({
1435            "task_bootstrap_contracts": {
1436                "logging": {
1437                    "schema_version": "agent_logging_bootstrap_contract.v1",
1438                    "task_family": "logging"
1439                }
1440            }
1441        });
1442        let contract =
1443            extract_logging_bootstrap_contract(&capabilities).expect("logging bootstrap contract");
1444        assert_eq!(
1445            contract["schema_version"],
1446            json!("agent_logging_bootstrap_contract.v1")
1447        );
1448        assert_eq!(contract["task_family"], json!("logging"));
1449    }
1450
1451    #[test]
1452    fn build_logging_bootstrap_output_selects_one_intent_recipe() {
1453        let contract = json!({
1454            "schema_version": "agent_logging_bootstrap_contract.v1",
1455            "task_family": "logging",
1456            "bootstrap_surface": "/v1/agent/capabilities",
1457            "intent_recipes": [
1458                {
1459                    "intent_id": "record_activity",
1460                    "endpoint": "/v4/agent/record-activity",
1461                    "cli_entrypoint": "kura record_activity"
1462                }
1463            ],
1464            "save_states": [{"save_state": "success"}],
1465            "upgrade_hints": [{"surface": "/v1/events"}],
1466            "integrity_guards": ["guard"]
1467        });
1468        let output = build_logging_bootstrap_output(&contract, Some("record_activity"))
1469            .expect("bootstrap output");
1470        assert_eq!(
1471            output["intent_recipe"]["intent_id"],
1472            json!("record_activity")
1473        );
1474        assert_eq!(
1475            output["intent_recipe"]["cli_entrypoint"],
1476            json!("kura record_activity")
1477        );
1478        assert_eq!(output["bootstrap_surface"], json!("/v1/agent/capabilities"));
1479        assert_eq!(output["save_states"][0]["save_state"], json!("success"));
1480    }
1481
1482    #[test]
1483    fn extract_resume_clarification_prompts_reads_blocked_response_shape() {
1484        let prompt_id = Uuid::now_v7();
1485        let prompts = extract_resume_clarification_prompts(&json!({
1486            "schema_version": "write_preflight.v1",
1487            "status": "blocked",
1488            "blockers": [
1489                {
1490                    "code": "logging_intent_clarification_required",
1491                    "details": {
1492                        "clarification_prompts": [
1493                            {
1494                                "prompt_id": prompt_id,
1495                                "scope_kind": "training_vs_test",
1496                                "accepted_resolution_fields": ["resolved_route_family"]
1497                            }
1498                        ]
1499                    }
1500                }
1501            ]
1502        }));
1503        assert_eq!(
1504            prompts,
1505            vec![ResumeClarificationPrompt {
1506                prompt_id,
1507                scope_kind: "training_vs_test".to_string(),
1508                accepted_resolution_fields: vec!["resolved_route_family".to_string()],
1509            }]
1510        );
1511    }
1512
1513    #[test]
1514    fn select_resume_clarification_prompt_accepts_single_prompt_without_explicit_id() {
1515        let prompt = ResumeClarificationPrompt {
1516            prompt_id: Uuid::now_v7(),
1517            scope_kind: "training_vs_test".to_string(),
1518            accepted_resolution_fields: vec!["resolved_route_family".to_string()],
1519        };
1520        let selected = select_resume_clarification_prompt(std::slice::from_ref(&prompt), None)
1521            .expect("prompt");
1522        assert_eq!(selected, prompt);
1523    }
1524
1525    #[test]
1526    fn parse_method_accepts_standard_http_methods() {
1527        for method in &[
1528            "get", "GET", "post", "PUT", "delete", "patch", "head", "OPTIONS",
1529        ] {
1530            let parsed = parse_method(method);
1531            assert!(!parsed.as_str().is_empty());
1532        }
1533    }
1534
1535    #[test]
1536    fn blocked_agent_request_paths_cover_legacy_workout_detours() {
1537        assert!(is_blocked_agent_request_path(
1538            "/v1/agent/exercise-resolve",
1539            &reqwest::Method::POST
1540        ));
1541        assert!(is_blocked_agent_request_path(
1542            "/v4/agent/write-event",
1543            &reqwest::Method::POST
1544        ));
1545        assert!(is_blocked_agent_request_path(
1546            "/v4/agent/write-correction",
1547            &reqwest::Method::POST
1548        ));
1549        assert!(!is_blocked_agent_request_path(
1550            "/v1/agent/context",
1551            &reqwest::Method::GET
1552        ));
1553    }
1554
1555    #[test]
1556    fn parse_targets_accepts_projection_type_key_format() {
1557        let parsed = parse_targets(&[
1558            "profile:overview".to_string(),
1559            "training_timeline:overview".to_string(),
1560        ]);
1561        assert_eq!(parsed[0]["projection_type"], "profile");
1562        assert_eq!(parsed[0]["key"], "overview");
1563        assert_eq!(parsed[1]["projection_type"], "training_timeline");
1564        assert_eq!(parsed[1]["key"], "overview");
1565    }
1566
1567    #[test]
1568    fn extract_events_array_supports_plain_array() {
1569        let events = extract_events_array(json!([
1570            {"event_type":"set.logged"},
1571            {"event_type":"metric.logged"}
1572        ]));
1573        assert_eq!(events.len(), 2);
1574    }
1575
1576    #[test]
1577    fn extract_events_array_supports_object_wrapper() {
1578        let events = extract_events_array(json!({
1579            "events": [{"event_type":"set.logged"}]
1580        }));
1581        assert_eq!(events.len(), 1);
1582    }
1583
1584    #[test]
1585    fn build_family_write_request_serializes_expected_fields() {
1586        let request = build_family_write_request(
1587            vec![json!({"event_type":"set.logged"})],
1588            vec![json!({"projection_type":"profile","key":"overview"})],
1589            Some(1200),
1590            None,
1591            None,
1592            None,
1593            None,
1594        );
1595        assert_eq!(request["events"].as_array().unwrap().len(), 1);
1596        assert_eq!(
1597            request["read_after_write_targets"]
1598                .as_array()
1599                .unwrap()
1600                .len(),
1601            1
1602        );
1603        assert_eq!(request["verify_timeout_ms"], 1200);
1604    }
1605
1606    #[test]
1607    fn build_family_write_request_includes_non_trivial_confirmation_when_present() {
1608        let request = build_family_write_request(
1609            vec![json!({"event_type":"set.logged"})],
1610            vec![json!({"projection_type":"profile","key":"overview"})],
1611            None,
1612            None,
1613            None,
1614            Some(json!({
1615                "schema_version": "non_trivial_confirmation.v1",
1616                "confirmed": true,
1617                "confirmed_at": "2026-02-25T12:00:00Z",
1618                "confirmation_token": "abc"
1619            })),
1620            None,
1621        );
1622        assert_eq!(
1623            request["non_trivial_confirmation"]["schema_version"],
1624            "non_trivial_confirmation.v1"
1625        );
1626        assert_eq!(
1627            request["non_trivial_confirmation"]["confirmation_token"],
1628            "abc"
1629        );
1630    }
1631
1632    #[test]
1633    fn build_family_write_request_includes_high_impact_fields_when_present() {
1634        let request = build_family_write_request(
1635            vec![json!({"event_type":"training_schedule.exception.upsert"})],
1636            vec![json!({"projection_type":"training_schedule","key":"effective"})],
1637            None,
1638            Some(json!({
1639                "schema_version": "intent_handshake.v1",
1640                "goal": "shift deload start",
1641                "impact_class": "high_impact_write",
1642                "temporal_basis": {
1643                    "schema_version": "temporal_basis.v1",
1644                    "context_generated_at": "2026-03-07T16:00:00Z",
1645                    "timezone": "Europe/Berlin",
1646                    "today_local_date": "2026-03-07"
1647                }
1648            })),
1649            Some(json!({
1650                "schema_version": "high_impact_confirmation.v1",
1651                "confirmed": true,
1652                "confirmed_at": "2026-03-07T16:05:00Z",
1653                "confirmation_token": "hi-123"
1654            })),
1655            None,
1656            None,
1657        );
1658        assert_eq!(
1659            request["intent_handshake"]["schema_version"],
1660            "intent_handshake.v1"
1661        );
1662        assert_eq!(
1663            request["high_impact_confirmation"]["confirmation_token"],
1664            "hi-123"
1665        );
1666    }
1667
1668    #[test]
1669    fn build_family_write_request_includes_session_completion_when_present() {
1670        let request = build_family_write_request(
1671            vec![json!({"event_type":"set.logged"})],
1672            vec![json!({"projection_type":"training_timeline","key":"today"})],
1673            None,
1674            None,
1675            None,
1676            None,
1677            Some(SessionCompletionStatus::Ongoing),
1678        );
1679        assert_eq!(
1680            request["session_completion"]["schema_version"],
1681            "training_session_completion.v1"
1682        );
1683        assert_eq!(request["session_completion"]["status"], "ongoing");
1684    }
1685
1686    #[test]
1687    fn build_non_trivial_confirmation_from_token_uses_expected_shape() {
1688        let payload = build_non_trivial_confirmation_from_token("tok-123");
1689        assert_eq!(payload["schema_version"], "non_trivial_confirmation.v1");
1690        assert_eq!(payload["confirmed"], true);
1691        assert_eq!(payload["confirmation_token"], "tok-123");
1692        assert!(payload["confirmed_at"].as_str().is_some());
1693    }
1694
1695    #[test]
1696    fn build_high_impact_confirmation_from_token_uses_expected_shape() {
1697        let payload = build_high_impact_confirmation_from_token("tok-456");
1698        assert_eq!(payload["schema_version"], "high_impact_confirmation.v1");
1699        assert_eq!(payload["confirmed"], true);
1700        assert_eq!(payload["confirmation_token"], "tok-456");
1701        assert!(payload["confirmed_at"].as_str().is_some());
1702    }
1703
1704    #[test]
1705    fn extract_temporal_basis_from_context_body_reads_meta_field() {
1706        let temporal_basis = extract_temporal_basis_from_context_body(&json!({
1707            "meta": {
1708                "temporal_basis": {
1709                    "schema_version": "temporal_basis.v1",
1710                    "timezone": "Europe/Berlin",
1711                    "today_local_date": "2026-03-07"
1712                }
1713            }
1714        }))
1715        .expect("temporal_basis must be extracted");
1716        assert_eq!(temporal_basis["schema_version"], "temporal_basis.v1");
1717        assert_eq!(temporal_basis["timezone"], "Europe/Berlin");
1718    }
1719
1720    #[test]
1721    fn build_default_intent_handshake_uses_event_types_and_temporal_basis() {
1722        let handshake = build_default_intent_handshake(
1723            &[json!({"event_type":"training_schedule.exception.upsert"})],
1724            Some("shift today's session"),
1725            json!({
1726                "schema_version": "temporal_basis.v1",
1727                "context_generated_at": "2026-03-07T16:00:00Z",
1728                "timezone": "Europe/Berlin",
1729                "today_local_date": "2026-03-07"
1730            }),
1731        );
1732        assert_eq!(handshake["schema_version"], "intent_handshake.v1");
1733        assert_eq!(handshake["goal"], "shift today's session");
1734        assert_eq!(handshake["impact_class"], "high_impact_write");
1735        assert_eq!(
1736            handshake["temporal_basis"]["today_local_date"],
1737            "2026-03-07"
1738        );
1739    }
1740
1741    #[test]
1742    fn high_impact_classification_keeps_bounded_schedule_exception_low_impact() {
1743        let events = vec![json!({
1744            "event_type": "training_schedule.exception.upsert",
1745            "data": {
1746                "exception_id": "deload-start-today",
1747                "operation": "patch",
1748                "selector": {
1749                    "local_date": "2026-03-07",
1750                    "session_name": "Technik + Power"
1751                },
1752                "progression_override": {
1753                    "deload_active": true,
1754                    "phase": "deload",
1755                    "volume_delta_pct": -30
1756                }
1757            }
1758        })];
1759        assert!(!has_high_impact_events(&events));
1760    }
1761
1762    #[test]
1763    fn high_impact_classification_escalates_unbounded_schedule_exception() {
1764        let events = vec![json!({
1765            "event_type": "training_schedule.exception.upsert",
1766            "data": {
1767                "exception_id": "rewrite-future-saturdays",
1768                "operation": "patch",
1769                "selector": {
1770                    "session_name": "Technik + Power"
1771                },
1772                "rewrite_template": true
1773            }
1774        })];
1775        assert!(has_high_impact_events(&events));
1776    }
1777
1778    #[test]
1779    fn save_confirmation_mode_serializes_expected_values() {
1780        assert_eq!(SaveConfirmationMode::Auto.as_str(), "auto");
1781        assert_eq!(SaveConfirmationMode::Always.as_str(), "always");
1782        assert_eq!(SaveConfirmationMode::Never.as_str(), "never");
1783    }
1784
1785    #[test]
1786    fn build_context_query_includes_budget_tokens_when_present() {
1787        let query = build_context_query(
1788            Some(3),
1789            Some(2),
1790            Some(1),
1791            Some("readiness check".to_string()),
1792            Some(false),
1793            Some(900),
1794        );
1795        assert!(query.contains(&("exercise_limit".to_string(), "3".to_string())));
1796        assert!(query.contains(&("strength_limit".to_string(), "2".to_string())));
1797        assert!(query.contains(&("custom_limit".to_string(), "1".to_string())));
1798        assert!(query.contains(&("task_intent".to_string(), "readiness check".to_string())));
1799        assert!(query.contains(&("include_system".to_string(), "false".to_string())));
1800        assert!(query.contains(&("budget_tokens".to_string(), "900".to_string())));
1801    }
1802
1803    #[test]
1804    fn build_context_query_supports_section_index_parity_params() {
1805        let query = build_context_query(
1806            Some(5),
1807            Some(5),
1808            Some(10),
1809            Some("startup".to_string()),
1810            Some(false),
1811            Some(1200),
1812        );
1813        assert!(query.contains(&("exercise_limit".to_string(), "5".to_string())));
1814        assert!(query.contains(&("strength_limit".to_string(), "5".to_string())));
1815        assert!(query.contains(&("custom_limit".to_string(), "10".to_string())));
1816        assert!(query.contains(&("task_intent".to_string(), "startup".to_string())));
1817        assert!(query.contains(&("include_system".to_string(), "false".to_string())));
1818        assert!(query.contains(&("budget_tokens".to_string(), "1200".to_string())));
1819    }
1820
1821    #[test]
1822    fn build_section_fetch_query_serializes_optional_params() {
1823        let query = build_section_fetch_query(
1824            "projections.exercise_progression".to_string(),
1825            Some(50),
1826            Some("abc123".to_string()),
1827            Some("data,meta".to_string()),
1828            Some("bench plateau".to_string()),
1829        );
1830        assert_eq!(
1831            query,
1832            vec![
1833                (
1834                    "section".to_string(),
1835                    "projections.exercise_progression".to_string(),
1836                ),
1837                ("limit".to_string(), "50".to_string()),
1838                ("cursor".to_string(), "abc123".to_string()),
1839                ("fields".to_string(), "data,meta".to_string()),
1840                ("task_intent".to_string(), "bench plateau".to_string()),
1841            ]
1842        );
1843    }
1844}