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