Skip to main content

cpex_core/cmf/
view.rs

1// Location: ./crates/cpex-core/src/cmf/view.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// MessageView — read-only projection for policy evaluation.
7//
8// Decomposes a Message into individually addressable views with a
9// uniform interface regardless of content type. Zero-copy design —
10// properties are computed on-demand by borrowing the underlying
11// content part and extensions directly.
12//
13// Mirrors the Python MessageView in cpex/framework/cmf/view.py.
14
15use serde::{Deserialize, Serialize};
16
17use super::content::*;
18use super::enums::{ContentType, Role};
19use super::message::Message;
20use crate::hooks::payload::Extensions;
21
22// ---------------------------------------------------------------------------
23// Enums
24// ---------------------------------------------------------------------------
25
26/// Type of content a view represents.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ViewKind {
30    Text,
31    Thinking,
32    ToolCall,
33    ToolResult,
34    Resource,
35    ResourceRef,
36    PromptRequest,
37    PromptResult,
38    Image,
39    Video,
40    Audio,
41    Document,
42}
43
44/// The action this content represents in the data flow.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum ViewAction {
48    Read,
49    Write,
50    Execute,
51    Invoke,
52    Send,
53    Receive,
54    Generate,
55}
56
57impl ViewKind {
58    /// Map ContentType to ViewKind.
59    pub fn from_content_type(ct: ContentType) -> Self {
60        match ct {
61            ContentType::Text => ViewKind::Text,
62            ContentType::Thinking => ViewKind::Thinking,
63            ContentType::ToolCall => ViewKind::ToolCall,
64            ContentType::ToolResult => ViewKind::ToolResult,
65            ContentType::Resource => ViewKind::Resource,
66            ContentType::ResourceRef => ViewKind::ResourceRef,
67            ContentType::PromptRequest => ViewKind::PromptRequest,
68            ContentType::PromptResult => ViewKind::PromptResult,
69            ContentType::Image => ViewKind::Image,
70            ContentType::Video => ViewKind::Video,
71            ContentType::Audio => ViewKind::Audio,
72            ContentType::Document => ViewKind::Document,
73        }
74    }
75
76    /// The default action for this kind of content.
77    pub fn default_action(&self, role: Role) -> ViewAction {
78        match self {
79            ViewKind::ToolCall => ViewAction::Execute,
80            ViewKind::ToolResult => ViewAction::Receive,
81            ViewKind::Resource | ViewKind::ResourceRef => ViewAction::Read,
82            ViewKind::PromptRequest => ViewAction::Invoke,
83            ViewKind::PromptResult => ViewAction::Receive,
84            // Direction-dependent kinds
85            ViewKind::Text
86            | ViewKind::Thinking
87            | ViewKind::Image
88            | ViewKind::Video
89            | ViewKind::Audio
90            | ViewKind::Document => match role {
91                Role::User => ViewAction::Send,
92                Role::Assistant => ViewAction::Generate,
93                Role::Tool => ViewAction::Receive,
94                Role::System | Role::Developer => ViewAction::Write,
95            },
96        }
97    }
98
99    /// Whether this is a tool-related kind.
100    pub fn is_tool(&self) -> bool {
101        matches!(self, ViewKind::ToolCall | ViewKind::ToolResult)
102    }
103
104    /// Whether this is a resource-related kind.
105    pub fn is_resource(&self) -> bool {
106        matches!(self, ViewKind::Resource | ViewKind::ResourceRef)
107    }
108
109    /// Whether this is a prompt-related kind.
110    pub fn is_prompt(&self) -> bool {
111        matches!(self, ViewKind::PromptRequest | ViewKind::PromptResult)
112    }
113
114    /// Whether this is a media kind (image, video, audio, document).
115    pub fn is_media(&self) -> bool {
116        matches!(
117            self,
118            ViewKind::Image | ViewKind::Video | ViewKind::Audio | ViewKind::Document
119        )
120    }
121
122    /// Whether this is a text kind (text or thinking).
123    pub fn is_text(&self) -> bool {
124        matches!(self, ViewKind::Text | ViewKind::Thinking)
125    }
126}
127
128// ---------------------------------------------------------------------------
129// MessageView
130// ---------------------------------------------------------------------------
131
132/// Read-only, zero-copy view over a single content part.
133///
134/// Provides a uniform interface for policy evaluation regardless
135/// of content type. Properties are computed on-demand by borrowing
136/// the underlying content part and extensions.
137///
138/// Produced by `Message::iter_views()` or the standalone `iter_views()`.
139pub struct MessageView<'a> {
140    /// The underlying content part.
141    part: &'a ContentPart,
142    /// The kind of content.
143    kind: ViewKind,
144    /// The parent message role.
145    role: Role,
146    /// Optional hook location (e.g., "tool_pre_invoke").
147    hook: Option<&'a str>,
148    /// Optional extensions (for security/http context).
149    extensions: Option<&'a Extensions>,
150}
151
152impl<'a> MessageView<'a> {
153    /// Create a new view over a content part.
154    pub fn new(
155        part: &'a ContentPart,
156        role: Role,
157        hook: Option<&'a str>,
158        extensions: Option<&'a Extensions>,
159    ) -> Self {
160        let kind = match part {
161            ContentPart::Text { .. } => ViewKind::Text,
162            ContentPart::Thinking { .. } => ViewKind::Thinking,
163            ContentPart::ToolCall { .. } => ViewKind::ToolCall,
164            ContentPart::ToolResult { .. } => ViewKind::ToolResult,
165            ContentPart::Resource { .. } => ViewKind::Resource,
166            ContentPart::ResourceRef { .. } => ViewKind::ResourceRef,
167            ContentPart::PromptRequest { .. } => ViewKind::PromptRequest,
168            ContentPart::PromptResult { .. } => ViewKind::PromptResult,
169            ContentPart::Image { .. } => ViewKind::Image,
170            ContentPart::Video { .. } => ViewKind::Video,
171            ContentPart::Audio { .. } => ViewKind::Audio,
172            ContentPart::Document { .. } => ViewKind::Document,
173        };
174
175        Self {
176            part,
177            kind,
178            role,
179            hook,
180            extensions,
181        }
182    }
183
184    // -- Core properties --
185
186    /// The kind of content this view represents.
187    pub fn kind(&self) -> ViewKind {
188        self.kind
189    }
190
191    /// The role of the parent message.
192    pub fn role(&self) -> Role {
193        self.role
194    }
195
196    /// The underlying content part.
197    pub fn raw(&self) -> &'a ContentPart {
198        self.part
199    }
200
201    /// The hook location, if set.
202    pub fn hook(&self) -> Option<&str> {
203        self.hook
204    }
205
206    /// The action this content represents.
207    pub fn action(&self) -> ViewAction {
208        self.kind.default_action(self.role)
209    }
210
211    // -- Phase helpers --
212
213    /// Whether this is a pre-execution hook (tool_pre_invoke, prompt_pre_fetch, etc.).
214    pub fn is_pre(&self) -> bool {
215        self.hook.is_some_and(|h| h.contains("pre"))
216    }
217
218    /// Whether this is a post-execution hook.
219    pub fn is_post(&self) -> bool {
220        self.hook.is_some_and(|h| h.contains("post"))
221    }
222
223    // -- Universal properties --
224
225    /// Text content (for text, thinking, tool result content).
226    pub fn content(&self) -> Option<&str> {
227        match self.part {
228            ContentPart::Text { text } | ContentPart::Thinking { text } => Some(text),
229            ContentPart::ToolResult { content: tr } => {
230                tr.content.as_str().map(Some).unwrap_or(None)
231            },
232            ContentPart::Resource { content: r } => r.content.as_deref(),
233            ContentPart::PromptResult { content: pr } => pr.content.as_deref(),
234            _ => None,
235        }
236    }
237
238    /// Entity name (tool name, resource URI, prompt name).
239    pub fn name(&self) -> Option<&str> {
240        match self.part {
241            ContentPart::ToolCall { content: tc } => Some(&tc.name),
242            ContentPart::ToolResult { content: tr } => Some(&tr.tool_name),
243            ContentPart::Resource { content: r } => r.name.as_deref().or(Some(&r.uri)),
244            ContentPart::ResourceRef { content: rr } => rr.name.as_deref().or(Some(&rr.uri)),
245            ContentPart::PromptRequest { content: pr } => Some(&pr.name),
246            ContentPart::PromptResult { content: pr } => Some(&pr.prompt_name),
247            _ => None,
248        }
249    }
250
251    /// URI for the entity.
252    pub fn uri(&self) -> Option<String> {
253        match self.part {
254            ContentPart::ToolCall { content: tc } => Some(format!("tool://_/{}", tc.name)),
255            ContentPart::Resource { content: r } => Some(r.uri.clone()),
256            ContentPart::ResourceRef { content: rr } => Some(rr.uri.clone()),
257            ContentPart::PromptRequest { content: pr } => Some(format!("prompt://_/{}", pr.name)),
258            _ => None,
259        }
260    }
261
262    /// Arguments (for tool calls and prompt requests).
263    pub fn args(&self) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
264        match self.part {
265            ContentPart::ToolCall { content: tc } => Some(&tc.arguments),
266            ContentPart::PromptRequest { content: pr } => Some(&pr.arguments),
267            _ => None,
268        }
269    }
270
271    /// Get a specific argument by name.
272    pub fn get_arg(&self, name: &str) -> Option<&serde_json::Value> {
273        self.args().and_then(|a| a.get(name))
274    }
275
276    /// Whether this content has arguments.
277    pub fn has_arg(&self, name: &str) -> bool {
278        self.get_arg(name).is_some()
279    }
280
281    /// MIME type (for resources, media).
282    pub fn mime_type(&self) -> Option<&str> {
283        match self.part {
284            ContentPart::Resource { content: r } => r.mime_type.as_deref(),
285            ContentPart::Image { content: img } => img.media_type.as_deref(),
286            ContentPart::Video { content: vid } => vid.media_type.as_deref(),
287            ContentPart::Audio { content: aud } => aud.media_type.as_deref(),
288            ContentPart::Document { content: doc } => doc.media_type.as_deref(),
289            _ => None,
290        }
291    }
292
293    /// Whether the result is an error (tool results, prompt results).
294    pub fn is_error(&self) -> bool {
295        match self.part {
296            ContentPart::ToolResult { content: tr } => tr.is_error,
297            ContentPart::PromptResult { content: pr } => pr.is_error,
298            _ => false,
299        }
300    }
301
302    // -- Type helpers --
303
304    pub fn is_tool(&self) -> bool {
305        self.kind.is_tool()
306    }
307    pub fn is_resource(&self) -> bool {
308        self.kind.is_resource()
309    }
310    pub fn is_prompt(&self) -> bool {
311        self.kind.is_prompt()
312    }
313    pub fn is_media(&self) -> bool {
314        self.kind.is_media()
315    }
316    pub fn is_text(&self) -> bool {
317        self.kind.is_text()
318    }
319
320    // -- Extension accessors --
321
322    /// Get the extensions, if provided.
323    pub fn extensions(&self) -> Option<&'a Extensions> {
324        self.extensions
325    }
326
327    /// Check if a security label exists.
328    pub fn has_label(&self, label: &str) -> bool {
329        self.extensions
330            .and_then(|e| e.security.as_ref())
331            .map(|s| s.has_label(label))
332            .unwrap_or(false)
333    }
334
335    /// Get an HTTP header value.
336    pub fn get_header(&self, name: &str) -> Option<&str> {
337        self.extensions
338            .and_then(|e| e.http.as_ref())
339            .and_then(|h| h.get_header(name))
340    }
341
342    // -- Serialization --
343
344    /// Sensitive headers stripped during serialization.
345    const SENSITIVE_HEADERS: &'static [&'static str] = &["authorization", "cookie", "x-api-key"];
346
347    /// Serialize the view to a JSON-compatible map.
348    ///
349    /// Includes the view's properties, arguments, and optionally
350    /// text content and extension context. Sensitive headers
351    /// (Authorization, Cookie, X-API-Key) are stripped.
352    pub fn to_dict(&self, include_content: bool, include_context: bool) -> serde_json::Value {
353        use super::constants::*;
354
355        let mut result = serde_json::Map::new();
356
357        // Core fields
358        result.insert(FIELD_KIND.into(), serde_json::json!(self.kind));
359        result.insert(FIELD_ROLE.into(), serde_json::json!(self.role));
360        result.insert(FIELD_IS_PRE.into(), serde_json::json!(self.is_pre()));
361        result.insert(FIELD_IS_POST.into(), serde_json::json!(self.is_post()));
362        result.insert(FIELD_ACTION.into(), serde_json::json!(self.action()));
363
364        if let Some(hook) = self.hook {
365            result.insert(FIELD_HOOK.into(), serde_json::json!(hook));
366        }
367
368        if let Some(uri) = self.uri() {
369            result.insert(FIELD_URI.into(), serde_json::json!(uri));
370        }
371
372        if let Some(name) = self.name() {
373            result.insert(FIELD_NAME.into(), serde_json::json!(name));
374        }
375
376        // Content
377        if include_content {
378            if let Some(text) = self.content() {
379                result.insert(FIELD_SIZE_BYTES.into(), serde_json::json!(text.len()));
380                result.insert(FIELD_CONTENT.into(), serde_json::json!(text));
381            }
382        }
383
384        if let Some(mime) = self.mime_type() {
385            result.insert(FIELD_MIME_TYPE.into(), serde_json::json!(mime));
386        }
387
388        // Arguments
389        if let Some(args) = self.args() {
390            result.insert(FIELD_ARGUMENTS.into(), serde_json::json!(args));
391        }
392
393        // Extensions context
394        if include_context {
395            if let Some(ext) = self.extensions {
396                let mut ext_map = serde_json::Map::new();
397
398                // Subject
399                if let Some(ref sec) = ext.security {
400                    if let Some(ref subject) = sec.subject {
401                        let mut sub_map = serde_json::Map::new();
402                        if let Some(ref id) = subject.id {
403                            sub_map.insert(FIELD_ID.into(), serde_json::json!(id));
404                        }
405                        if let Some(ref st) = subject.subject_type {
406                            sub_map.insert(FIELD_TYPE.into(), serde_json::json!(st));
407                        }
408                        if !subject.roles.is_empty() {
409                            let mut roles: Vec<&String> = subject.roles.iter().collect();
410                            roles.sort();
411                            sub_map.insert(FIELD_ROLES.into(), serde_json::json!(roles));
412                        }
413                        if !subject.permissions.is_empty() {
414                            let mut perms: Vec<&String> = subject.permissions.iter().collect();
415                            perms.sort();
416                            sub_map.insert(FIELD_PERMISSIONS.into(), serde_json::json!(perms));
417                        }
418                        if !subject.teams.is_empty() {
419                            let mut teams: Vec<&String> = subject.teams.iter().collect();
420                            teams.sort();
421                            sub_map.insert(FIELD_TEAMS.into(), serde_json::json!(teams));
422                        }
423                        if !sub_map.is_empty() {
424                            ext_map
425                                .insert(FIELD_SUBJECT.into(), serde_json::Value::Object(sub_map));
426                        }
427                    }
428
429                    // Labels
430                    if !sec.labels.is_empty() {
431                        let mut labels: Vec<&String> = sec.labels.iter().collect();
432                        labels.sort();
433                        ext_map.insert(FIELD_LABELS.into(), serde_json::json!(labels));
434                    }
435                }
436
437                // Environment
438                if let Some(ref req) = ext.request {
439                    if let Some(ref env) = req.environment {
440                        ext_map.insert(FIELD_ENVIRONMENT.into(), serde_json::json!(env));
441                    }
442                }
443
444                // Request headers (strip sensitive)
445                if let Some(ref http) = ext.http {
446                    let safe: std::collections::HashMap<&String, &String> = http
447                        .request_headers
448                        .iter()
449                        .filter(|(k, _)| {
450                            !Self::SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str())
451                        })
452                        .collect();
453                    if !safe.is_empty() {
454                        ext_map.insert(FIELD_HEADERS.into(), serde_json::json!(safe));
455                    }
456                }
457
458                // Agent context
459                if let Some(ref agent) = ext.agent {
460                    let mut agent_map = serde_json::Map::new();
461                    if let Some(ref input) = agent.input {
462                        agent_map.insert(FIELD_INPUT.into(), serde_json::json!(input));
463                    }
464                    if let Some(ref sid) = agent.session_id {
465                        agent_map.insert(FIELD_SESSION_ID.into(), serde_json::json!(sid));
466                    }
467                    if let Some(ref cid) = agent.conversation_id {
468                        agent_map.insert(FIELD_CONVERSATION_ID.into(), serde_json::json!(cid));
469                    }
470                    if let Some(turn) = agent.turn {
471                        agent_map.insert(FIELD_TURN.into(), serde_json::json!(turn));
472                    }
473                    if let Some(ref aid) = agent.agent_id {
474                        agent_map.insert(FIELD_AGENT_ID.into(), serde_json::json!(aid));
475                    }
476                    if let Some(ref paid) = agent.parent_agent_id {
477                        agent_map.insert(FIELD_PARENT_AGENT_ID.into(), serde_json::json!(paid));
478                    }
479                    if !agent_map.is_empty() {
480                        ext_map.insert(FIELD_AGENT.into(), serde_json::Value::Object(agent_map));
481                    }
482                }
483
484                // Meta
485                if let Some(ref meta) = ext.meta {
486                    let mut meta_map = serde_json::Map::new();
487                    if let Some(ref et) = meta.entity_type {
488                        meta_map.insert(FIELD_ENTITY_TYPE.into(), serde_json::json!(et));
489                    }
490                    if let Some(ref en) = meta.entity_name {
491                        meta_map.insert(FIELD_ENTITY_NAME.into(), serde_json::json!(en));
492                    }
493                    if !meta.tags.is_empty() {
494                        let mut tags: Vec<&String> = meta.tags.iter().collect();
495                        tags.sort();
496                        meta_map.insert(FIELD_TAGS.into(), serde_json::json!(tags));
497                    }
498                    if !meta_map.is_empty() {
499                        ext_map.insert(FIELD_META.into(), serde_json::Value::Object(meta_map));
500                    }
501                }
502
503                if !ext_map.is_empty() {
504                    result.insert(FIELD_EXTENSIONS.into(), serde_json::Value::Object(ext_map));
505                }
506            }
507        }
508
509        serde_json::Value::Object(result)
510    }
511
512    /// Serialize to OPA-compatible input format.
513    ///
514    /// Wraps the view in the standard OPA input envelope:
515    /// `{"input": {...view data...}}`.
516    pub fn to_opa_input(&self, include_content: bool) -> serde_json::Value {
517        use super::constants::FIELD_OPA_INPUT;
518        serde_json::json!({
519            FIELD_OPA_INPUT: self.to_dict(include_content, true)
520        })
521    }
522}
523
524impl<'a> std::fmt::Debug for MessageView<'a> {
525    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526        f.debug_struct("MessageView")
527            .field("kind", &self.kind)
528            .field("role", &self.role)
529            .field("name", &self.name())
530            .field("hook", &self.hook)
531            .finish()
532    }
533}
534
535// ---------------------------------------------------------------------------
536// iter_views — decompose a Message into views
537// ---------------------------------------------------------------------------
538
539/// Decompose a Message into individually addressable MessageViews.
540///
541/// Yields one view per content part. Each view provides a uniform
542/// interface for policy evaluation regardless of content type.
543pub fn iter_views<'a>(
544    message: &'a Message,
545    hook: Option<&'a str>,
546    extensions: Option<&'a Extensions>,
547) -> impl Iterator<Item = MessageView<'a>> {
548    message
549        .content
550        .iter()
551        .map(move |part| MessageView::new(part, message.role, hook, extensions))
552}
553
554// Also add iter_views to Message
555impl Message {
556    /// Decompose this message into individually addressable MessageViews.
557    ///
558    /// Yields one view per content part. Each view provides a uniform
559    /// interface for policy evaluation regardless of content type.
560    pub fn iter_views<'a>(
561        &'a self,
562        hook: Option<&'a str>,
563        extensions: Option<&'a Extensions>,
564    ) -> impl Iterator<Item = MessageView<'a>> {
565        iter_views(self, hook, extensions)
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::cmf::enums::Role;
573    use crate::hooks::payload::MetaExtension;
574
575    fn make_test_message() -> Message {
576        Message {
577            schema_version: "2.0".into(),
578            role: Role::Assistant,
579            content: vec![
580                ContentPart::Thinking {
581                    text: "Let me think...".into(),
582                },
583                ContentPart::Text {
584                    text: "Here's the answer.".into(),
585                },
586                ContentPart::ToolCall {
587                    content: ToolCall {
588                        tool_call_id: "tc_001".into(),
589                        name: "get_weather".into(),
590                        arguments: [("city".to_string(), serde_json::json!("London"))].into(),
591                        namespace: None,
592                    },
593                },
594                ContentPart::Resource {
595                    content: Resource {
596                        resource_request_id: "rr_001".into(),
597                        uri: "file:///data.csv".into(),
598                        name: Some("Data File".into()),
599                        resource_type: crate::cmf::enums::ResourceType::File,
600                        content: Some("col1,col2".into()),
601                        mime_type: Some("text/csv".into()),
602                        ..Default::default()
603                    },
604                },
605            ],
606            channel: None,
607        }
608    }
609
610    #[test]
611    fn test_iter_views_count() {
612        let msg = make_test_message();
613        let views: Vec<_> = msg.iter_views(None, None).collect();
614        assert_eq!(views.len(), 4);
615    }
616
617    #[test]
618    fn test_view_kinds() {
619        let msg = make_test_message();
620        let views: Vec<_> = msg.iter_views(None, None).collect();
621        assert_eq!(views[0].kind(), ViewKind::Thinking);
622        assert_eq!(views[1].kind(), ViewKind::Text);
623        assert_eq!(views[2].kind(), ViewKind::ToolCall);
624        assert_eq!(views[3].kind(), ViewKind::Resource);
625    }
626
627    #[test]
628    fn test_view_content() {
629        let msg = make_test_message();
630        let views: Vec<_> = msg.iter_views(None, None).collect();
631        assert_eq!(views[0].content(), Some("Let me think..."));
632        assert_eq!(views[1].content(), Some("Here's the answer."));
633        assert!(views[2].content().is_none()); // tool call has no text content
634        assert_eq!(views[3].content(), Some("col1,col2")); // resource has text content
635    }
636
637    #[test]
638    fn test_view_name() {
639        let msg = make_test_message();
640        let views: Vec<_> = msg.iter_views(None, None).collect();
641        assert!(views[0].name().is_none()); // thinking has no name
642        assert!(views[1].name().is_none()); // text has no name
643        assert_eq!(views[2].name(), Some("get_weather"));
644        assert_eq!(views[3].name(), Some("Data File"));
645    }
646
647    #[test]
648    fn test_view_uri() {
649        let msg = make_test_message();
650        let views: Vec<_> = msg.iter_views(None, None).collect();
651        assert_eq!(views[2].uri(), Some("tool://_/get_weather".to_string()));
652        assert_eq!(views[3].uri(), Some("file:///data.csv".to_string()));
653    }
654
655    #[test]
656    fn test_view_args() {
657        let msg = make_test_message();
658        let views: Vec<_> = msg.iter_views(None, None).collect();
659        let tool_view = &views[2];
660        assert!(tool_view.has_arg("city"));
661        assert_eq!(tool_view.get_arg("city").unwrap(), "London");
662        assert!(!tool_view.has_arg("nonexistent"));
663    }
664
665    #[test]
666    fn test_view_action() {
667        let msg = make_test_message();
668        let views: Vec<_> = msg.iter_views(None, None).collect();
669        assert_eq!(views[0].action(), ViewAction::Generate); // thinking from assistant
670        assert_eq!(views[1].action(), ViewAction::Generate); // text from assistant
671        assert_eq!(views[2].action(), ViewAction::Execute); // tool call
672        assert_eq!(views[3].action(), ViewAction::Read); // resource
673    }
674
675    #[test]
676    fn test_view_action_user_role() {
677        let msg = Message::text(Role::User, "Hello");
678        let views: Vec<_> = msg.iter_views(None, None).collect();
679        assert_eq!(views[0].action(), ViewAction::Send); // text from user
680    }
681
682    #[test]
683    fn test_view_hook_pre_post() {
684        let msg = make_test_message();
685        let pre_views: Vec<_> = msg.iter_views(Some("tool_pre_invoke"), None).collect();
686        assert!(pre_views[0].is_pre());
687        assert!(!pre_views[0].is_post());
688
689        let post_views: Vec<_> = msg.iter_views(Some("tool_post_invoke"), None).collect();
690        assert!(post_views[0].is_post());
691        assert!(!post_views[0].is_pre());
692    }
693
694    #[test]
695    fn test_view_type_helpers() {
696        let msg = make_test_message();
697        let views: Vec<_> = msg.iter_views(None, None).collect();
698        assert!(views[0].is_text()); // thinking
699        assert!(views[1].is_text()); // text
700        assert!(views[2].is_tool()); // tool call
701        assert!(views[3].is_resource()); // resource
702    }
703
704    #[test]
705    fn test_view_mime_type() {
706        let msg = make_test_message();
707        let views: Vec<_> = msg.iter_views(None, None).collect();
708        assert_eq!(views[3].mime_type(), Some("text/csv"));
709    }
710
711    #[test]
712    fn test_view_with_extensions() {
713        use crate::extensions::{HttpExtension, SecurityExtension};
714        use std::sync::Arc;
715
716        let mut security = SecurityExtension::default();
717        security.add_label("PII");
718
719        let mut http = HttpExtension::default();
720        http.set_header("Authorization", "Bearer tok");
721
722        let ext = Extensions {
723            security: Some(Arc::new(security)),
724            http: Some(Arc::new(http)),
725            ..Default::default()
726        };
727
728        let msg = make_test_message();
729        let views: Vec<_> = msg.iter_views(None, Some(&ext)).collect();
730
731        assert!(views[0].has_label("PII"));
732        assert!(!views[0].has_label("HIPAA"));
733        assert_eq!(views[0].get_header("Authorization"), Some("Bearer tok"));
734    }
735
736    #[test]
737    fn test_to_dict_basic() {
738        let msg = Message::text(Role::User, "Hello world");
739        let views: Vec<_> = msg.iter_views(Some("llm_input"), None).collect();
740        let dict = views[0].to_dict(true, false);
741
742        assert_eq!(dict["kind"], "text");
743        assert_eq!(dict["role"], "user");
744        assert_eq!(dict["action"], "send");
745        assert_eq!(dict["hook"], "llm_input");
746        assert_eq!(dict["content"], "Hello world");
747        assert_eq!(dict["size_bytes"], 11);
748        assert_eq!(dict["is_pre"], false);
749        assert_eq!(dict["is_post"], false);
750    }
751
752    #[test]
753    fn test_to_dict_tool_call() {
754        let msg = make_test_message();
755        let views: Vec<_> = msg.iter_views(Some("tool_pre_invoke"), None).collect();
756        let dict = views[2].to_dict(true, false); // tool call
757
758        assert_eq!(dict["kind"], "tool_call");
759        assert_eq!(dict["name"], "get_weather");
760        assert_eq!(dict["uri"], "tool://_/get_weather");
761        assert_eq!(dict["action"], "execute");
762        assert_eq!(dict["is_pre"], true);
763        assert!(dict["arguments"].is_object());
764        assert_eq!(dict["arguments"]["city"], "London");
765    }
766
767    #[test]
768    fn test_to_dict_without_content() {
769        let msg = Message::text(Role::User, "Secret message");
770        let views: Vec<_> = msg.iter_views(None, None).collect();
771        let dict = views[0].to_dict(false, false);
772
773        assert!(dict.get("content").is_none());
774        assert!(dict.get("size_bytes").is_none());
775    }
776
777    #[test]
778    fn test_to_dict_with_extensions() {
779        use crate::extensions::{
780            AgentExtension, HttpExtension, RequestExtension, SecurityExtension,
781        };
782        use std::sync::Arc;
783
784        let mut security = SecurityExtension::default();
785        security.add_label("PII");
786        security.subject = Some(crate::extensions::security::SubjectExtension {
787            id: Some("alice".into()),
788            subject_type: Some(crate::extensions::security::SubjectType::User),
789            roles: ["admin".to_string()].into(),
790            ..Default::default()
791        });
792
793        let mut http = HttpExtension::default();
794        http.set_header("Authorization", "Bearer secret");
795        http.set_header("X-Request-ID", "req-123");
796
797        let ext = Extensions {
798            security: Some(Arc::new(security)),
799            http: Some(Arc::new(http)),
800            request: Some(Arc::new(RequestExtension {
801                environment: Some("production".into()),
802                ..Default::default()
803            })),
804            agent: Some(Arc::new(AgentExtension {
805                session_id: Some("sess-001".into()),
806                agent_id: Some("agent-x".into()),
807                ..Default::default()
808            })),
809            meta: Some(Arc::new(MetaExtension {
810                entity_type: Some("tool".into()),
811                entity_name: Some("get_compensation".into()),
812                tags: ["pii".to_string()].into(),
813                ..Default::default()
814            })),
815            ..Default::default()
816        };
817
818        let msg = Message::text(Role::User, "test");
819        let views: Vec<_> = msg.iter_views(None, Some(&ext)).collect();
820        let dict = views[0].to_dict(true, true);
821
822        let extensions = &dict["extensions"];
823
824        // Subject visible
825        assert_eq!(extensions["subject"]["id"], "alice");
826        assert!(extensions["subject"]["roles"]
827            .as_array()
828            .unwrap()
829            .contains(&serde_json::json!("admin")));
830
831        // Labels visible
832        assert!(extensions["labels"]
833            .as_array()
834            .unwrap()
835            .contains(&serde_json::json!("PII")));
836
837        // Environment visible
838        assert_eq!(extensions["environment"], "production");
839
840        // Headers visible — but Authorization stripped (sensitive)
841        assert!(extensions["headers"].get("Authorization").is_none());
842        assert_eq!(extensions["headers"]["X-Request-ID"], "req-123");
843
844        // Agent context visible
845        assert_eq!(extensions["agent"]["session_id"], "sess-001");
846        assert_eq!(extensions["agent"]["agent_id"], "agent-x");
847
848        // Meta visible
849        assert_eq!(extensions["meta"]["entity_type"], "tool");
850        assert_eq!(extensions["meta"]["entity_name"], "get_compensation");
851    }
852
853    #[test]
854    fn test_to_opa_input() {
855        let msg = Message::text(Role::User, "Hello");
856        let views: Vec<_> = msg.iter_views(None, None).collect();
857        let opa = views[0].to_opa_input(true);
858
859        assert!(opa.get("input").is_some());
860        assert_eq!(opa["input"]["kind"], "text");
861        assert_eq!(opa["input"]["role"], "user");
862        assert_eq!(opa["input"]["content"], "Hello");
863    }
864}