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 Capabilities,
16 #[command(visible_alias = "write-training")]
18 LogTraining(LogTrainingArgs),
19 WriteEvent(WriteEventArgs),
21 WriteCorrection(WriteCorrectionArgs),
23 LoggingBootstrap {
25 #[arg(long)]
27 intent: Option<String>,
28 },
29 Context {
31 #[arg(long)]
33 exercise_limit: Option<u32>,
34 #[arg(long)]
36 strength_limit: Option<u32>,
37 #[arg(long)]
39 custom_limit: Option<u32>,
40 #[arg(long)]
42 task_intent: Option<String>,
43 #[arg(long)]
45 include_system: Option<bool>,
46 #[arg(long)]
48 budget_tokens: Option<u32>,
49 },
50 SectionIndex {
52 #[arg(long)]
54 exercise_limit: Option<u32>,
55 #[arg(long)]
57 strength_limit: Option<u32>,
58 #[arg(long)]
60 custom_limit: Option<u32>,
61 #[arg(long)]
63 task_intent: Option<String>,
64 #[arg(long)]
66 include_system: Option<bool>,
67 #[arg(long)]
69 budget_tokens: Option<u32>,
70 },
71 SectionFetch {
73 #[arg(long)]
75 section: String,
76 #[arg(long)]
78 limit: Option<u32>,
79 #[arg(long)]
81 cursor: Option<String>,
82 #[arg(long)]
84 fields: Option<String>,
85 #[arg(long)]
87 task_intent: Option<String>,
88 },
89 AnswerAdmissibility {
91 #[arg(long)]
93 task_intent: String,
94 #[arg(long)]
96 draft_answer: String,
97 },
98 Evidence {
100 #[command(subcommand)]
101 command: AgentEvidenceCommands,
102 },
103 SetSaveConfirmationMode {
105 #[arg(value_enum)]
107 mode: SaveConfirmationMode,
108 },
109 ResolveVisualization(ResolveVisualizationArgs),
111 #[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 Event {
171 #[arg(long)]
173 event_id: Uuid,
174 },
175}
176
177#[derive(Args)]
178pub struct AgentRequestArgs {
179 pub method: String,
181
182 pub path: String,
184
185 #[arg(long, short = 'd')]
187 pub data: Option<String>,
188
189 #[arg(long, short = 'f', conflicts_with = "data")]
191 pub data_file: Option<String>,
192
193 #[arg(long, short = 'q')]
195 pub query: Vec<String>,
196
197 #[arg(long, short = 'H')]
199 pub header: Vec<String>,
200
201 #[arg(long)]
203 pub raw: bool,
204
205 #[arg(long, short = 'i')]
207 pub include: bool,
208}
209
210#[derive(Args)]
211pub struct LogTrainingArgs {
212 #[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 #[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 #[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 #[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 #[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 #[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 #[arg(long, conflicts_with = "task_intent")]
277 pub request_file: Option<String>,
278
279 #[arg(long, required_unless_present = "request_file")]
281 pub task_intent: Option<String>,
282
283 #[arg(long)]
285 pub user_preference_override: Option<String>,
286
287 #[arg(long)]
289 pub complexity_hint: Option<String>,
290
291 #[arg(long, default_value_t = true)]
293 pub allow_rich_rendering: bool,
294
295 #[arg(long)]
297 pub spec_file: Option<String>,
298
299 #[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}