1use serde::{Deserialize, Serialize};
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ActionIntent {
41 pub id: String,
43
44 pub name: String,
46
47 pub params: serde_json::Value,
49
50 #[serde(default)]
52 pub meta: IntentMeta,
53}
54
55impl ActionIntent {
56 pub fn new(name: impl Into<String>, params: serde_json::Value) -> Self {
58 Self {
59 id: format!("intent-{}", uuid::Uuid::new_v4()),
60 name: name.into(),
61 params,
62 meta: IntentMeta::default(),
63 }
64 }
65
66 pub fn from_llm_tool_call(
68 id: impl Into<String>,
69 name: impl Into<String>,
70 params: serde_json::Value,
71 ) -> Self {
72 Self {
73 id: id.into(),
74 name: name.into(),
75 params,
76 meta: IntentMeta {
77 source: IntentSource::LlmToolCall,
78 ..IntentMeta::default()
79 },
80 }
81 }
82
83 #[must_use]
85 pub fn with_meta(mut self, meta: IntentMeta) -> Self {
86 self.meta = meta;
87 self
88 }
89}
90
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct IntentMeta {
100 #[serde(default)]
102 pub source: IntentSource,
103
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub priority: Option<Priority>,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub expected_latency_ms: Option<u64>,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub confidence: Option<Confidence>,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum IntentSource {
124 #[default]
126 Lua,
127 LlmToolCall,
129 System,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum Priority {
139 Low,
140 Normal,
141 High,
142 Critical,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
162pub struct Confidence(f64);
163
164impl Confidence {
165 pub fn new(value: f64) -> Option<Self> {
169 if value.is_finite() && (0.0..=1.0).contains(&value) {
170 Some(Self(value))
171 } else {
172 None
173 }
174 }
175
176 pub fn get(self) -> f64 {
178 self.0
179 }
180}
181
182impl Serialize for Confidence {
183 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
184 self.0.serialize(serializer)
185 }
186}
187
188impl<'de> Deserialize<'de> for Confidence {
189 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
190 let v = f64::deserialize(deserializer)?;
191 Self::new(v).ok_or_else(|| {
192 serde::de::Error::custom(format!("confidence must be in [0.0, 1.0], got {v}"))
193 })
194 }
195}
196
197impl std::fmt::Display for Confidence {
198 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 write!(f, "{:.2}", self.0)
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum IntentResolver {
211 Internal,
213
214 Component {
216 component_fqn: String,
218 operation: String,
220 timeout_ms: Option<u64>,
223 },
224
225 Mcp {
230 server_name: String,
232 tool_name: String,
234 },
235}
236
237#[derive(Debug, Clone)]
245pub struct IntentDef {
246 pub name: String,
248
249 pub description: String,
251
252 pub parameters: serde_json::Value,
254
255 pub resolver: IntentResolver,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct IntentResult {
264 pub intent_id: String,
266
267 pub name: String,
269
270 pub ok: bool,
272
273 pub content: serde_json::Value,
275
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub error: Option<String>,
279
280 pub duration_ms: u64,
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum StopReason {
295 EndTurn,
297 ToolUse,
299 MaxTokens,
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "lowercase")]
311pub enum Role {
312 System,
313 User,
314 Assistant,
315 Tool,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(untagged)]
328pub enum MessageContent {
329 Text(String),
331 Blocks(Vec<ContentBlock>),
333}
334
335impl MessageContent {
336 pub fn text(&self) -> Option<&str> {
338 match self {
339 Self::Text(s) => Some(s),
340 Self::Blocks(blocks) => blocks.iter().find_map(|b| match b {
341 ContentBlock::Text { text } => Some(text.as_str()),
342 _ => None,
343 }),
344 }
345 }
346}
347
348impl From<String> for MessageContent {
349 fn from(s: String) -> Self {
350 Self::Text(s)
351 }
352}
353
354impl From<&str> for MessageContent {
355 fn from(s: &str) -> Self {
356 Self::Text(s.to_string())
357 }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
364#[serde(tag = "type")]
365pub enum ContentBlock {
366 #[serde(rename = "text")]
368 Text { text: String },
369
370 #[serde(rename = "tool_use")]
372 ToolUse {
373 id: String,
374 name: String,
375 input: serde_json::Value,
376 },
377
378 #[serde(rename = "tool_result")]
380 ToolResult {
381 tool_use_id: String,
382 content: String,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 is_error: Option<bool>,
385 },
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
395 fn action_intent_new_generates_id() {
396 let intent = ActionIntent::new("read", serde_json::json!({"path": "src/main.rs"}));
397 assert!(
398 intent.id.starts_with("intent-"),
399 "expected intent- prefix, got: {}",
400 intent.id
401 );
402 assert_eq!(intent.name, "read");
403 assert_eq!(intent.params["path"], "src/main.rs");
404 assert_eq!(intent.meta.source, IntentSource::Lua);
405 }
406
407 #[test]
408 fn action_intent_from_llm_tool_call() {
409 let intent =
410 ActionIntent::from_llm_tool_call("call_abc", "exec", serde_json::json!({"cmd": "ls"}));
411 assert_eq!(intent.id, "call_abc");
412 assert_eq!(intent.name, "exec");
413 assert_eq!(intent.meta.source, IntentSource::LlmToolCall);
414 }
415
416 #[test]
417 fn action_intent_with_meta() {
418 let meta = IntentMeta {
419 source: IntentSource::System,
420 priority: Some(Priority::High),
421 expected_latency_ms: Some(500),
422 confidence: Confidence::new(0.95),
423 };
424 let intent = ActionIntent::new("read", serde_json::json!({})).with_meta(meta);
425 assert_eq!(intent.meta.source, IntentSource::System);
426 assert_eq!(intent.meta.priority, Some(Priority::High));
427 assert_eq!(intent.meta.expected_latency_ms, Some(500));
428 let c = intent.meta.confidence.expect("should have confidence");
429 assert!((c.get() - 0.95).abs() < f64::EPSILON);
430 }
431
432 #[test]
433 fn action_intent_unique_ids() {
434 let a = ActionIntent::new("read", serde_json::json!({}));
435 let b = ActionIntent::new("read", serde_json::json!({}));
436 assert_ne!(a.id, b.id, "each intent should get a unique ID");
437 }
438
439 #[test]
442 fn action_intent_serde_roundtrip() {
443 let intent = ActionIntent::new(
444 "write",
445 serde_json::json!({"path": "a.txt", "content": "hi"}),
446 );
447 let json = serde_json::to_string(&intent).expect("serialize ActionIntent");
448 let back: ActionIntent = serde_json::from_str(&json).expect("deserialize ActionIntent");
449 assert_eq!(back.id, intent.id);
450 assert_eq!(back.name, "write");
451 assert_eq!(back.params["path"], "a.txt");
452 }
453
454 #[test]
455 fn intent_meta_default_serde() {
456 let meta = IntentMeta::default();
457 let json = serde_json::to_string(&meta).expect("serialize IntentMeta default");
458 assert!(
459 json.contains(r#""source":"lua"#),
460 "default source should be lua, got: {}",
461 json
462 );
463 assert!(
465 !json.contains("priority"),
466 "priority should be skipped when None"
467 );
468 }
469
470 #[test]
473 fn intent_source_default_is_lua() {
474 assert_eq!(IntentSource::default(), IntentSource::Lua);
475 }
476
477 #[test]
478 fn intent_source_serde_variants() {
479 let cases = [
480 (IntentSource::Lua, r#""lua""#),
481 (IntentSource::LlmToolCall, r#""llm_tool_call""#),
482 (IntentSource::System, r#""system""#),
483 ];
484 for (variant, expected) in cases {
485 let json = serde_json::to_string(&variant).expect("serialize IntentSource");
486 assert_eq!(json, expected, "IntentSource::{variant:?}");
487 let back: IntentSource = serde_json::from_str(&json).expect("deserialize IntentSource");
488 assert_eq!(back, variant);
489 }
490 }
491
492 #[test]
495 fn priority_ordering() {
496 assert!(Priority::Low < Priority::Normal);
497 assert!(Priority::Normal < Priority::High);
498 assert!(Priority::High < Priority::Critical);
499 }
500
501 #[test]
502 fn priority_serde_variants() {
503 let cases = [
504 (Priority::Low, r#""low""#),
505 (Priority::Normal, r#""normal""#),
506 (Priority::High, r#""high""#),
507 (Priority::Critical, r#""critical""#),
508 ];
509 for (variant, expected) in cases {
510 let json = serde_json::to_string(&variant).expect("serialize Priority");
511 assert_eq!(json, expected, "Priority::{variant:?}");
512 let back: Priority = serde_json::from_str(&json).expect("deserialize Priority");
513 assert_eq!(back, variant);
514 }
515 }
516
517 #[test]
520 fn intent_resolver_internal_eq() {
521 assert_eq!(IntentResolver::Internal, IntentResolver::Internal);
522 }
523
524 #[test]
525 fn intent_resolver_component_eq() {
526 let a = IntentResolver::Component {
527 component_fqn: "lua::skill_manager".into(),
528 operation: "execute".into(),
529 timeout_ms: None,
530 };
531 let b = IntentResolver::Component {
532 component_fqn: "lua::skill_manager".into(),
533 operation: "execute".into(),
534 timeout_ms: None,
535 };
536 assert_eq!(a, b);
537 }
538
539 #[test]
540 fn intent_resolver_different_ne() {
541 let internal = IntentResolver::Internal;
542 let component = IntentResolver::Component {
543 component_fqn: "lua::x".into(),
544 operation: "op".into(),
545 timeout_ms: None,
546 };
547 assert_ne!(internal, component);
548 }
549
550 #[test]
553 fn intent_def_construction() {
554 let def = IntentDef {
555 name: "read".into(),
556 description: "Read file contents".into(),
557 parameters: serde_json::json!({
558 "type": "object",
559 "properties": {
560 "path": { "type": "string", "description": "File path" }
561 },
562 "required": ["path"]
563 }),
564 resolver: IntentResolver::Internal,
565 };
566 assert_eq!(def.name, "read");
567 assert_eq!(def.resolver, IntentResolver::Internal);
568 assert!(def.parameters["properties"]["path"].is_object());
569 }
570
571 #[test]
574 fn intent_result_success() {
575 let result = IntentResult {
576 intent_id: "intent-123".into(),
577 name: "read".into(),
578 ok: true,
579 content: serde_json::json!({"content": "fn main() {}", "size": 13}),
580 error: None,
581 duration_ms: 5,
582 };
583 assert!(result.ok);
584 assert!(result.error.is_none());
585 assert_eq!(result.content["size"], 13);
586 }
587
588 #[test]
589 fn intent_result_failure() {
590 let result = IntentResult {
591 intent_id: "intent-456".into(),
592 name: "read".into(),
593 ok: false,
594 content: serde_json::Value::Null,
595 error: Some("file not found".into()),
596 duration_ms: 1,
597 };
598 assert!(!result.ok);
599 assert_eq!(result.error.as_deref(), Some("file not found"),);
600 }
601
602 #[test]
603 fn intent_result_serde_roundtrip() {
604 let result = IntentResult {
605 intent_id: "i-1".into(),
606 name: "exec".into(),
607 ok: true,
608 content: serde_json::json!({"stdout": "hello"}),
609 error: None,
610 duration_ms: 42,
611 };
612 let json = serde_json::to_string(&result).expect("serialize IntentResult");
613 let back: IntentResult = serde_json::from_str(&json).expect("deserialize IntentResult");
614 assert_eq!(back.intent_id, "i-1");
615 assert_eq!(back.duration_ms, 42);
616 assert!(!json.contains("error"));
618 }
619
620 #[test]
623 fn stop_reason_serde_variants() {
624 let cases = [
625 (StopReason::EndTurn, r#""end_turn""#),
626 (StopReason::ToolUse, r#""tool_use""#),
627 (StopReason::MaxTokens, r#""max_tokens""#),
628 ];
629 for (variant, expected) in cases {
630 let json = serde_json::to_string(&variant).expect("serialize StopReason");
631 assert_eq!(json, expected, "StopReason::{variant:?}");
632 let back: StopReason = serde_json::from_str(&json).expect("deserialize StopReason");
633 assert_eq!(back, variant);
634 }
635 }
636
637 #[test]
640 fn role_serde_variants() {
641 let cases = [
642 (Role::System, r#""system""#),
643 (Role::User, r#""user""#),
644 (Role::Assistant, r#""assistant""#),
645 (Role::Tool, r#""tool""#),
646 ];
647 for (variant, expected) in cases {
648 let json = serde_json::to_string(&variant).expect("serialize Role");
649 assert_eq!(json, expected, "Role::{variant:?}");
650 let back: Role = serde_json::from_str(&json).expect("deserialize Role");
651 assert_eq!(back, variant);
652 }
653 }
654
655 #[test]
658 fn confidence_valid_range() {
659 assert!(Confidence::new(0.0).is_some());
660 assert!(Confidence::new(0.5).is_some());
661 assert!(Confidence::new(1.0).is_some());
662 }
663
664 #[test]
665 fn confidence_rejects_out_of_range() {
666 assert!(Confidence::new(-0.01).is_none());
667 assert!(Confidence::new(1.01).is_none());
668 assert!(Confidence::new(f64::NAN).is_none());
669 assert!(Confidence::new(f64::INFINITY).is_none());
670 assert!(Confidence::new(f64::NEG_INFINITY).is_none());
671 }
672
673 #[test]
674 fn confidence_get_returns_inner() {
675 let c = Confidence::new(0.75).expect("valid");
676 assert!((c.get() - 0.75).abs() < f64::EPSILON);
677 }
678
679 #[test]
680 fn confidence_serde_roundtrip() {
681 let c = Confidence::new(0.42).expect("valid");
682 let json = serde_json::to_string(&c).expect("serialize");
683 assert_eq!(json, "0.42");
684 let back: Confidence = serde_json::from_str(&json).expect("deserialize");
685 assert_eq!(back, c);
686 }
687
688 #[test]
689 fn confidence_deserialize_rejects_invalid() {
690 let bad_cases = ["1.5", "-0.1", "\"NaN\""];
691 for case in bad_cases {
692 assert!(
693 serde_json::from_str::<Confidence>(case).is_err(),
694 "should reject: {case}"
695 );
696 }
697 }
698
699 #[test]
702 fn message_content_text_variant() {
703 let content = MessageContent::Text("hello".into());
704 assert_eq!(content.text(), Some("hello"));
705
706 let json = serde_json::to_string(&content).expect("serialize Text");
707 assert_eq!(json, r#""hello""#);
708 }
709
710 #[test]
711 fn message_content_blocks_text_extraction() {
712 let content = MessageContent::Blocks(vec![
713 ContentBlock::Text {
714 text: "thinking...".into(),
715 },
716 ContentBlock::ToolUse {
717 id: "call_1".into(),
718 name: "read".into(),
719 input: serde_json::json!({"path": "x"}),
720 },
721 ]);
722 assert_eq!(content.text(), Some("thinking..."));
723 }
724
725 #[test]
726 fn message_content_blocks_no_text() {
727 let content = MessageContent::Blocks(vec![ContentBlock::ToolUse {
728 id: "call_1".into(),
729 name: "read".into(),
730 input: serde_json::json!({}),
731 }]);
732 assert_eq!(content.text(), None);
733 }
734
735 #[test]
736 fn message_content_serde_roundtrip_text() {
737 let original = MessageContent::Text("plain text".into());
738 let json = serde_json::to_string(&original).expect("serialize");
739 let back: MessageContent = serde_json::from_str(&json).expect("deserialize");
740 assert_eq!(back.text(), Some("plain text"));
741 }
742
743 #[test]
744 fn message_content_serde_roundtrip_blocks() {
745 let original = MessageContent::Blocks(vec![
746 ContentBlock::Text {
747 text: "here is the file:".into(),
748 },
749 ContentBlock::ToolUse {
750 id: "c1".into(),
751 name: "read".into(),
752 input: serde_json::json!({"path": "main.rs"}),
753 },
754 ]);
755 let json = serde_json::to_string(&original).expect("serialize");
756 let back: MessageContent = serde_json::from_str(&json).expect("deserialize");
757 match back {
758 MessageContent::Blocks(blocks) => {
759 assert_eq!(blocks.len(), 2);
760 match &blocks[0] {
761 ContentBlock::Text { text } => assert_eq!(text, "here is the file:"),
762 other => panic!("expected Text block, got: {other:?}"),
763 }
764 match &blocks[1] {
765 ContentBlock::ToolUse { id, name, input } => {
766 assert_eq!(id, "c1");
767 assert_eq!(name, "read");
768 assert_eq!(input["path"], "main.rs");
769 }
770 other => panic!("expected ToolUse block, got: {other:?}"),
771 }
772 }
773 other => panic!("expected Blocks, got: {other:?}"),
774 }
775 }
776
777 #[test]
780 fn content_block_tool_result_serde() {
781 let block = ContentBlock::ToolResult {
782 tool_use_id: "c1".into(),
783 content: "fn main() {}".into(),
784 is_error: None,
785 };
786 let json = serde_json::to_string(&block).expect("serialize ToolResult");
787 assert!(json.contains(r#""type":"tool_result""#));
788 assert!(!json.contains("is_error"), "None should be omitted");
789
790 let back: ContentBlock = serde_json::from_str(&json).expect("deserialize ToolResult");
791 match back {
792 ContentBlock::ToolResult {
793 tool_use_id,
794 content,
795 is_error,
796 } => {
797 assert_eq!(tool_use_id, "c1");
798 assert_eq!(content, "fn main() {}");
799 assert!(is_error.is_none());
800 }
801 other => panic!("expected ToolResult, got: {other:?}"),
802 }
803 }
804
805 #[test]
806 fn content_block_tool_result_with_error() {
807 let block = ContentBlock::ToolResult {
808 tool_use_id: "c2".into(),
809 content: "permission denied".into(),
810 is_error: Some(true),
811 };
812 let json = serde_json::to_string(&block).expect("serialize");
813 assert!(json.contains(r#""is_error":true"#));
814 }
815}