1use std::io::Read;
2
3use chrono::{DateTime, Utc};
4use clap::{Args, Subcommand, ValueEnum};
5use serde_json::{Value, json};
6use uuid::Uuid;
7
8use crate::util::{
9 api_request, exit_error, print_json_stderr, print_json_stdout, raw_api_request,
10 raw_api_request_with_query, read_json_from_file,
11};
12
13#[derive(Subcommand)]
14pub enum AgentCommands {
15 Capabilities,
17 #[command(visible_alias = "write-training")]
19 LogTraining(LogTrainingArgs),
20 WriteEvent(WriteEventArgs),
22 WriteCorrection(WriteCorrectionArgs),
24 LogTurn(LogTurnArgs),
26 LoggingBootstrap {
28 #[arg(long)]
30 intent: Option<String>,
31 },
32 Context {
34 #[arg(long)]
36 exercise_limit: Option<u32>,
37 #[arg(long)]
39 strength_limit: Option<u32>,
40 #[arg(long)]
42 custom_limit: Option<u32>,
43 #[arg(long)]
45 task_intent: Option<String>,
46 #[arg(long)]
48 include_system: Option<bool>,
49 #[arg(long)]
51 budget_tokens: Option<u32>,
52 },
53 SectionIndex {
55 #[arg(long)]
57 exercise_limit: Option<u32>,
58 #[arg(long)]
60 strength_limit: Option<u32>,
61 #[arg(long)]
63 custom_limit: Option<u32>,
64 #[arg(long)]
66 task_intent: Option<String>,
67 #[arg(long)]
69 include_system: Option<bool>,
70 #[arg(long)]
72 budget_tokens: Option<u32>,
73 },
74 SectionFetch {
76 #[arg(long)]
78 section: String,
79 #[arg(long)]
81 limit: Option<u32>,
82 #[arg(long)]
84 cursor: Option<String>,
85 #[arg(long)]
87 fields: Option<String>,
88 #[arg(long)]
90 task_intent: Option<String>,
91 },
92 AnswerAdmissibility {
94 #[arg(long)]
96 task_intent: String,
97 #[arg(long)]
99 draft_answer: String,
100 },
101 #[command(name = "write-structured", alias = "write-with-proof")]
103 WriteWithProof(WriteWithProofArgs),
104 Evidence {
106 #[command(subcommand)]
107 command: AgentEvidenceCommands,
108 },
109 SetSaveConfirmationMode {
111 #[arg(value_enum)]
113 mode: SaveConfirmationMode,
114 },
115 ResolveVisualization(ResolveVisualizationArgs),
117 Request(AgentRequestArgs),
119}
120
121#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
122pub enum SaveConfirmationMode {
123 Auto,
124 Always,
125 Never,
126}
127
128#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
129pub enum SessionCompletionStatus {
130 Ongoing,
131 CompletedInBatch,
132}
133
134impl SessionCompletionStatus {
135 fn as_str(self) -> &'static str {
136 match self {
137 SessionCompletionStatus::Ongoing => "ongoing",
138 SessionCompletionStatus::CompletedInBatch => "completed_in_batch",
139 }
140 }
141}
142
143#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
144pub enum ConversationDraftMode {
145 Append,
146 Finalize,
147}
148
149impl ConversationDraftMode {
150 fn as_str(self) -> &'static str {
151 match self {
152 ConversationDraftMode::Append => "append",
153 ConversationDraftMode::Finalize => "finalize",
154 }
155 }
156}
157
158impl SaveConfirmationMode {
159 fn as_str(self) -> &'static str {
160 match self {
161 SaveConfirmationMode::Auto => "auto",
162 SaveConfirmationMode::Always => "always",
163 SaveConfirmationMode::Never => "never",
164 }
165 }
166}
167
168#[derive(Subcommand)]
169pub enum AgentEvidenceCommands {
170 Event {
172 #[arg(long)]
174 event_id: Uuid,
175 },
176}
177
178#[derive(Args)]
179pub struct AgentRequestArgs {
180 pub method: String,
182
183 pub path: String,
185
186 #[arg(long, short = 'd')]
188 pub data: Option<String>,
189
190 #[arg(long, short = 'f', conflicts_with = "data")]
192 pub data_file: Option<String>,
193
194 #[arg(long, short = 'q')]
196 pub query: Vec<String>,
197
198 #[arg(long, short = 'H')]
200 pub header: Vec<String>,
201
202 #[arg(long)]
204 pub raw: bool,
205
206 #[arg(long, short = 'i')]
208 pub include: bool,
209}
210
211#[derive(Args)]
212pub struct LogTrainingArgs {
213 #[arg(
215 long,
216 short = 'd',
217 required_unless_present = "request_file",
218 conflicts_with = "request_file"
219 )]
220 pub data: Option<String>,
221
222 #[arg(
224 long,
225 short = 'f',
226 required_unless_present = "data",
227 conflicts_with = "data"
228 )]
229 pub request_file: Option<String>,
230}
231
232#[derive(Args)]
233pub struct WriteEventArgs {
234 #[arg(
236 long,
237 short = 'd',
238 required_unless_present = "request_file",
239 conflicts_with = "request_file"
240 )]
241 pub data: Option<String>,
242
243 #[arg(
245 long,
246 short = 'f',
247 required_unless_present = "data",
248 conflicts_with = "data"
249 )]
250 pub request_file: Option<String>,
251}
252
253#[derive(Args)]
254pub struct WriteCorrectionArgs {
255 #[arg(
257 long,
258 short = 'd',
259 required_unless_present = "request_file",
260 conflicts_with = "request_file"
261 )]
262 pub data: Option<String>,
263
264 #[arg(
266 long,
267 short = 'f',
268 required_unless_present = "data",
269 conflicts_with = "data"
270 )]
271 pub request_file: Option<String>,
272}
273
274#[derive(Args)]
275pub struct LogTurnArgs {
276 #[arg(value_name = "MESSAGE", required_unless_present = "message_file")]
278 pub message: Option<String>,
279
280 #[arg(long, conflicts_with = "message")]
282 pub message_file: Option<String>,
283
284 #[arg(long)]
286 pub session_id: Option<String>,
287
288 #[arg(long)]
290 pub modality: Option<String>,
291
292 #[arg(long)]
294 pub recorded_at: Option<String>,
295
296 #[arg(long)]
298 pub observed_at: Option<String>,
299
300 #[arg(long)]
302 pub idempotency_key: Option<String>,
303}
304
305#[derive(Args)]
306pub struct WriteWithProofArgs {
307 #[arg(
309 long,
310 required_unless_present = "request_file",
311 conflicts_with = "request_file"
312 )]
313 pub events_file: Option<String>,
314
315 #[arg(
317 long,
318 required_unless_present = "request_file",
319 conflicts_with = "request_file"
320 )]
321 pub target: Vec<String>,
322
323 #[arg(long)]
325 pub verify_timeout_ms: Option<u64>,
326
327 #[arg(long, conflicts_with_all = ["request_file", "non_trivial_confirmation_file"])]
330 pub non_trivial_confirmation_token: Option<String>,
331
332 #[arg(long, conflicts_with_all = ["request_file", "non_trivial_confirmation_token"])]
334 pub non_trivial_confirmation_file: Option<String>,
335
336 #[arg(long, conflicts_with_all = ["request_file", "intent_handshake_file"])]
338 pub intent_goal: Option<String>,
339
340 #[arg(long, conflicts_with_all = ["request_file", "intent_goal"])]
342 pub intent_handshake_file: Option<String>,
343
344 #[arg(long, conflicts_with_all = ["request_file", "high_impact_confirmation_file"])]
346 pub high_impact_confirmation_token: Option<String>,
347
348 #[arg(long, conflicts_with_all = ["request_file", "high_impact_confirmation_token"])]
350 pub high_impact_confirmation_file: Option<String>,
351
352 #[arg(long, conflicts_with = "request_file")]
354 pub clarification_resolution_file: Vec<String>,
355
356 #[arg(long)]
358 pub resume_file: Option<String>,
359
360 #[arg(long)]
362 pub clarification_prompt_id: Option<Uuid>,
363
364 #[arg(long)]
366 pub clarification_route_family: Option<String>,
367
368 #[arg(long)]
370 pub clarification_protocol_variant: Option<String>,
371
372 #[arg(long)]
374 pub clarification_note: Option<String>,
375
376 #[arg(long, value_enum, conflicts_with = "request_file")]
378 pub session_status: Option<SessionCompletionStatus>,
379
380 #[arg(long, value_enum, conflicts_with = "request_file", hide = true)]
382 pub conversation_draft_mode: Option<ConversationDraftMode>,
383
384 #[arg(long, conflicts_with_all = ["events_file", "target", "verify_timeout_ms", "non_trivial_confirmation_token", "non_trivial_confirmation_file", "intent_goal", "intent_handshake_file", "high_impact_confirmation_token", "high_impact_confirmation_file", "clarification_resolution_file", "session_status", "conversation_draft_mode"])]
386 pub request_file: Option<String>,
387}
388
389#[derive(Args)]
390pub struct ResolveVisualizationArgs {
391 #[arg(long, conflicts_with = "task_intent")]
393 pub request_file: Option<String>,
394
395 #[arg(long, required_unless_present = "request_file")]
397 pub task_intent: Option<String>,
398
399 #[arg(long)]
401 pub user_preference_override: Option<String>,
402
403 #[arg(long)]
405 pub complexity_hint: Option<String>,
406
407 #[arg(long, default_value_t = true)]
409 pub allow_rich_rendering: bool,
410
411 #[arg(long)]
413 pub spec_file: Option<String>,
414
415 #[arg(long)]
417 pub telemetry_session_id: Option<String>,
418}
419
420pub async fn run(api_url: &str, token: Option<&str>, command: AgentCommands) -> i32 {
421 match command {
422 AgentCommands::Capabilities => capabilities(api_url, token).await,
423 AgentCommands::LogTraining(args) => log_training(api_url, token, args).await,
424 AgentCommands::WriteEvent(args) => write_event_vnext(api_url, token, args).await,
425 AgentCommands::WriteCorrection(args) => write_correction_vnext(api_url, token, args).await,
426 AgentCommands::LogTurn(args) => log_turn(api_url, token, args).await,
427 AgentCommands::LoggingBootstrap { intent } => {
428 logging_bootstrap(api_url, token, intent).await
429 }
430 AgentCommands::Context {
431 exercise_limit,
432 strength_limit,
433 custom_limit,
434 task_intent,
435 include_system,
436 budget_tokens,
437 } => {
438 context(
439 api_url,
440 token,
441 exercise_limit,
442 strength_limit,
443 custom_limit,
444 task_intent,
445 include_system,
446 budget_tokens,
447 )
448 .await
449 }
450 AgentCommands::SectionIndex {
451 exercise_limit,
452 strength_limit,
453 custom_limit,
454 task_intent,
455 include_system,
456 budget_tokens,
457 } => {
458 section_index(
459 api_url,
460 token,
461 exercise_limit,
462 strength_limit,
463 custom_limit,
464 task_intent,
465 include_system,
466 budget_tokens,
467 )
468 .await
469 }
470 AgentCommands::SectionFetch {
471 section,
472 limit,
473 cursor,
474 fields,
475 task_intent,
476 } => section_fetch(api_url, token, section, limit, cursor, fields, task_intent).await,
477 AgentCommands::AnswerAdmissibility {
478 task_intent,
479 draft_answer,
480 } => answer_admissibility(api_url, token, task_intent, draft_answer).await,
481 AgentCommands::WriteWithProof(args) => write_with_proof(api_url, token, args).await,
482 AgentCommands::Evidence { command } => match command {
483 AgentEvidenceCommands::Event { event_id } => {
484 evidence_event(api_url, token, event_id).await
485 }
486 },
487 AgentCommands::SetSaveConfirmationMode { mode } => {
488 set_save_confirmation_mode(api_url, token, mode).await
489 }
490 AgentCommands::ResolveVisualization(args) => {
491 resolve_visualization(api_url, token, args).await
492 }
493 AgentCommands::Request(args) => request(api_url, token, args).await,
494 }
495}
496
497async fn capabilities(api_url: &str, token: Option<&str>) -> i32 {
498 api_request(
499 api_url,
500 reqwest::Method::GET,
501 "/v1/agent/capabilities",
502 token,
503 None,
504 &[],
505 &[],
506 false,
507 false,
508 )
509 .await
510}
511
512fn normalize_logging_bootstrap_intent(intent: &str) -> Option<String> {
513 let normalized = intent.trim().to_ascii_lowercase();
514 if normalized.is_empty() {
515 None
516 } else {
517 Some(normalized)
518 }
519}
520
521fn extract_logging_bootstrap_contract(capabilities: &Value) -> Option<Value> {
522 capabilities
523 .pointer("/task_bootstrap_contracts/logging")
524 .cloned()
525 .filter(|value| value.is_object())
526}
527
528fn available_logging_bootstrap_intents(contract: &Value) -> Vec<String> {
529 let Some(recipes) = contract.get("intent_recipes").and_then(Value::as_array) else {
530 return Vec::new();
531 };
532 recipes
533 .iter()
534 .filter_map(|recipe| recipe.get("intent_id").and_then(Value::as_str))
535 .map(str::to_string)
536 .collect()
537}
538
539fn build_logging_bootstrap_output(contract: &Value, intent: Option<&str>) -> Result<Value, Value> {
540 let Some(intent) = intent else {
541 return Ok(contract.clone());
542 };
543 let Some(normalized_intent) = normalize_logging_bootstrap_intent(intent) else {
544 return Err(json!({
545 "error": "usage_error",
546 "message": "--intent must not be empty",
547 }));
548 };
549 let recipes = contract
550 .get("intent_recipes")
551 .and_then(Value::as_array)
552 .cloned()
553 .unwrap_or_default();
554 let Some(recipe) = recipes.into_iter().find(|recipe| {
555 recipe
556 .get("intent_id")
557 .and_then(Value::as_str)
558 .map(|value| value.eq_ignore_ascii_case(&normalized_intent))
559 .unwrap_or(false)
560 }) else {
561 return Err(json!({
562 "error": "usage_error",
563 "message": format!("Unknown logging bootstrap intent: {intent}"),
564 "available_intents": available_logging_bootstrap_intents(contract),
565 }));
566 };
567 Ok(json!({
568 "schema_version": contract.get("schema_version").cloned().unwrap_or(Value::Null),
569 "task_family": contract.get("task_family").cloned().unwrap_or(Value::Null),
570 "bootstrap_surface": contract.get("bootstrap_surface").cloned().unwrap_or(Value::Null),
571 "intent_recipe": recipe,
572 "save_states": contract.get("save_states").cloned().unwrap_or_else(|| json!([])),
573 "upgrade_hints": contract.get("upgrade_hints").cloned().unwrap_or_else(|| json!([])),
574 "integrity_guards": contract.get("integrity_guards").cloned().unwrap_or_else(|| json!([])),
575 }))
576}
577
578fn extract_preferred_structured_write_endpoint(capabilities: &Value) -> Option<String> {
579 capabilities
580 .get("preferred_structured_write_endpoint")
581 .or_else(|| capabilities.get("preferred_write_endpoint"))
582 .and_then(Value::as_str)
583 .map(str::trim)
584 .filter(|value| *value == "/v2/agent/write-with-proof")
585 .map(str::to_string)
586}
587
588async fn negotiated_write_with_proof_endpoint(api_url: &str, token: Option<&str>) -> String {
589 match raw_api_request(
590 api_url,
591 reqwest::Method::GET,
592 "/v1/agent/capabilities",
593 token,
594 )
595 .await
596 {
597 Ok((status, body)) if (200..=299).contains(&status) => {
598 extract_preferred_structured_write_endpoint(&body)
599 .unwrap_or_else(|| "/v2/agent/write-with-proof".to_string())
600 }
601 _ => "/v2/agent/write-with-proof".to_string(),
602 }
603}
604
605const LOG_TRAINING_SCHEMA_VERSION: &str = "write_training.v1";
606const LOG_TURN_SCHEMA_VERSION: &str = "agent_evidence_ingress_request.v1";
607const CLARIFICATION_RESOLUTION_SCHEMA_VERSION: &str = "agent_logging_clarification_resolution.v1";
608
609#[derive(Debug, Clone, PartialEq, Eq)]
610struct ResumeClarificationPrompt {
611 prompt_id: Uuid,
612 scope_kind: String,
613 accepted_resolution_fields: Vec<String>,
614}
615
616pub async fn log_turn(api_url: &str, token: Option<&str>, args: LogTurnArgs) -> i32 {
617 let body = build_log_turn_request(&args);
618 api_request(
619 api_url,
620 reqwest::Method::POST,
621 "/v3/agent/evidence",
622 token,
623 Some(body),
624 &[],
625 &[],
626 false,
627 false,
628 )
629 .await
630}
631
632pub async fn log_training(api_url: &str, token: Option<&str>, args: LogTrainingArgs) -> i32 {
633 let body = build_log_training_request(&args);
634 api_request(
635 api_url,
636 reqwest::Method::POST,
637 "/v4/agent/write-training",
638 token,
639 Some(body),
640 &[],
641 &[],
642 false,
643 false,
644 )
645 .await
646}
647
648pub async fn write_event_vnext(api_url: &str, token: Option<&str>, args: WriteEventArgs) -> i32 {
649 let body = resolve_write_vnext_request(
650 args.data.as_deref(),
651 args.request_file.as_deref(),
652 "write-event",
653 "/v4/agent/write-event",
654 );
655 api_request(
656 api_url,
657 reqwest::Method::POST,
658 "/v4/agent/write-event",
659 token,
660 Some(body),
661 &[],
662 &[],
663 false,
664 false,
665 )
666 .await
667}
668
669pub async fn write_correction_vnext(
670 api_url: &str,
671 token: Option<&str>,
672 args: WriteCorrectionArgs,
673) -> i32 {
674 let body = resolve_write_vnext_request(
675 args.data.as_deref(),
676 args.request_file.as_deref(),
677 "write-correction",
678 "/v4/agent/write-correction",
679 );
680 api_request(
681 api_url,
682 reqwest::Method::POST,
683 "/v4/agent/write-correction",
684 token,
685 Some(body),
686 &[],
687 &[],
688 false,
689 false,
690 )
691 .await
692}
693
694fn build_log_turn_request(args: &LogTurnArgs) -> Value {
695 let message = resolve_log_turn_message(args.message.as_deref(), args.message_file.as_deref());
696 let modality = normalize_non_empty_arg(args.modality.as_deref(), "--modality");
697 let recorded_at = normalize_rfc3339_arg(args.recorded_at.as_deref(), "--recorded-at");
698 let observed_at = normalize_rfc3339_arg(args.observed_at.as_deref(), "--observed-at");
699 let session_id = normalize_non_empty_arg(args.session_id.as_deref(), "--session-id");
700 let idempotency_key =
701 normalize_non_empty_arg(args.idempotency_key.as_deref(), "--idempotency-key");
702
703 let mut body = json!({
704 "schema_version": LOG_TURN_SCHEMA_VERSION,
705 "text_evidence": message,
706 "modality": modality.unwrap_or_else(|| "chat_message".to_string()),
707 "source": {
708 "surface": "cli",
709 "client": "kura-cli",
710 "command_family": "log_turn"
711 },
712 "metadata": {
713 "ingress_surface": "kura-cli.log-turn"
714 }
715 });
716 if let Some(session_id) = session_id {
717 body["session_hint"] = json!({
718 "session_id": session_id,
719 });
720 }
721 if let Some(recorded_at) = recorded_at {
722 body["recorded_at"] = json!(recorded_at);
723 }
724 if let Some(observed_at) = observed_at {
725 body["observed_at"] = json!(observed_at);
726 }
727 if let Some(idempotency_key) = idempotency_key {
728 body["idempotency_key"] = json!(idempotency_key);
729 }
730 body
731}
732
733fn build_log_training_request(args: &LogTrainingArgs) -> Value {
734 let mut body = resolve_log_training_request(args.data.as_deref(), args.request_file.as_deref());
735 let object = body.as_object_mut().unwrap_or_else(|| {
736 exit_error(
737 "log-training payload must be a JSON object",
738 Some(
739 "Pass a JSON object with date plus entries, for example via `kura log --request-file payload.json`.",
740 ),
741 )
742 });
743
744 object
745 .entry("schema_version".to_string())
746 .or_insert_with(|| json!(LOG_TRAINING_SCHEMA_VERSION));
747
748 let source_context_value = object
749 .entry("source_context".to_string())
750 .or_insert_with(|| json!({}));
751 let source_context = source_context_value.as_object_mut().unwrap_or_else(|| {
752 exit_error(
753 "log-training source_context must be a JSON object when provided",
754 Some("Use source_context as an object, or omit it."),
755 )
756 });
757 source_context
758 .entry("surface".to_string())
759 .or_insert_with(|| json!("cli"));
760 source_context
761 .entry("client".to_string())
762 .or_insert_with(|| json!("kura-cli"));
763 source_context
764 .entry("command_family".to_string())
765 .or_insert_with(|| json!("write_training"));
766
767 body
768}
769
770async fn logging_bootstrap(api_url: &str, token: Option<&str>, intent: Option<String>) -> i32 {
771 let (status, body) = raw_api_request(
772 api_url,
773 reqwest::Method::GET,
774 "/v1/agent/capabilities",
775 token,
776 )
777 .await
778 .unwrap_or_else(|error| {
779 exit_error(
780 &format!("Failed to fetch /v1/agent/capabilities for logging bootstrap: {error}"),
781 Some("Retry once the API is reachable, or fall back to `kura agent capabilities`."),
782 )
783 });
784
785 if !(200..=299).contains(&status) {
786 print_json_stderr(&body);
787 return if (400..500).contains(&status) { 1 } else { 2 };
788 }
789
790 let Some(contract) = extract_logging_bootstrap_contract(&body) else {
791 exit_error(
792 "agent capabilities response is missing task_bootstrap_contracts.logging",
793 Some("Retry after `kura agent capabilities` succeeds, or inspect the full manifest."),
794 );
795 };
796
797 match build_logging_bootstrap_output(&contract, intent.as_deref()) {
798 Ok(output) => {
799 print_json_stdout(&output);
800 0
801 }
802 Err(error) => {
803 print_json_stderr(&error);
804 4
805 }
806 }
807}
808
809pub async fn context(
810 api_url: &str,
811 token: Option<&str>,
812 exercise_limit: Option<u32>,
813 strength_limit: Option<u32>,
814 custom_limit: Option<u32>,
815 task_intent: Option<String>,
816 include_system: Option<bool>,
817 budget_tokens: Option<u32>,
818) -> i32 {
819 let query = build_context_query(
820 exercise_limit,
821 strength_limit,
822 custom_limit,
823 task_intent,
824 include_system,
825 budget_tokens,
826 );
827
828 api_request(
829 api_url,
830 reqwest::Method::GET,
831 "/v1/agent/context",
832 token,
833 None,
834 &query,
835 &[],
836 false,
837 false,
838 )
839 .await
840}
841
842pub async fn section_index(
843 api_url: &str,
844 token: Option<&str>,
845 exercise_limit: Option<u32>,
846 strength_limit: Option<u32>,
847 custom_limit: Option<u32>,
848 task_intent: Option<String>,
849 include_system: Option<bool>,
850 budget_tokens: Option<u32>,
851) -> i32 {
852 let query = build_context_query(
853 exercise_limit,
854 strength_limit,
855 custom_limit,
856 task_intent,
857 include_system,
858 budget_tokens,
859 );
860 api_request(
861 api_url,
862 reqwest::Method::GET,
863 "/v1/agent/context/section-index",
864 token,
865 None,
866 &query,
867 &[],
868 false,
869 false,
870 )
871 .await
872}
873
874pub async fn section_fetch(
875 api_url: &str,
876 token: Option<&str>,
877 section: String,
878 limit: Option<u32>,
879 cursor: Option<String>,
880 fields: Option<String>,
881 task_intent: Option<String>,
882) -> i32 {
883 let query = build_section_fetch_query(section, limit, cursor, fields, task_intent);
884 api_request(
885 api_url,
886 reqwest::Method::GET,
887 "/v1/agent/context/section-fetch",
888 token,
889 None,
890 &query,
891 &[],
892 false,
893 false,
894 )
895 .await
896}
897
898pub async fn answer_admissibility(
899 api_url: &str,
900 token: Option<&str>,
901 task_intent: String,
902 draft_answer: String,
903) -> i32 {
904 let body = json!({
905 "task_intent": task_intent,
906 "draft_answer": draft_answer,
907 });
908 api_request(
909 api_url,
910 reqwest::Method::POST,
911 "/v1/agent/answer-admissibility",
912 token,
913 Some(body),
914 &[],
915 &[],
916 false,
917 false,
918 )
919 .await
920}
921
922async fn evidence_event(api_url: &str, token: Option<&str>, event_id: Uuid) -> i32 {
923 let path = format!("/v1/agent/evidence/event/{event_id}");
924 api_request(
925 api_url,
926 reqwest::Method::GET,
927 &path,
928 token,
929 None,
930 &[],
931 &[],
932 false,
933 false,
934 )
935 .await
936}
937
938async fn set_save_confirmation_mode(
939 api_url: &str,
940 token: Option<&str>,
941 mode: SaveConfirmationMode,
942) -> i32 {
943 let body = json!({
944 "timestamp": Utc::now().to_rfc3339(),
945 "event_type": "preference.set",
946 "data": {
947 "key": "save_confirmation_mode",
948 "value": mode.as_str(),
949 },
950 "metadata": {
951 "source": "cli",
952 "agent": "kura-cli",
953 "idempotency_key": Uuid::now_v7().to_string(),
954 }
955 });
956 api_request(
957 api_url,
958 reqwest::Method::POST,
959 "/v1/events",
960 token,
961 Some(body),
962 &[],
963 &[],
964 false,
965 false,
966 )
967 .await
968}
969
970async fn request(api_url: &str, token: Option<&str>, args: AgentRequestArgs) -> i32 {
971 let method = parse_method(&args.method);
972 let path = normalize_agent_path(&args.path);
973 let query = parse_query_pairs(&args.query);
974 let headers = parse_headers(&args.header);
975 let body = resolve_body(args.data.as_deref(), args.data_file.as_deref());
976
977 api_request(
978 api_url,
979 method,
980 &path,
981 token,
982 body,
983 &query,
984 &headers,
985 args.raw,
986 args.include,
987 )
988 .await
989}
990
991pub async fn write_with_proof(api_url: &str, token: Option<&str>, args: WriteWithProofArgs) -> i32 {
992 let body = if let Some(file) = args.request_file.as_deref() {
993 let mut request = load_full_request(file);
994 if let Some(clarification_resolutions) = resolve_clarification_resolutions(
995 &args.clarification_resolution_file,
996 args.resume_file.as_deref(),
997 args.clarification_prompt_id,
998 args.clarification_route_family.as_deref(),
999 args.clarification_protocol_variant.as_deref(),
1000 args.clarification_note.as_deref(),
1001 ) {
1002 request["clarification_resolutions"] = json!(clarification_resolutions);
1003 }
1004 request
1005 } else {
1006 build_request_from_events_and_targets(
1007 api_url,
1008 token,
1009 args.events_file.as_deref().unwrap_or(""),
1010 &args.target,
1011 args.verify_timeout_ms,
1012 args.intent_goal.as_deref(),
1013 args.intent_handshake_file.as_deref(),
1014 args.high_impact_confirmation_token.as_deref(),
1015 args.high_impact_confirmation_file.as_deref(),
1016 args.non_trivial_confirmation_token.as_deref(),
1017 args.non_trivial_confirmation_file.as_deref(),
1018 &args.clarification_resolution_file,
1019 args.resume_file.as_deref(),
1020 args.clarification_prompt_id,
1021 args.clarification_route_family.as_deref(),
1022 args.clarification_protocol_variant.as_deref(),
1023 args.clarification_note.as_deref(),
1024 args.session_status,
1025 args.conversation_draft_mode,
1026 )
1027 .await
1028 };
1029 let write_endpoint = negotiated_write_with_proof_endpoint(api_url, token).await;
1030
1031 api_request(
1032 api_url,
1033 reqwest::Method::POST,
1034 &write_endpoint,
1035 token,
1036 Some(body),
1037 &[],
1038 &[],
1039 false,
1040 false,
1041 )
1042 .await
1043}
1044
1045async fn resolve_visualization(
1046 api_url: &str,
1047 token: Option<&str>,
1048 args: ResolveVisualizationArgs,
1049) -> i32 {
1050 let body = if let Some(file) = args.request_file.as_deref() {
1051 match read_json_from_file(file) {
1052 Ok(v) => v,
1053 Err(e) => exit_error(
1054 &e,
1055 Some("Provide a valid JSON payload for /v1/agent/visualization/resolve."),
1056 ),
1057 }
1058 } else {
1059 let task_intent = match args.task_intent {
1060 Some(intent) if !intent.trim().is_empty() => intent,
1061 _ => exit_error(
1062 "task_intent is required unless --request-file is used.",
1063 Some("Use --task-intent or provide --request-file."),
1064 ),
1065 };
1066
1067 let mut body = json!({
1068 "task_intent": task_intent,
1069 "allow_rich_rendering": args.allow_rich_rendering
1070 });
1071 if let Some(mode) = args.user_preference_override {
1072 body["user_preference_override"] = json!(mode);
1073 }
1074 if let Some(complexity) = args.complexity_hint {
1075 body["complexity_hint"] = json!(complexity);
1076 }
1077 if let Some(session_id) = args.telemetry_session_id {
1078 body["telemetry_session_id"] = json!(session_id);
1079 }
1080 if let Some(spec_file) = args.spec_file.as_deref() {
1081 let spec = match read_json_from_file(spec_file) {
1082 Ok(v) => v,
1083 Err(e) => exit_error(&e, Some("Provide a valid JSON visualization_spec payload.")),
1084 };
1085 body["visualization_spec"] = spec;
1086 }
1087 body
1088 };
1089
1090 api_request(
1091 api_url,
1092 reqwest::Method::POST,
1093 "/v1/agent/visualization/resolve",
1094 token,
1095 Some(body),
1096 &[],
1097 &[],
1098 false,
1099 false,
1100 )
1101 .await
1102}
1103
1104fn parse_method(raw: &str) -> reqwest::Method {
1105 match raw.to_uppercase().as_str() {
1106 "GET" => reqwest::Method::GET,
1107 "POST" => reqwest::Method::POST,
1108 "PUT" => reqwest::Method::PUT,
1109 "DELETE" => reqwest::Method::DELETE,
1110 "PATCH" => reqwest::Method::PATCH,
1111 "HEAD" => reqwest::Method::HEAD,
1112 "OPTIONS" => reqwest::Method::OPTIONS,
1113 other => exit_error(
1114 &format!("Unknown HTTP method: {other}"),
1115 Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
1116 ),
1117 }
1118}
1119
1120fn normalize_agent_path(raw: &str) -> String {
1121 let trimmed = raw.trim();
1122 if trimmed.is_empty() {
1123 exit_error(
1124 "Agent path must not be empty.",
1125 Some("Use relative path like 'context' or absolute path '/v1/agent/context'."),
1126 );
1127 }
1128
1129 if trimmed.starts_with("/v1/agent") || trimmed.starts_with("/v2/agent") {
1130 return trimmed.to_string();
1131 }
1132 if trimmed.starts_with("v1/agent") || trimmed.starts_with("v2/agent") {
1133 return format!("/{trimmed}");
1134 }
1135 if trimmed.starts_with('/') {
1136 exit_error(
1137 &format!("Invalid agent path '{trimmed}'."),
1138 Some(
1139 "`kura agent request` only supports /v1/agent/* or /v2/agent/* paths. Use `kura api` for other endpoints.",
1140 ),
1141 );
1142 }
1143
1144 format!("/v1/agent/{}", trimmed.trim_start_matches('/'))
1145}
1146
1147fn parse_query_pairs(raw: &[String]) -> Vec<(String, String)> {
1148 raw.iter()
1149 .map(|entry| {
1150 entry.split_once('=').map_or_else(
1151 || {
1152 exit_error(
1153 &format!("Invalid query parameter: '{entry}'"),
1154 Some("Format: key=value, e.g. --query event_type=set.logged"),
1155 )
1156 },
1157 |(k, v)| (k.to_string(), v.to_string()),
1158 )
1159 })
1160 .collect()
1161}
1162
1163fn build_context_query(
1164 exercise_limit: Option<u32>,
1165 strength_limit: Option<u32>,
1166 custom_limit: Option<u32>,
1167 task_intent: Option<String>,
1168 include_system: Option<bool>,
1169 budget_tokens: Option<u32>,
1170) -> Vec<(String, String)> {
1171 let mut query = Vec::new();
1172 if let Some(v) = exercise_limit {
1173 query.push(("exercise_limit".to_string(), v.to_string()));
1174 }
1175 if let Some(v) = strength_limit {
1176 query.push(("strength_limit".to_string(), v.to_string()));
1177 }
1178 if let Some(v) = custom_limit {
1179 query.push(("custom_limit".to_string(), v.to_string()));
1180 }
1181 if let Some(v) = task_intent {
1182 query.push(("task_intent".to_string(), v));
1183 }
1184 if let Some(v) = include_system {
1185 query.push(("include_system".to_string(), v.to_string()));
1186 }
1187 if let Some(v) = budget_tokens {
1188 query.push(("budget_tokens".to_string(), v.to_string()));
1189 }
1190 query
1191}
1192
1193fn build_section_fetch_query(
1194 section: String,
1195 limit: Option<u32>,
1196 cursor: Option<String>,
1197 fields: Option<String>,
1198 task_intent: Option<String>,
1199) -> Vec<(String, String)> {
1200 let section = section.trim();
1201 if section.is_empty() {
1202 exit_error(
1203 "section must not be empty",
1204 Some("Provide --section using an id from /v1/agent/context/section-index"),
1205 );
1206 }
1207 let mut query = vec![("section".to_string(), section.to_string())];
1208 if let Some(v) = limit {
1209 query.push(("limit".to_string(), v.to_string()));
1210 }
1211 if let Some(v) = cursor {
1212 query.push(("cursor".to_string(), v));
1213 }
1214 if let Some(v) = fields {
1215 query.push(("fields".to_string(), v));
1216 }
1217 if let Some(v) = task_intent {
1218 query.push(("task_intent".to_string(), v));
1219 }
1220 query
1221}
1222
1223fn parse_headers(raw: &[String]) -> Vec<(String, String)> {
1224 raw.iter()
1225 .map(|entry| {
1226 entry.split_once(':').map_or_else(
1227 || {
1228 exit_error(
1229 &format!("Invalid header: '{entry}'"),
1230 Some("Format: Key:Value, e.g. --header Content-Type:application/json"),
1231 )
1232 },
1233 |(k, v)| (k.trim().to_string(), v.trim().to_string()),
1234 )
1235 })
1236 .collect()
1237}
1238
1239fn resolve_body(data: Option<&str>, data_file: Option<&str>) -> Option<serde_json::Value> {
1240 if let Some(raw) = data {
1241 match serde_json::from_str(raw) {
1242 Ok(v) => return Some(v),
1243 Err(e) => exit_error(
1244 &format!("Invalid JSON in --data: {e}"),
1245 Some("Provide valid JSON string"),
1246 ),
1247 }
1248 }
1249
1250 if let Some(file) = data_file {
1251 return match read_json_from_file(file) {
1252 Ok(v) => Some(v),
1253 Err(e) => exit_error(&e, Some("Provide a valid JSON file or use '-' for stdin")),
1254 };
1255 }
1256
1257 None
1258}
1259
1260fn read_text_from_file(path: &str, docs_hint: &str) -> String {
1261 let raw = if path == "-" {
1262 let mut buffer = String::new();
1263 std::io::stdin()
1264 .read_to_string(&mut buffer)
1265 .unwrap_or_else(|error| {
1266 exit_error(&format!("Failed to read stdin: {error}"), Some(docs_hint))
1267 });
1268 buffer
1269 } else {
1270 std::fs::read_to_string(path).unwrap_or_else(|error| {
1271 exit_error(
1272 &format!("Failed to read file '{path}': {error}"),
1273 Some(docs_hint),
1274 )
1275 })
1276 };
1277 let trimmed = raw.trim();
1278 if trimmed.is_empty() {
1279 exit_error("log-turn message must not be empty", Some(docs_hint));
1280 }
1281 trimmed.to_string()
1282}
1283
1284fn resolve_log_turn_message(message: Option<&str>, message_file: Option<&str>) -> String {
1285 if let Some(path) = message_file {
1286 return read_text_from_file(
1287 path,
1288 "Use MESSAGE directly or provide --message-file with raw text (use '-' for stdin).",
1289 );
1290 }
1291
1292 let message = message
1293 .map(str::trim)
1294 .filter(|value| !value.is_empty())
1295 .unwrap_or_else(|| {
1296 exit_error(
1297 "MESSAGE must not be empty",
1298 Some("Pass the raw user turn directly, for example: `kura log \"bench 4x5 80\"`."),
1299 )
1300 });
1301 message.to_string()
1302}
1303
1304fn resolve_log_training_request(data: Option<&str>, request_file: Option<&str>) -> Value {
1305 match (data, request_file) {
1306 (Some(raw), None) => serde_json::from_str::<Value>(raw).unwrap_or_else(|error| {
1307 exit_error(
1308 &format!("failed to parse --data as JSON: {error}"),
1309 Some("Pass a full routine training JSON object."),
1310 )
1311 }),
1312 (None, Some(path)) => read_json_from_file(path).unwrap_or_else(|error| {
1313 exit_error(
1314 &format!("failed to read log-training request file: {error}"),
1315 Some("Pass a JSON object file, or use --data with inline JSON."),
1316 )
1317 }),
1318 _ => exit_error(
1319 "provide exactly one of --data or --request-file",
1320 Some("Use `kura log --request-file payload.json` for routine training logging."),
1321 ),
1322 }
1323}
1324
1325fn resolve_write_vnext_request(
1326 data: Option<&str>,
1327 request_file: Option<&str>,
1328 command_name: &str,
1329 endpoint: &str,
1330) -> Value {
1331 match (data, request_file) {
1332 (Some(raw), None) => serde_json::from_str::<Value>(raw).unwrap_or_else(|error| {
1333 exit_error(
1334 &format!("failed to parse --data as JSON: {error}"),
1335 Some(&format!("Pass a full JSON object for {endpoint}.")),
1336 )
1337 }),
1338 (None, Some(path)) => read_json_from_file(path).unwrap_or_else(|error| {
1339 exit_error(
1340 &format!("failed to read {command_name} request file: {error}"),
1341 Some(&format!(
1342 "Pass a JSON object file for {endpoint}, or use --data with inline JSON."
1343 )),
1344 )
1345 }),
1346 _ => exit_error(
1347 "provide exactly one of --data or --request-file",
1348 Some(&format!(
1349 "Use `kura agent {command_name} --request-file payload.json` for vNext writes."
1350 )),
1351 ),
1352 }
1353}
1354
1355fn normalize_non_empty_arg(raw: Option<&str>, field_name: &str) -> Option<String> {
1356 raw.map(str::trim)
1357 .filter(|value| !value.is_empty())
1358 .map(str::to_string)
1359 .or_else(|| {
1360 if raw.is_some() {
1361 exit_error(
1362 &format!("{field_name} must not be empty"),
1363 Some("Remove the flag or provide a non-empty value."),
1364 );
1365 }
1366 None
1367 })
1368}
1369
1370fn normalize_rfc3339_arg(raw: Option<&str>, field_name: &str) -> Option<String> {
1371 let normalized = normalize_non_empty_arg(raw, field_name)?;
1372 let parsed = DateTime::parse_from_rfc3339(&normalized).unwrap_or_else(|error| {
1373 exit_error(
1374 &format!("{field_name} must be RFC3339: {error}"),
1375 Some("Example: 2026-03-15T09:30:00Z"),
1376 )
1377 });
1378 Some(parsed.with_timezone(&Utc).to_rfc3339())
1379}
1380
1381fn load_full_request(path: &str) -> serde_json::Value {
1382 let mut payload = match read_json_from_file(path) {
1383 Ok(v) => v,
1384 Err(e) => exit_error(
1385 &e,
1386 Some(
1387 "Provide JSON with events, read_after_write_targets, and optional structured-write fields such as verify_timeout_ms or clarification_resolutions.",
1388 ),
1389 ),
1390 };
1391 if payload
1392 .get("events")
1393 .and_then(|value| value.as_array())
1394 .is_none()
1395 {
1396 exit_error(
1397 "request payload must include an events array",
1398 Some(
1399 "Use --request-file with {\"events\": [...], \"read_after_write_targets\": [...]} and optional clarification_resolutions/high_impact_confirmation fields.",
1400 ),
1401 );
1402 }
1403 if payload
1404 .get("read_after_write_targets")
1405 .and_then(|value| value.as_array())
1406 .is_none()
1407 {
1408 let conversation_draft_mode = payload
1409 .get("conversation_draft")
1410 .and_then(|value| value.get("mode"))
1411 .and_then(Value::as_str)
1412 .map(|value| value.trim().to_ascii_lowercase());
1413 if conversation_draft_mode.as_deref() == Some("append") {
1414 payload["read_after_write_targets"] = json!([]);
1415 return payload;
1416 }
1417 exit_error(
1418 "request payload must include read_after_write_targets array",
1419 Some("Set read_after_write_targets to [{\"projection_type\":\"...\",\"key\":\"...\"}]"),
1420 );
1421 }
1422 payload
1423}
1424
1425fn unwrap_resume_payload<'a>(payload: &'a Value) -> &'a Value {
1426 if payload
1427 .get("schema_version")
1428 .and_then(Value::as_str)
1429 .is_some_and(|value| value == "write_preflight.v1")
1430 {
1431 return payload;
1432 }
1433 if let Some(body) = payload.get("body") {
1434 return unwrap_resume_payload(body);
1435 }
1436 if let Some(received) = payload.get("received") {
1437 return unwrap_resume_payload(received);
1438 }
1439 payload
1440}
1441
1442fn extract_resume_clarification_prompts(payload: &Value) -> Vec<ResumeClarificationPrompt> {
1443 let root = unwrap_resume_payload(payload);
1444 root.get("blockers")
1445 .and_then(Value::as_array)
1446 .into_iter()
1447 .flatten()
1448 .filter_map(|blocker| blocker.get("details"))
1449 .filter_map(|details| details.get("clarification_prompts"))
1450 .filter_map(Value::as_array)
1451 .flatten()
1452 .filter_map(|prompt| {
1453 let prompt_id = prompt.get("prompt_id")?.as_str()?;
1454 let prompt_id = Uuid::parse_str(prompt_id).ok()?;
1455 let scope_kind = prompt.get("scope_kind")?.as_str()?.trim().to_string();
1456 let accepted_resolution_fields = prompt
1457 .get("accepted_resolution_fields")
1458 .and_then(Value::as_array)
1459 .map(|fields| {
1460 fields
1461 .iter()
1462 .filter_map(Value::as_str)
1463 .map(str::trim)
1464 .filter(|field| !field.is_empty())
1465 .map(str::to_string)
1466 .collect::<Vec<_>>()
1467 })
1468 .unwrap_or_default();
1469 Some(ResumeClarificationPrompt {
1470 prompt_id,
1471 scope_kind,
1472 accepted_resolution_fields,
1473 })
1474 })
1475 .collect()
1476}
1477
1478fn select_resume_clarification_prompt(
1479 prompts: &[ResumeClarificationPrompt],
1480 explicit_prompt_id: Option<Uuid>,
1481) -> Result<ResumeClarificationPrompt, String> {
1482 if prompts.is_empty() {
1483 return Err(
1484 "resume_file does not contain a clarification_required blocker with clarification_prompts"
1485 .to_string(),
1486 );
1487 }
1488
1489 if let Some(prompt_id) = explicit_prompt_id {
1490 return prompts
1491 .iter()
1492 .find(|prompt| prompt.prompt_id == prompt_id)
1493 .cloned()
1494 .ok_or_else(|| {
1495 format!("resume_file does not contain clarification prompt {prompt_id}")
1496 });
1497 }
1498
1499 if prompts.len() == 1 {
1500 return Ok(prompts[0].clone());
1501 }
1502
1503 Err(
1504 "resume_file contains multiple clarification prompts; provide --clarification-prompt-id"
1505 .to_string(),
1506 )
1507}
1508
1509fn load_resume_clarification_prompt(
1510 resume_file: &str,
1511 explicit_prompt_id: Option<Uuid>,
1512) -> ResumeClarificationPrompt {
1513 let payload = read_json_from_file(resume_file).unwrap_or_else(|error| {
1514 exit_error(
1515 &error,
1516 Some("Provide the blocked structured-write response JSON via --resume-file."),
1517 )
1518 });
1519 let prompts = extract_resume_clarification_prompts(&payload);
1520 select_resume_clarification_prompt(&prompts, explicit_prompt_id).unwrap_or_else(|error| {
1521 exit_error(
1522 &error,
1523 Some(
1524 "Use the prior blocked structured-write response body, or pass --clarification-prompt-id when multiple prompts are present.",
1525 ),
1526 )
1527 })
1528}
1529
1530async fn build_request_from_events_and_targets(
1531 api_url: &str,
1532 token: Option<&str>,
1533 events_file: &str,
1534 raw_targets: &[String],
1535 verify_timeout_ms: Option<u64>,
1536 intent_goal: Option<&str>,
1537 intent_handshake_file: Option<&str>,
1538 high_impact_confirmation_token: Option<&str>,
1539 high_impact_confirmation_file: Option<&str>,
1540 non_trivial_confirmation_token: Option<&str>,
1541 non_trivial_confirmation_file: Option<&str>,
1542 clarification_resolution_files: &[String],
1543 resume_file: Option<&str>,
1544 clarification_prompt_id: Option<Uuid>,
1545 clarification_route_family: Option<&str>,
1546 clarification_protocol_variant: Option<&str>,
1547 clarification_note: Option<&str>,
1548 session_status: Option<SessionCompletionStatus>,
1549 conversation_draft_mode: Option<ConversationDraftMode>,
1550) -> serde_json::Value {
1551 if raw_targets.is_empty() && conversation_draft_mode != Some(ConversationDraftMode::Append) {
1552 exit_error(
1553 "--target is required when --request-file is not used",
1554 Some("Repeat --target projection_type:key for read-after-write checks."),
1555 );
1556 }
1557
1558 let parsed_targets = parse_targets(raw_targets);
1559 let events_payload = match read_json_from_file(events_file) {
1560 Ok(v) => v,
1561 Err(e) => exit_error(
1562 &e,
1563 Some("Provide --events-file as JSON array or object with events array."),
1564 ),
1565 };
1566
1567 let events = extract_events_array(events_payload);
1568 let intent_handshake =
1569 resolve_intent_handshake(api_url, token, &events, intent_goal, intent_handshake_file).await;
1570 let high_impact_confirmation = resolve_high_impact_confirmation(
1571 high_impact_confirmation_token,
1572 high_impact_confirmation_file,
1573 );
1574 let non_trivial_confirmation = resolve_non_trivial_confirmation(
1575 non_trivial_confirmation_token,
1576 non_trivial_confirmation_file,
1577 );
1578 let clarification_resolutions = resolve_clarification_resolutions(
1579 clarification_resolution_files,
1580 resume_file,
1581 clarification_prompt_id,
1582 clarification_route_family,
1583 clarification_protocol_variant,
1584 clarification_note,
1585 );
1586 build_write_with_proof_request(
1587 events,
1588 parsed_targets,
1589 verify_timeout_ms,
1590 intent_handshake,
1591 high_impact_confirmation,
1592 non_trivial_confirmation,
1593 clarification_resolutions,
1594 session_status,
1595 conversation_draft_mode,
1596 )
1597}
1598
1599fn resolve_optional_object_file(
1600 confirmation_file: Option<&str>,
1601 field_name: &str,
1602 docs_hint: &str,
1603) -> Option<serde_json::Value> {
1604 if let Some(path) = confirmation_file {
1605 let payload = match read_json_from_file(path) {
1606 Ok(v) => v,
1607 Err(e) => exit_error(&e, Some(docs_hint)),
1608 };
1609 if !payload.is_object() {
1610 exit_error(
1611 &format!("{field_name} payload must be a JSON object"),
1612 Some(docs_hint),
1613 );
1614 }
1615 return Some(payload);
1616 }
1617 None
1618}
1619
1620fn build_confirmation_payload(
1621 schema_version: &str,
1622 confirmation_token: &str,
1623 docs_hint: &str,
1624) -> serde_json::Value {
1625 let token = confirmation_token.trim();
1626 if token.is_empty() {
1627 exit_error(
1628 &format!("{schema_version} confirmation token must not be empty"),
1629 Some(docs_hint),
1630 );
1631 }
1632 json!({
1633 "schema_version": schema_version,
1634 "confirmed": true,
1635 "confirmed_at": Utc::now().to_rfc3339(),
1636 "confirmation_token": token,
1637 })
1638}
1639
1640fn resolve_non_trivial_confirmation(
1641 confirmation_token: Option<&str>,
1642 confirmation_file: Option<&str>,
1643) -> Option<serde_json::Value> {
1644 resolve_optional_object_file(
1645 confirmation_file,
1646 "non_trivial_confirmation",
1647 "Provide a valid JSON object for non_trivial_confirmation.v1.",
1648 )
1649 .or_else(|| confirmation_token.map(build_non_trivial_confirmation_from_token))
1650}
1651
1652fn resolve_high_impact_confirmation(
1653 confirmation_token: Option<&str>,
1654 confirmation_file: Option<&str>,
1655) -> Option<serde_json::Value> {
1656 resolve_optional_object_file(
1657 confirmation_file,
1658 "high_impact_confirmation",
1659 "Provide a valid JSON object for high_impact_confirmation.v1.",
1660 )
1661 .or_else(|| confirmation_token.map(build_high_impact_confirmation_from_token))
1662}
1663
1664fn build_non_trivial_confirmation_from_token(confirmation_token: &str) -> serde_json::Value {
1665 build_confirmation_payload(
1666 "non_trivial_confirmation.v1",
1667 confirmation_token,
1668 "Use the confirmation token from claim_guard.non_trivial_confirmation_challenge.",
1669 )
1670}
1671
1672fn build_high_impact_confirmation_from_token(confirmation_token: &str) -> serde_json::Value {
1673 build_confirmation_payload(
1674 "high_impact_confirmation.v1",
1675 confirmation_token,
1676 "Use the confirmation token from the prior high-impact confirm-first response.",
1677 )
1678}
1679
1680fn resolve_clarification_resolutions(
1681 clarification_resolution_files: &[String],
1682 resume_file: Option<&str>,
1683 clarification_prompt_id: Option<Uuid>,
1684 clarification_route_family: Option<&str>,
1685 clarification_protocol_variant: Option<&str>,
1686 clarification_note: Option<&str>,
1687) -> Option<Vec<serde_json::Value>> {
1688 let mut resolutions = Vec::new();
1689 for path in clarification_resolution_files {
1690 let payload = match read_json_from_file(path) {
1691 Ok(v) => v,
1692 Err(e) => exit_error(
1693 &e,
1694 Some("Provide a valid JSON object or array for clarification_resolutions entries."),
1695 ),
1696 };
1697 match payload {
1698 Value::Object(_) => resolutions.push(payload),
1699 Value::Array(entries) => {
1700 if entries.is_empty() {
1701 exit_error(
1702 "clarification_resolution_file must not contain an empty array",
1703 Some("Provide one resolution object or an array of resolution objects."),
1704 );
1705 }
1706 for (index, entry) in entries.into_iter().enumerate() {
1707 if !entry.is_object() {
1708 exit_error(
1709 &format!(
1710 "clarification_resolution_file entry {index} must be an object"
1711 ),
1712 Some(
1713 "Each clarification_resolutions entry must be a JSON object matching the server schema.",
1714 ),
1715 );
1716 }
1717 resolutions.push(entry);
1718 }
1719 }
1720 _ => exit_error(
1721 "clarification_resolution_file must contain a JSON object or array",
1722 Some(
1723 "Provide one resolution object or an array of resolution objects matching AgentLoggingClarificationResolution.",
1724 ),
1725 ),
1726 }
1727 }
1728
1729 let route_family =
1730 normalize_non_empty_arg(clarification_route_family, "--clarification-route-family");
1731 let protocol_variant = normalize_non_empty_arg(
1732 clarification_protocol_variant,
1733 "--clarification-protocol-variant",
1734 );
1735 let resolution_note = normalize_non_empty_arg(clarification_note, "--clarification-note");
1736
1737 let inline_requested = resume_file.is_some()
1738 || clarification_prompt_id.is_some()
1739 || route_family.is_some()
1740 || protocol_variant.is_some()
1741 || resolution_note.is_some();
1742
1743 if inline_requested {
1744 if route_family.is_none() && protocol_variant.is_none() && resolution_note.is_none() {
1745 exit_error(
1746 "Clarification retry requires an answer field.",
1747 Some(
1748 "Use --clarification-route-family or --clarification-protocol-variant, optionally plus --clarification-note.",
1749 ),
1750 );
1751 }
1752
1753 let prompt = if let Some(path) = resume_file {
1754 load_resume_clarification_prompt(path, clarification_prompt_id)
1755 } else {
1756 ResumeClarificationPrompt {
1757 prompt_id: clarification_prompt_id.unwrap_or_else(|| {
1758 exit_error(
1759 "Clarification retry requires --clarification-prompt-id when --resume-file is not used.",
1760 Some("Reuse the blocked response via --resume-file, or pass the prompt UUID directly."),
1761 )
1762 }),
1763 scope_kind: String::new(),
1764 accepted_resolution_fields: Vec::new(),
1765 }
1766 };
1767
1768 if prompt
1769 .accepted_resolution_fields
1770 .iter()
1771 .any(|field| field == "resolved_route_family")
1772 && route_family.is_none()
1773 {
1774 exit_error(
1775 "Clarification retry requires --clarification-route-family for this prompt.",
1776 Some(
1777 "Use the exact route-family answer the user provided, for example training_execution.",
1778 ),
1779 );
1780 }
1781 if prompt
1782 .accepted_resolution_fields
1783 .iter()
1784 .any(|field| field == "protocol_variant")
1785 && protocol_variant.is_none()
1786 {
1787 exit_error(
1788 "Clarification retry requires --clarification-protocol-variant for this prompt.",
1789 Some("Use the exact protocol variant the user provided, for example free_arms."),
1790 );
1791 }
1792
1793 let mut resolution = json!({
1794 "schema_version": CLARIFICATION_RESOLUTION_SCHEMA_VERSION,
1795 "prompt_id": prompt.prompt_id,
1796 });
1797 if let Some(route_family) = route_family {
1798 resolution["resolved_route_family"] = json!(route_family);
1799 }
1800 if let Some(protocol_variant) = protocol_variant {
1801 resolution["protocol_variant"] = json!(protocol_variant);
1802 }
1803 if let Some(resolution_note) = resolution_note {
1804 resolution["resolution_note"] = json!(resolution_note);
1805 }
1806 resolutions.push(resolution);
1807 }
1808
1809 if resolutions.is_empty() {
1810 None
1811 } else {
1812 Some(resolutions)
1813 }
1814}
1815
1816const PLAN_UPDATE_VOLUME_DELTA_HIGH_IMPACT_ABS_GTE: f64 = 15.0;
1817const PLAN_UPDATE_INTENSITY_DELTA_HIGH_IMPACT_ABS_GTE: f64 = 10.0;
1818const PLAN_UPDATE_FREQUENCY_DELTA_HIGH_IMPACT_ABS_GTE: f64 = 2.0;
1819const PLAN_UPDATE_DURATION_DELTA_WEEKS_HIGH_IMPACT_ABS_GTE: f64 = 2.0;
1820const SCHEDULE_EXCEPTION_LOW_IMPACT_MAX_RANGE_DAYS: i64 = 14;
1821
1822fn normalized_event_type(event: &Value) -> Option<String> {
1823 event
1824 .get("event_type")
1825 .and_then(Value::as_str)
1826 .map(str::trim)
1827 .filter(|value| !value.is_empty())
1828 .map(|value| value.to_lowercase())
1829}
1830
1831fn is_always_high_impact_event_type(event_type: &str) -> bool {
1832 matches!(
1833 event_type.trim().to_lowercase().as_str(),
1834 "training_plan.created"
1835 | "training_plan.archived"
1836 | "projection_rule.created"
1837 | "projection_rule.archived"
1838 | "weight_target.set"
1839 | "sleep_target.set"
1840 | "nutrition_target.set"
1841 | "workflow.onboarding.closed"
1842 | "workflow.onboarding.override_granted"
1843 | "workflow.onboarding.aborted"
1844 | "workflow.onboarding.restarted"
1845 )
1846}
1847
1848fn read_abs_f64(value: Option<&Value>) -> Option<f64> {
1849 let raw = value?;
1850 if let Some(number) = raw.as_f64() {
1851 return Some(number.abs());
1852 }
1853 if let Some(number) = raw.as_i64() {
1854 return Some((number as f64).abs());
1855 }
1856 if let Some(number) = raw.as_u64() {
1857 return Some((number as f64).abs());
1858 }
1859 raw.as_str()
1860 .and_then(|text| text.trim().parse::<f64>().ok())
1861 .map(f64::abs)
1862}
1863
1864fn read_plan_delta_abs(data: &Value, keys: &[&str]) -> Option<f64> {
1865 for key in keys {
1866 if let Some(number) = read_abs_f64(data.get(*key)) {
1867 return Some(number);
1868 }
1869 if let Some(number) = read_abs_f64(data.get("delta").and_then(|delta| delta.get(*key))) {
1870 return Some(number);
1871 }
1872 }
1873 None
1874}
1875
1876fn read_bool_like(value: Option<&Value>) -> Option<bool> {
1877 let raw = value?;
1878 if let Some(boolean) = raw.as_bool() {
1879 return Some(boolean);
1880 }
1881 if let Some(number) = raw.as_i64() {
1882 return match number {
1883 0 => Some(false),
1884 1 => Some(true),
1885 _ => None,
1886 };
1887 }
1888 raw.as_str()
1889 .and_then(|text| match text.trim().to_lowercase().as_str() {
1890 "true" | "yes" | "ja" | "1" | "on" | "active" => Some(true),
1891 "false" | "no" | "nein" | "0" | "off" | "inactive" => Some(false),
1892 _ => None,
1893 })
1894}
1895
1896fn parse_local_date_value(value: Option<&Value>) -> Option<chrono::NaiveDate> {
1897 value
1898 .and_then(Value::as_str)
1899 .map(str::trim)
1900 .filter(|value| !value.is_empty())
1901 .and_then(|value| chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").ok())
1902}
1903
1904fn selector_has_explicit_occurrence_anchor(selector: &Value) -> bool {
1905 selector
1906 .get("occurrence_id")
1907 .and_then(Value::as_str)
1908 .map(str::trim)
1909 .filter(|value| !value.is_empty())
1910 .is_some()
1911 || selector
1912 .get("occurrence_ids")
1913 .and_then(Value::as_array)
1914 .map(|values| {
1915 values.iter().any(|value| {
1916 value
1917 .as_str()
1918 .map(str::trim)
1919 .filter(|raw| !raw.is_empty())
1920 .is_some()
1921 })
1922 })
1923 .unwrap_or(false)
1924}
1925
1926fn selector_has_bounded_temporal_anchor(selector: &Value) -> bool {
1927 if selector_has_explicit_occurrence_anchor(selector) {
1928 return true;
1929 }
1930 if parse_local_date_value(selector.get("local_date").or_else(|| selector.get("date"))).is_some()
1931 {
1932 return true;
1933 }
1934 if selector
1935 .get("local_dates")
1936 .and_then(Value::as_array)
1937 .map(|values| {
1938 values
1939 .iter()
1940 .any(|value| parse_local_date_value(Some(value)).is_some())
1941 })
1942 .unwrap_or(false)
1943 {
1944 return true;
1945 }
1946 if parse_local_date_value(selector.get("week_of")).is_some() {
1947 return true;
1948 }
1949
1950 let date_range = selector
1951 .get("date_range")
1952 .or_else(|| selector.get("between"))
1953 .unwrap_or(&Value::Null);
1954 let start = parse_local_date_value(date_range.get("start").or_else(|| date_range.get("from")));
1955 let end = parse_local_date_value(date_range.get("end").or_else(|| date_range.get("to")));
1956 match (start, end) {
1957 (Some(start), Some(end)) if end >= start => {
1958 (end - start).num_days() <= SCHEDULE_EXCEPTION_LOW_IMPACT_MAX_RANGE_DAYS
1959 }
1960 _ => false,
1961 }
1962}
1963
1964fn schedule_exception_scope_is_high_impact(data: &Value) -> bool {
1965 let scope_value = data
1966 .get("change_scope")
1967 .or_else(|| data.get("update_scope"))
1968 .or_else(|| {
1969 data.get("scope")
1970 .and_then(|scope| scope.get("change_scope"))
1971 })
1972 .or_else(|| data.get("scope").and_then(|scope| scope.get("scope")))
1973 .and_then(Value::as_str)
1974 .map(|raw| raw.trim().to_lowercase());
1975 if matches!(
1976 scope_value.as_deref(),
1977 Some(
1978 "bulk"
1979 | "future_block"
1980 | "full_rewrite"
1981 | "template_rewrite"
1982 | "replace_future_schedule"
1983 | "mesocycle_reset"
1984 | "phase_shift"
1985 )
1986 ) {
1987 return true;
1988 }
1989
1990 for key in ["days_affected", "occurrences_affected"] {
1991 if read_abs_f64(data.get("scope").and_then(|scope| scope.get(key))).unwrap_or(0.0)
1992 > SCHEDULE_EXCEPTION_LOW_IMPACT_MAX_RANGE_DAYS as f64
1993 {
1994 return true;
1995 }
1996 }
1997 if read_abs_f64(
1998 data.get("scope")
1999 .and_then(|scope| scope.get("weeks_affected")),
2000 )
2001 .unwrap_or(0.0)
2002 > 2.0
2003 {
2004 return true;
2005 }
2006 false
2007}
2008
2009fn training_schedule_exception_is_high_impact(event_type: &str, data: &Value) -> bool {
2010 if read_bool_like(data.get("requires_explicit_confirmation")).unwrap_or(false)
2011 || read_bool_like(data.get("rewrite_template")).unwrap_or(false)
2012 || read_bool_like(data.get("replace_future_schedule")).unwrap_or(false)
2013 || read_bool_like(data.get("replace_entire_weekly_template")).unwrap_or(false)
2014 || read_bool_like(data.get("clear_all")).unwrap_or(false)
2015 || schedule_exception_scope_is_high_impact(data)
2016 {
2017 return true;
2018 }
2019
2020 match event_type {
2021 "training_schedule.exception.cleared" => false,
2022 "training_schedule.exception.upsert" => {
2023 let selector = data.get("selector").unwrap_or(&Value::Null);
2024 !selector_has_bounded_temporal_anchor(selector)
2025 }
2026 _ => true,
2027 }
2028}
2029
2030fn training_plan_update_is_high_impact(data: &Value) -> bool {
2031 let scope = data
2032 .get("change_scope")
2033 .or_else(|| data.get("update_scope"))
2034 .and_then(Value::as_str)
2035 .map(|raw| raw.trim().to_lowercase());
2036 if matches!(
2037 scope.as_deref(),
2038 Some(
2039 "full_rewrite" | "structural" | "major_adjustment" | "mesocycle_reset" | "phase_shift"
2040 )
2041 ) {
2042 return true;
2043 }
2044
2045 if data
2046 .get("replace_entire_plan")
2047 .and_then(Value::as_bool)
2048 .unwrap_or(false)
2049 || data
2050 .get("archive_previous_plan")
2051 .and_then(Value::as_bool)
2052 .unwrap_or(false)
2053 || data
2054 .get("requires_explicit_confirmation")
2055 .and_then(Value::as_bool)
2056 .unwrap_or(false)
2057 {
2058 return true;
2059 }
2060
2061 let volume_delta = read_plan_delta_abs(
2062 data,
2063 &[
2064 "volume_delta_pct",
2065 "planned_volume_delta_pct",
2066 "total_volume_delta_pct",
2067 ],
2068 )
2069 .unwrap_or(0.0);
2070 if volume_delta >= PLAN_UPDATE_VOLUME_DELTA_HIGH_IMPACT_ABS_GTE {
2071 return true;
2072 }
2073
2074 let intensity_delta = read_plan_delta_abs(
2075 data,
2076 &[
2077 "intensity_delta_pct",
2078 "rir_delta",
2079 "rpe_delta",
2080 "effort_delta_pct",
2081 ],
2082 )
2083 .unwrap_or(0.0);
2084 if intensity_delta >= PLAN_UPDATE_INTENSITY_DELTA_HIGH_IMPACT_ABS_GTE {
2085 return true;
2086 }
2087
2088 let frequency_delta = read_plan_delta_abs(
2089 data,
2090 &["frequency_delta_per_week", "sessions_per_week_delta"],
2091 )
2092 .unwrap_or(0.0);
2093 if frequency_delta >= PLAN_UPDATE_FREQUENCY_DELTA_HIGH_IMPACT_ABS_GTE {
2094 return true;
2095 }
2096
2097 let duration_delta = read_plan_delta_abs(
2098 data,
2099 &["cycle_length_weeks_delta", "plan_duration_weeks_delta"],
2100 )
2101 .unwrap_or(0.0);
2102 duration_delta >= PLAN_UPDATE_DURATION_DELTA_WEEKS_HIGH_IMPACT_ABS_GTE
2103}
2104
2105fn is_high_impact_event(event: &Value) -> bool {
2106 let Some(event_type) = normalized_event_type(event) else {
2107 return false;
2108 };
2109 if event_type == "training_plan.updated" {
2110 return event
2111 .get("data")
2112 .is_some_and(training_plan_update_is_high_impact);
2113 }
2114 if event_type == "training_schedule.exception.upsert"
2115 || event_type == "training_schedule.exception.cleared"
2116 {
2117 return event
2118 .get("data")
2119 .is_some_and(|data| training_schedule_exception_is_high_impact(&event_type, data));
2120 }
2121 is_always_high_impact_event_type(&event_type)
2122}
2123
2124fn has_high_impact_events(events: &[Value]) -> bool {
2125 events.iter().any(is_high_impact_event)
2126}
2127
2128fn extract_temporal_basis_from_context_body(body: &Value) -> Option<Value> {
2129 body.pointer("/meta/temporal_basis")
2130 .cloned()
2131 .filter(|value| value.is_object())
2132}
2133
2134async fn fetch_temporal_basis_for_high_impact_write(
2135 api_url: &str,
2136 token: Option<&str>,
2137) -> serde_json::Value {
2138 let query = vec![
2139 ("include_system".to_string(), "false".to_string()),
2140 ("budget_tokens".to_string(), "400".to_string()),
2141 ];
2142 let (status, body) = raw_api_request_with_query(
2143 api_url,
2144 reqwest::Method::GET,
2145 "/v1/agent/context",
2146 token,
2147 &query,
2148 )
2149 .await
2150 .unwrap_or_else(|error| {
2151 exit_error(
2152 &format!("Failed to fetch /v1/agent/context for temporal_basis: {error}"),
2153 Some(
2154 "Retry once the API is reachable, or pass a full --intent-handshake-file payload.",
2155 ),
2156 )
2157 });
2158 if !(200..=299).contains(&status) {
2159 exit_error(
2160 &format!(
2161 "GET /v1/agent/context returned HTTP {status} while preparing temporal_basis."
2162 ),
2163 Some(
2164 "Use `kura agent context` to inspect the failure, or pass --intent-handshake-file.",
2165 ),
2166 );
2167 }
2168 extract_temporal_basis_from_context_body(&body).unwrap_or_else(|| {
2169 exit_error(
2170 "agent context response is missing meta.temporal_basis",
2171 Some(
2172 "Retry after `kura agent context` succeeds, or pass a full --intent-handshake-file payload.",
2173 ),
2174 )
2175 })
2176}
2177
2178fn build_default_intent_handshake(
2179 events: &[serde_json::Value],
2180 intent_goal: Option<&str>,
2181 temporal_basis: serde_json::Value,
2182) -> serde_json::Value {
2183 let event_types: Vec<String> = events.iter().filter_map(normalized_event_type).collect();
2184 let planned_action = if event_types.is_empty() {
2185 "apply high-impact structured write update".to_string()
2186 } else {
2187 format!("write events: {}", event_types.join(", "))
2188 };
2189
2190 json!({
2191 "schema_version": "intent_handshake.v1",
2192 "goal": intent_goal.unwrap_or("execute requested high-impact write safely"),
2193 "planned_action": planned_action,
2194 "assumptions": ["context and request intent are current"],
2195 "non_goals": ["no unrelated writes outside current task scope"],
2196 "impact_class": "high_impact_write",
2197 "success_criteria": "structured write returns verification and claim_guard for this action",
2198 "created_at": chrono::Utc::now().to_rfc3339(),
2199 "handshake_id": format!("cli-hs-{}", Uuid::now_v7()),
2200 "temporal_basis": temporal_basis,
2201 })
2202}
2203
2204async fn resolve_intent_handshake(
2205 api_url: &str,
2206 token: Option<&str>,
2207 events: &[serde_json::Value],
2208 intent_goal: Option<&str>,
2209 intent_handshake_file: Option<&str>,
2210) -> Option<serde_json::Value> {
2211 if let Some(payload) = resolve_optional_object_file(
2212 intent_handshake_file,
2213 "intent_handshake",
2214 "Provide a valid JSON object for intent_handshake.v1.",
2215 ) {
2216 return Some(payload);
2217 }
2218 if !has_high_impact_events(events) {
2219 return None;
2220 }
2221 let temporal_basis = fetch_temporal_basis_for_high_impact_write(api_url, token).await;
2222 Some(build_default_intent_handshake(
2223 events,
2224 intent_goal,
2225 temporal_basis,
2226 ))
2227}
2228
2229fn parse_targets(raw_targets: &[String]) -> Vec<serde_json::Value> {
2230 raw_targets
2231 .iter()
2232 .map(|raw| {
2233 let (projection_type, key) = raw.split_once(':').unwrap_or_else(|| {
2234 exit_error(
2235 &format!("Invalid --target '{raw}'"),
2236 Some("Use format projection_type:key, e.g. user_profile:me"),
2237 )
2238 });
2239 let projection_type = projection_type.trim();
2240 let key = key.trim();
2241 if projection_type.is_empty() || key.is_empty() {
2242 exit_error(
2243 &format!("Invalid --target '{raw}'"),
2244 Some("projection_type and key must both be non-empty."),
2245 );
2246 }
2247 json!({
2248 "projection_type": projection_type,
2249 "key": key,
2250 })
2251 })
2252 .collect()
2253}
2254
2255fn extract_events_array(events_payload: serde_json::Value) -> Vec<serde_json::Value> {
2256 if let Some(events) = events_payload.as_array() {
2257 return events.to_vec();
2258 }
2259 if let Some(events) = events_payload
2260 .get("events")
2261 .and_then(|value| value.as_array())
2262 {
2263 return events.to_vec();
2264 }
2265 exit_error(
2266 "events payload must be an array or object with events array",
2267 Some("Example: --events-file events.json where file is [{...}] or {\"events\": [{...}]}"),
2268 );
2269}
2270
2271fn build_write_with_proof_request(
2272 events: Vec<serde_json::Value>,
2273 parsed_targets: Vec<serde_json::Value>,
2274 verify_timeout_ms: Option<u64>,
2275 intent_handshake: Option<serde_json::Value>,
2276 high_impact_confirmation: Option<serde_json::Value>,
2277 non_trivial_confirmation: Option<serde_json::Value>,
2278 clarification_resolutions: Option<Vec<serde_json::Value>>,
2279 session_status: Option<SessionCompletionStatus>,
2280 conversation_draft_mode: Option<ConversationDraftMode>,
2281) -> serde_json::Value {
2282 let mut request = json!({
2283 "events": events,
2284 "read_after_write_targets": parsed_targets,
2285 });
2286 if let Some(timeout) = verify_timeout_ms {
2287 request["verify_timeout_ms"] = json!(timeout);
2288 }
2289 if let Some(intent_handshake) = intent_handshake {
2290 request["intent_handshake"] = intent_handshake;
2291 }
2292 if let Some(high_impact_confirmation) = high_impact_confirmation {
2293 request["high_impact_confirmation"] = high_impact_confirmation;
2294 }
2295 if let Some(non_trivial_confirmation) = non_trivial_confirmation {
2296 request["non_trivial_confirmation"] = non_trivial_confirmation;
2297 }
2298 if let Some(clarification_resolutions) = clarification_resolutions {
2299 request["clarification_resolutions"] = json!(clarification_resolutions);
2300 }
2301 if let Some(session_status) = session_status {
2302 request["session_completion"] = json!({
2303 "schema_version": "training_session_completion.v1",
2304 "status": session_status.as_str(),
2305 });
2306 }
2307 if let Some(conversation_draft_mode) = conversation_draft_mode {
2308 request["conversation_draft"] = json!({
2309 "schema_version": "agent_conversation_session_draft.v1",
2310 "mode": conversation_draft_mode.as_str(),
2311 });
2312 }
2313 request
2314}
2315
2316#[cfg(test)]
2317mod tests {
2318 use super::{
2319 ConversationDraftMode, LogTrainingArgs, LogTurnArgs, ResumeClarificationPrompt,
2320 SaveConfirmationMode, SessionCompletionStatus, build_context_query,
2321 build_default_intent_handshake, build_high_impact_confirmation_from_token,
2322 build_log_training_request, build_log_turn_request, build_logging_bootstrap_output,
2323 build_non_trivial_confirmation_from_token, build_section_fetch_query,
2324 build_write_with_proof_request, extract_events_array, extract_logging_bootstrap_contract,
2325 extract_preferred_structured_write_endpoint, extract_resume_clarification_prompts,
2326 extract_temporal_basis_from_context_body, has_high_impact_events, normalize_agent_path,
2327 parse_method, parse_targets, select_resume_clarification_prompt,
2328 };
2329 use serde_json::json;
2330 use uuid::Uuid;
2331
2332 #[test]
2333 fn normalize_agent_path_accepts_relative_path() {
2334 assert_eq!(
2335 normalize_agent_path("evidence/event/abc"),
2336 "/v1/agent/evidence/event/abc"
2337 );
2338 }
2339
2340 #[test]
2341 fn normalize_agent_path_accepts_absolute_agent_path() {
2342 assert_eq!(
2343 normalize_agent_path("/v1/agent/context"),
2344 "/v1/agent/context"
2345 );
2346 }
2347
2348 #[test]
2349 fn normalize_agent_path_accepts_absolute_v2_agent_path() {
2350 assert_eq!(
2351 normalize_agent_path("/v2/agent/write-with-proof"),
2352 "/v2/agent/write-with-proof"
2353 );
2354 }
2355
2356 #[test]
2357 fn extract_preferred_structured_write_endpoint_accepts_v2_only() {
2358 assert_eq!(
2359 extract_preferred_structured_write_endpoint(&json!({
2360 "preferred_structured_write_endpoint": "/v2/agent/write-with-proof"
2361 }))
2362 .as_deref(),
2363 Some("/v2/agent/write-with-proof")
2364 );
2365 assert!(
2366 extract_preferred_structured_write_endpoint(&json!({
2367 "preferred_write_endpoint": "/v1/agent/write-with-proof"
2368 }))
2369 .is_none()
2370 );
2371 assert!(
2372 extract_preferred_structured_write_endpoint(&json!({
2373 "preferred_write_endpoint": "https://bad.example"
2374 }))
2375 .is_none()
2376 );
2377 assert_eq!(
2378 extract_preferred_structured_write_endpoint(&json!({
2379 "preferred_write_endpoint": "/v2/agent/write-with-proof"
2380 }))
2381 .as_deref(),
2382 Some("/v2/agent/write-with-proof")
2383 );
2384 }
2385
2386 #[test]
2387 fn extract_logging_bootstrap_contract_reads_logging_node() {
2388 let capabilities = json!({
2389 "task_bootstrap_contracts": {
2390 "logging": {
2391 "schema_version": "agent_logging_bootstrap_contract.v1",
2392 "task_family": "logging"
2393 }
2394 }
2395 });
2396 let contract =
2397 extract_logging_bootstrap_contract(&capabilities).expect("logging bootstrap contract");
2398 assert_eq!(
2399 contract["schema_version"],
2400 json!("agent_logging_bootstrap_contract.v1")
2401 );
2402 assert_eq!(contract["task_family"], json!("logging"));
2403 }
2404
2405 #[test]
2406 fn build_logging_bootstrap_output_selects_one_intent_recipe() {
2407 let contract = json!({
2408 "schema_version": "agent_logging_bootstrap_contract.v1",
2409 "task_family": "logging",
2410 "bootstrap_surface": "/v1/agent/capabilities",
2411 "intent_recipes": [
2412 {
2413 "intent_id": "log_conversation",
2414 "endpoint": "/v3/agent/evidence",
2415 "cli_entrypoint": "kura log"
2416 }
2417 ],
2418 "save_states": [{"save_state": "received"}],
2419 "upgrade_hints": [{"surface": "/v1/events"}],
2420 "integrity_guards": ["guard"]
2421 });
2422 let output = build_logging_bootstrap_output(&contract, Some("log_conversation"))
2423 .expect("bootstrap output");
2424 assert_eq!(
2425 output["intent_recipe"]["intent_id"],
2426 json!("log_conversation")
2427 );
2428 assert_eq!(output["intent_recipe"]["cli_entrypoint"], json!("kura log"));
2429 assert_eq!(output["bootstrap_surface"], json!("/v1/agent/capabilities"));
2430 assert_eq!(output["save_states"][0]["save_state"], json!("received"));
2431 }
2432
2433 #[test]
2434 fn build_log_training_request_uses_dedicated_training_shape() {
2435 let request = build_log_training_request(&LogTrainingArgs {
2436 data: Some(
2437 json!({
2438 "date": "2026-03-20",
2439 "entries": [
2440 {
2441 "block_type": "repetition_sets",
2442 "exercise": {"label": "Back Squat"},
2443 "sets": [{"count": 5, "reps": 5, "weight_kg": 100, "rir": 2}]
2444 }
2445 ],
2446 "session_id": "session:2026-03-20-lower"
2447 })
2448 .to_string(),
2449 ),
2450 request_file: None,
2451 });
2452
2453 assert_eq!(request["schema_version"], json!("write_training.v1"));
2454 assert_eq!(request["date"], json!("2026-03-20"));
2455 assert_eq!(
2456 request["entries"][0]["block_type"],
2457 json!("repetition_sets")
2458 );
2459 assert_eq!(
2460 request["entries"][0]["exercise"]["label"],
2461 json!("Back Squat")
2462 );
2463 assert_eq!(request["source_context"]["surface"], json!("cli"));
2464 assert_eq!(
2465 request["source_context"]["command_family"],
2466 json!("write_training")
2467 );
2468 }
2469
2470 #[test]
2471 fn build_log_turn_request_uses_evidence_ingress_shape() {
2472 let request = build_log_turn_request(&LogTurnArgs {
2473 message: Some("bench 4x5 80".to_string()),
2474 message_file: None,
2475 session_id: Some("2026-03-15-upper".to_string()),
2476 modality: None,
2477 recorded_at: Some("2026-03-15T09:30:00+01:00".to_string()),
2478 observed_at: None,
2479 idempotency_key: Some("idem-123".to_string()),
2480 });
2481 assert_eq!(
2482 request["schema_version"],
2483 json!("agent_evidence_ingress_request.v1")
2484 );
2485 assert_eq!(request["text_evidence"], json!("bench 4x5 80"));
2486 assert_eq!(request["modality"], json!("chat_message"));
2487 assert_eq!(
2488 request["session_hint"]["session_id"],
2489 json!("2026-03-15-upper")
2490 );
2491 assert_eq!(request["idempotency_key"], json!("idem-123"));
2492 assert_eq!(request["source"]["command_family"], json!("log_turn"));
2493 }
2494
2495 #[test]
2496 fn extract_resume_clarification_prompts_reads_blocked_response_shape() {
2497 let prompt_id = Uuid::now_v7();
2498 let prompts = extract_resume_clarification_prompts(&json!({
2499 "schema_version": "write_preflight.v1",
2500 "status": "blocked",
2501 "blockers": [
2502 {
2503 "code": "logging_intent_clarification_required",
2504 "details": {
2505 "clarification_prompts": [
2506 {
2507 "prompt_id": prompt_id,
2508 "scope_kind": "training_vs_test",
2509 "accepted_resolution_fields": ["resolved_route_family"]
2510 }
2511 ]
2512 }
2513 }
2514 ]
2515 }));
2516 assert_eq!(
2517 prompts,
2518 vec![ResumeClarificationPrompt {
2519 prompt_id,
2520 scope_kind: "training_vs_test".to_string(),
2521 accepted_resolution_fields: vec!["resolved_route_family".to_string()],
2522 }]
2523 );
2524 }
2525
2526 #[test]
2527 fn select_resume_clarification_prompt_accepts_single_prompt_without_explicit_id() {
2528 let prompt = ResumeClarificationPrompt {
2529 prompt_id: Uuid::now_v7(),
2530 scope_kind: "training_vs_test".to_string(),
2531 accepted_resolution_fields: vec!["resolved_route_family".to_string()],
2532 };
2533 let selected = select_resume_clarification_prompt(std::slice::from_ref(&prompt), None)
2534 .expect("prompt");
2535 assert_eq!(selected, prompt);
2536 }
2537
2538 #[test]
2539 fn parse_method_accepts_standard_http_methods() {
2540 for method in &[
2541 "get", "GET", "post", "PUT", "delete", "patch", "head", "OPTIONS",
2542 ] {
2543 let parsed = parse_method(method);
2544 assert!(!parsed.as_str().is_empty());
2545 }
2546 }
2547
2548 #[test]
2549 fn parse_targets_accepts_projection_type_key_format() {
2550 let parsed = parse_targets(&[
2551 "user_profile:me".to_string(),
2552 "training_timeline:overview".to_string(),
2553 ]);
2554 assert_eq!(parsed[0]["projection_type"], "user_profile");
2555 assert_eq!(parsed[0]["key"], "me");
2556 assert_eq!(parsed[1]["projection_type"], "training_timeline");
2557 assert_eq!(parsed[1]["key"], "overview");
2558 }
2559
2560 #[test]
2561 fn extract_events_array_supports_plain_array() {
2562 let events = extract_events_array(json!([
2563 {"event_type":"set.logged"},
2564 {"event_type":"metric.logged"}
2565 ]));
2566 assert_eq!(events.len(), 2);
2567 }
2568
2569 #[test]
2570 fn extract_events_array_supports_object_wrapper() {
2571 let events = extract_events_array(json!({
2572 "events": [{"event_type":"set.logged"}]
2573 }));
2574 assert_eq!(events.len(), 1);
2575 }
2576
2577 #[test]
2578 fn build_write_with_proof_request_serializes_expected_fields() {
2579 let request = build_write_with_proof_request(
2580 vec![json!({"event_type":"set.logged"})],
2581 vec![json!({"projection_type":"user_profile","key":"me"})],
2582 Some(1200),
2583 None,
2584 None,
2585 None,
2586 None,
2587 None,
2588 None,
2589 );
2590 assert_eq!(request["events"].as_array().unwrap().len(), 1);
2591 assert_eq!(
2592 request["read_after_write_targets"]
2593 .as_array()
2594 .unwrap()
2595 .len(),
2596 1
2597 );
2598 assert_eq!(request["verify_timeout_ms"], 1200);
2599 }
2600
2601 #[test]
2602 fn build_write_with_proof_request_includes_non_trivial_confirmation_when_present() {
2603 let request = build_write_with_proof_request(
2604 vec![json!({"event_type":"set.logged"})],
2605 vec![json!({"projection_type":"user_profile","key":"me"})],
2606 None,
2607 None,
2608 None,
2609 Some(json!({
2610 "schema_version": "non_trivial_confirmation.v1",
2611 "confirmed": true,
2612 "confirmed_at": "2026-02-25T12:00:00Z",
2613 "confirmation_token": "abc"
2614 })),
2615 None,
2616 None,
2617 None,
2618 );
2619 assert_eq!(
2620 request["non_trivial_confirmation"]["schema_version"],
2621 "non_trivial_confirmation.v1"
2622 );
2623 assert_eq!(
2624 request["non_trivial_confirmation"]["confirmation_token"],
2625 "abc"
2626 );
2627 }
2628
2629 #[test]
2630 fn build_write_with_proof_request_includes_high_impact_fields_when_present() {
2631 let request = build_write_with_proof_request(
2632 vec![json!({"event_type":"training_schedule.exception.upsert"})],
2633 vec![json!({"projection_type":"training_schedule","key":"effective"})],
2634 None,
2635 Some(json!({
2636 "schema_version": "intent_handshake.v1",
2637 "goal": "shift deload start",
2638 "impact_class": "high_impact_write",
2639 "temporal_basis": {
2640 "schema_version": "temporal_basis.v1",
2641 "context_generated_at": "2026-03-07T16:00:00Z",
2642 "timezone": "Europe/Berlin",
2643 "today_local_date": "2026-03-07"
2644 }
2645 })),
2646 Some(json!({
2647 "schema_version": "high_impact_confirmation.v1",
2648 "confirmed": true,
2649 "confirmed_at": "2026-03-07T16:05:00Z",
2650 "confirmation_token": "hi-123"
2651 })),
2652 None,
2653 None,
2654 None,
2655 None,
2656 );
2657 assert_eq!(
2658 request["intent_handshake"]["schema_version"],
2659 "intent_handshake.v1"
2660 );
2661 assert_eq!(
2662 request["high_impact_confirmation"]["confirmation_token"],
2663 "hi-123"
2664 );
2665 }
2666
2667 #[test]
2668 fn build_write_with_proof_request_includes_clarification_resolutions_when_present() {
2669 let request = build_write_with_proof_request(
2670 vec![json!({"event_type":"set.logged"})],
2671 vec![json!({"projection_type":"training_timeline","key":"today"})],
2672 None,
2673 None,
2674 None,
2675 None,
2676 Some(vec![json!({
2677 "schema_version": "logging_clarification_resolution.v1",
2678 "prompt_id": "3f6e2b68-63a6-44c2-a4df-73d80f3b23e0",
2679 "resolved_route_family": "training",
2680 "resolved_at": "2026-03-08T10:00:00Z"
2681 })]),
2682 None,
2683 None,
2684 );
2685 assert_eq!(
2686 request["clarification_resolutions"]
2687 .as_array()
2688 .unwrap()
2689 .len(),
2690 1
2691 );
2692 assert_eq!(
2693 request["clarification_resolutions"][0]["resolved_route_family"],
2694 "training"
2695 );
2696 }
2697
2698 #[test]
2699 fn build_write_with_proof_request_includes_session_completion_when_present() {
2700 let request = build_write_with_proof_request(
2701 vec![json!({"event_type":"set.logged"})],
2702 vec![json!({"projection_type":"training_timeline","key":"today"})],
2703 None,
2704 None,
2705 None,
2706 None,
2707 None,
2708 Some(SessionCompletionStatus::Ongoing),
2709 None,
2710 );
2711 assert_eq!(
2712 request["session_completion"]["schema_version"],
2713 "training_session_completion.v1"
2714 );
2715 assert_eq!(request["session_completion"]["status"], "ongoing");
2716 }
2717
2718 #[test]
2719 fn build_write_with_proof_request_includes_conversation_draft_when_present() {
2720 let request = build_write_with_proof_request(
2721 vec![json!({"event_type":"session.completed"})],
2722 Vec::new(),
2723 None,
2724 None,
2725 None,
2726 None,
2727 None,
2728 Some(SessionCompletionStatus::Ongoing),
2729 Some(ConversationDraftMode::Append),
2730 );
2731 assert_eq!(
2732 request["conversation_draft"]["schema_version"],
2733 "agent_conversation_session_draft.v1"
2734 );
2735 assert_eq!(request["conversation_draft"]["mode"], "append");
2736 }
2737
2738 #[test]
2739 fn build_non_trivial_confirmation_from_token_uses_expected_shape() {
2740 let payload = build_non_trivial_confirmation_from_token("tok-123");
2741 assert_eq!(payload["schema_version"], "non_trivial_confirmation.v1");
2742 assert_eq!(payload["confirmed"], true);
2743 assert_eq!(payload["confirmation_token"], "tok-123");
2744 assert!(payload["confirmed_at"].as_str().is_some());
2745 }
2746
2747 #[test]
2748 fn build_high_impact_confirmation_from_token_uses_expected_shape() {
2749 let payload = build_high_impact_confirmation_from_token("tok-456");
2750 assert_eq!(payload["schema_version"], "high_impact_confirmation.v1");
2751 assert_eq!(payload["confirmed"], true);
2752 assert_eq!(payload["confirmation_token"], "tok-456");
2753 assert!(payload["confirmed_at"].as_str().is_some());
2754 }
2755
2756 #[test]
2757 fn extract_temporal_basis_from_context_body_reads_meta_field() {
2758 let temporal_basis = extract_temporal_basis_from_context_body(&json!({
2759 "meta": {
2760 "temporal_basis": {
2761 "schema_version": "temporal_basis.v1",
2762 "timezone": "Europe/Berlin",
2763 "today_local_date": "2026-03-07"
2764 }
2765 }
2766 }))
2767 .expect("temporal_basis must be extracted");
2768 assert_eq!(temporal_basis["schema_version"], "temporal_basis.v1");
2769 assert_eq!(temporal_basis["timezone"], "Europe/Berlin");
2770 }
2771
2772 #[test]
2773 fn build_default_intent_handshake_uses_event_types_and_temporal_basis() {
2774 let handshake = build_default_intent_handshake(
2775 &[json!({"event_type":"training_schedule.exception.upsert"})],
2776 Some("shift today's session"),
2777 json!({
2778 "schema_version": "temporal_basis.v1",
2779 "context_generated_at": "2026-03-07T16:00:00Z",
2780 "timezone": "Europe/Berlin",
2781 "today_local_date": "2026-03-07"
2782 }),
2783 );
2784 assert_eq!(handshake["schema_version"], "intent_handshake.v1");
2785 assert_eq!(handshake["goal"], "shift today's session");
2786 assert_eq!(handshake["impact_class"], "high_impact_write");
2787 assert_eq!(
2788 handshake["temporal_basis"]["today_local_date"],
2789 "2026-03-07"
2790 );
2791 }
2792
2793 #[test]
2794 fn high_impact_classification_keeps_bounded_schedule_exception_low_impact() {
2795 let events = vec![json!({
2796 "event_type": "training_schedule.exception.upsert",
2797 "data": {
2798 "exception_id": "deload-start-today",
2799 "operation": "patch",
2800 "selector": {
2801 "local_date": "2026-03-07",
2802 "session_name": "Technik + Power"
2803 },
2804 "progression_override": {
2805 "deload_active": true,
2806 "phase": "deload",
2807 "volume_delta_pct": -30
2808 }
2809 }
2810 })];
2811 assert!(!has_high_impact_events(&events));
2812 }
2813
2814 #[test]
2815 fn high_impact_classification_escalates_unbounded_schedule_exception() {
2816 let events = vec![json!({
2817 "event_type": "training_schedule.exception.upsert",
2818 "data": {
2819 "exception_id": "rewrite-future-saturdays",
2820 "operation": "patch",
2821 "selector": {
2822 "session_name": "Technik + Power"
2823 },
2824 "rewrite_template": true
2825 }
2826 })];
2827 assert!(has_high_impact_events(&events));
2828 }
2829
2830 #[test]
2831 fn save_confirmation_mode_serializes_expected_values() {
2832 assert_eq!(SaveConfirmationMode::Auto.as_str(), "auto");
2833 assert_eq!(SaveConfirmationMode::Always.as_str(), "always");
2834 assert_eq!(SaveConfirmationMode::Never.as_str(), "never");
2835 }
2836
2837 #[test]
2838 fn build_context_query_includes_budget_tokens_when_present() {
2839 let query = build_context_query(
2840 Some(3),
2841 Some(2),
2842 Some(1),
2843 Some("readiness check".to_string()),
2844 Some(false),
2845 Some(900),
2846 );
2847 assert!(query.contains(&("exercise_limit".to_string(), "3".to_string())));
2848 assert!(query.contains(&("strength_limit".to_string(), "2".to_string())));
2849 assert!(query.contains(&("custom_limit".to_string(), "1".to_string())));
2850 assert!(query.contains(&("task_intent".to_string(), "readiness check".to_string())));
2851 assert!(query.contains(&("include_system".to_string(), "false".to_string())));
2852 assert!(query.contains(&("budget_tokens".to_string(), "900".to_string())));
2853 }
2854
2855 #[test]
2856 fn build_context_query_supports_section_index_parity_params() {
2857 let query = build_context_query(
2858 Some(5),
2859 Some(5),
2860 Some(10),
2861 Some("startup".to_string()),
2862 Some(false),
2863 Some(1200),
2864 );
2865 assert!(query.contains(&("exercise_limit".to_string(), "5".to_string())));
2866 assert!(query.contains(&("strength_limit".to_string(), "5".to_string())));
2867 assert!(query.contains(&("custom_limit".to_string(), "10".to_string())));
2868 assert!(query.contains(&("task_intent".to_string(), "startup".to_string())));
2869 assert!(query.contains(&("include_system".to_string(), "false".to_string())));
2870 assert!(query.contains(&("budget_tokens".to_string(), "1200".to_string())));
2871 }
2872
2873 #[test]
2874 fn build_section_fetch_query_serializes_optional_params() {
2875 let query = build_section_fetch_query(
2876 "projections.exercise_progression".to_string(),
2877 Some(50),
2878 Some("abc123".to_string()),
2879 Some("data,meta".to_string()),
2880 Some("bench plateau".to_string()),
2881 );
2882 assert_eq!(
2883 query,
2884 vec![
2885 (
2886 "section".to_string(),
2887 "projections.exercise_progression".to_string(),
2888 ),
2889 ("limit".to_string(), "50".to_string()),
2890 ("cursor".to_string(), "abc123".to_string()),
2891 ("fields".to_string(), "data,meta".to_string()),
2892 ("task_intent".to_string(), "bench plateau".to_string()),
2893 ]
2894 );
2895 }
2896}