Skip to main content

orcs_types/
intent.rs

1//! ActionIntent domain types.
2//!
3//! `ActionIntent` is the central domain concept in ORCS: a declaration of
4//! "what someone wants to do", independent of who issued it (LLM, Lua, User)
5//! or how it gets resolved (Internal Rust dispatch, Component RPC, etc.).
6//!
7//! # Design Rationale
8//!
9//! Three previously separate dispatch paths converge into one:
10//!
11//! ```text
12//! LLM tool_calls ─────┐
13//!                      │
14//! orcs.dispatch() ────>├──→ ActionIntent ──→ Resolver ──→ Executor
15//!                      │
16//! orcs.request() ─────┘
17//! ```
18//!
19//! Rust encapsulates all complexity (provider differences, permission checks,
20//! session management, Component RPC, Hook execution) so that Lua callers
21//! deal only with intents and results.
22//!
23//! # Lifecycle
24//!
25//! 1. **Declaration** — `ActionIntent` created (from LLM, Lua, or system)
26//! 2. **Enrichment** — `IntentMeta` attached (priority, confidence, latency)
27//! 3. **Resolution** — `IntentResolver` found via `IntentRegistry`
28//! 4. **Execution** — Resolver dispatches (Internal tool / Component RPC)
29//! 5. **Result** — `IntentResult` returned to caller
30
31use serde::{Deserialize, Serialize};
32
33// ── ActionIntent ─────────────────────────────────────────────────────
34
35/// A declaration of "what someone wants to do".
36///
37/// Provider-agnostic, source-agnostic. This is the domain primitive
38/// that unifies LLM tool_calls, `orcs.dispatch()`, and Component RPC.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ActionIntent {
41    /// Correlation ID (from LLM tool_call id, or auto-generated).
42    pub id: String,
43
44    /// Action name (e.g. "read", "exec", "run_skill").
45    pub name: String,
46
47    /// Arguments (JSON object). Schema validated at resolution time.
48    pub params: serde_json::Value,
49
50    /// Execution metadata (priority, confidence, latency hint, etc.).
51    #[serde(default)]
52    pub meta: IntentMeta,
53}
54
55impl ActionIntent {
56    /// Create a new intent with auto-generated ID.
57    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    /// Create from an LLM tool_call (preserves the provider-assigned ID).
67    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    /// Attach metadata (builder-style).
84    #[must_use]
85    pub fn with_meta(mut self, meta: IntentMeta) -> Self {
86        self.meta = meta;
87        self
88    }
89}
90
91// ── IntentMeta ───────────────────────────────────────────────────────
92
93/// Execution metadata attached to an intent.
94///
95/// All fields are optional; callers set what they know.
96/// The resolver / executor may use these for scheduling, UX feedback,
97/// or Human-in-the-Loop gating.
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct IntentMeta {
100    /// Who issued the intent (diagnostic / audit).
101    #[serde(default)]
102    pub source: IntentSource,
103
104    /// Scheduling priority.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub priority: Option<Priority>,
107
108    /// Estimated execution time in milliseconds (UX feedback).
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub expected_latency_ms: Option<u64>,
111
112    /// Issuer's confidence that this intent is correct.
113    /// Below a threshold → Human-in-the-Loop confirmation.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub confidence: Option<Confidence>,
116}
117
118// ── IntentSource ─────────────────────────────────────────────────────
119
120/// Who issued the intent.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum IntentSource {
124    /// Lua script via `orcs.dispatch()` or `orcs.intent()`.
125    #[default]
126    Lua,
127    /// LLM provider's tool_calls response.
128    LlmToolCall,
129    /// System-generated (timer, hook, etc.).
130    System,
131}
132
133// ── Priority ─────────────────────────────────────────────────────────
134
135/// Scheduling priority for intent execution.
136#[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// ── Confidence ──────────────────────────────────────────────────────
146
147/// Confidence score in the range `[0.0, 1.0]`.
148///
149/// Values outside the range (including `NaN` and infinities) are rejected
150/// at construction time.  Below a configurable threshold this triggers
151/// Human-in-the-Loop confirmation.
152///
153/// ```
154/// use orcs_types::intent::Confidence;
155///
156/// assert!(Confidence::new(0.95).is_some());
157/// assert!(Confidence::new(-0.1).is_none());
158/// assert!(Confidence::new(1.1).is_none());
159/// assert!(Confidence::new(f64::NAN).is_none());
160/// ```
161#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
162pub struct Confidence(f64);
163
164impl Confidence {
165    /// Creates a `Confidence` from a raw `f64`.
166    ///
167    /// Returns `None` if the value is outside `[0.0, 1.0]` or is not finite.
168    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    /// Returns the inner `f64` value.
177    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// ── IntentResolver ───────────────────────────────────────────────────
204
205/// Where an intent gets resolved.
206///
207/// The `IntentRegistry` maps intent names to resolvers. When an intent
208/// arrives, the registry looks up the resolver and dispatches accordingly.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum IntentResolver {
211    /// Rust-internal dispatch (the 8 builtin tools: read, write, grep, …).
212    Internal,
213
214    /// Component RPC via EventBus.
215    Component {
216        /// Fully-qualified component name (e.g. "lua::skill_manager").
217        component_fqn: String,
218        /// Operation name passed to the component's `on_request`.
219        operation: String,
220        /// Optional RPC timeout override (milliseconds).
221        /// Defaults to `DEFAULT_TIMEOUT_MS` (30s) when `None`.
222        timeout_ms: Option<u64>,
223    },
224
225    /// MCP server tool dispatch.
226    ///
227    /// The tool is invoked via JSON-RPC `tools/call` on the named server.
228    /// `McpClientManager` handles the actual protocol communication.
229    Mcp {
230        /// MCP server name (key in `[mcp.servers]` config).
231        server_name: String,
232        /// Original tool name on the MCP server (before namespacing).
233        tool_name: String,
234    },
235}
236
237// ── IntentDef ────────────────────────────────────────────────────────
238
239/// Definition of a named intent, registered in `IntentRegistry`.
240///
241/// Serves two purposes:
242/// 1. Generate LLM API `tools` array (name + description + parameters)
243/// 2. Route intents to their resolver (Internal / Component)
244#[derive(Debug, Clone)]
245pub struct IntentDef {
246    /// Unique intent name (e.g. "read", "run_skill").
247    pub name: String,
248
249    /// Human-readable description (sent to LLM).
250    pub description: String,
251
252    /// JSON Schema for parameters (sent to LLM).
253    pub parameters: serde_json::Value,
254
255    /// Where this intent gets resolved.
256    pub resolver: IntentResolver,
257}
258
259// ── IntentResult ─────────────────────────────────────────────────────
260
261/// Outcome of executing an intent.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct IntentResult {
264    /// Correlation ID (matches `ActionIntent.id`).
265    pub intent_id: String,
266
267    /// Intent name (for diagnostics).
268    pub name: String,
269
270    /// Whether execution succeeded.
271    pub ok: bool,
272
273    /// Result payload (tool output on success, error detail on failure).
274    pub content: serde_json::Value,
275
276    /// Error message (when `ok == false`).
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub error: Option<String>,
279
280    /// Execution duration in milliseconds.
281    pub duration_ms: u64,
282}
283
284// ── StopReason ───────────────────────────────────────────────────────
285
286/// Why the LLM stopped generating.
287///
288/// Normalized from provider-specific values:
289/// - Ollama:    tool_calls array presence
290/// - OpenAI:    `finish_reason == "tool_calls"`
291/// - Anthropic: `stop_reason == "tool_use"`
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum StopReason {
295    /// Model finished its response (final answer).
296    EndTurn,
297    /// Model wants to use tools (intents issued).
298    ToolUse,
299    /// Output truncated by token limit.
300    MaxTokens,
301}
302
303// ── Role ─────────────────────────────────────────────────────────────
304
305/// Conversation message role.
306///
307/// Domain roles (System, User, Assistant) plus wire-format roles (Tool)
308/// needed for OpenAI/Ollama tool result messages.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "lowercase")]
311pub enum Role {
312    System,
313    User,
314    Assistant,
315    /// Tool result feedback (OpenAI/Ollama wire format: `role: "tool"`).
316    Tool,
317}
318
319// ── MessageContent ───────────────────────────────────────────────────
320
321/// Message content: plain text or structured blocks.
322///
323/// Internal representation uses the Anthropic content-block model
324/// (the only format where text + tool_use coexist in a single message).
325/// Converted to provider-specific wire format in `build_*_body`.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(untagged)]
328pub enum MessageContent {
329    /// Plain text (common case, backward-compatible).
330    Text(String),
331    /// Structured content blocks (tool_use, tool_result, mixed).
332    Blocks(Vec<ContentBlock>),
333}
334
335impl MessageContent {
336    /// Extract the text portion, ignoring tool blocks.
337    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// ── ContentBlock ─────────────────────────────────────────────────────
361
362/// A single block within a structured message.
363#[derive(Debug, Clone, Serialize, Deserialize)]
364#[serde(tag = "type")]
365pub enum ContentBlock {
366    /// Text content.
367    #[serde(rename = "text")]
368    Text { text: String },
369
370    /// Tool use request (assistant → system).
371    #[serde(rename = "tool_use")]
372    ToolUse {
373        id: String,
374        name: String,
375        input: serde_json::Value,
376    },
377
378    /// Tool result feedback (system → assistant).
379    #[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    // ── ActionIntent ─────────────────────────────────────────────
393
394    #[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    // ── Serialization round-trip ─────────────────────────────────
440
441    #[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        // Optional fields omitted
464        assert!(
465            !json.contains("priority"),
466            "priority should be skipped when None"
467        );
468    }
469
470    // ── IntentSource ─────────────────────────────────────────────
471
472    #[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    // ── Priority ─────────────────────────────────────────────────
493
494    #[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    // ── IntentResolver ───────────────────────────────────────────
518
519    #[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    // ── IntentDef ────────────────────────────────────────────────
551
552    #[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    // ── IntentResult ─────────────────────────────────────────────
572
573    #[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        // error=None should be omitted in JSON
617        assert!(!json.contains("error"));
618    }
619
620    // ── StopReason ───────────────────────────────────────────────
621
622    #[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    // ── Role ─────────────────────────────────────────────────────
638
639    #[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    // ── Confidence ────────────────────────────────────────────────
656
657    #[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    // ── MessageContent ───────────────────────────────────────────
700
701    #[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    // ── ContentBlock ─────────────────────────────────────────────
778
779    #[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}