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