Skip to main content

meerkat_runtime/
comms_bridge.rs

1//! Runtime comms bridge helpers.
2//!
3//! These helpers translate drained comms interactions into the runtime-owned
4//! input families used by the comms classification bridge.
5
6use chrono::Utc;
7#[cfg(test)]
8use meerkat_core::comms::PeerId;
9use meerkat_core::interaction::{
10    InboxInteraction, InteractionContent, PeerIngressConvention, PeerIngressFact, PeerIngressKind,
11    PeerInputCandidate, PeerInputClass,
12};
13#[cfg(test)]
14use meerkat_core::interaction::{PeerIngressIdentity, ResponseStatus};
15use meerkat_core::lifecycle::InputId;
16
17use crate::identifiers::{CorrelationId, LogicalRuntimeId};
18use crate::input::{
19    ExternalEventInput, Input, InputDurability, InputHeader, InputOrigin, InputVisibility,
20    PeerConvention, PeerInput, ResponseProgressPhase, ResponseTerminalStatus,
21    peer_response_terminal_idempotency_key,
22};
23
24#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
25pub enum PeerIngressProjectionError {
26    #[error(
27        "classified peer ingress {interaction_id} ({kind:?}) cannot project to a runtime PeerInput"
28    )]
29    UnsupportedPeerConvention {
30        interaction_id: meerkat_core::InteractionId,
31        kind: PeerIngressKind,
32    },
33    #[error("classified peer ingress {interaction_id} missing canonical peer id")]
34    MissingCanonicalPeerId {
35        interaction_id: meerkat_core::InteractionId,
36    },
37    #[error("classified peer response {interaction_id} missing machine response terminality")]
38    MissingResponseTerminality {
39        interaction_id: meerkat_core::InteractionId,
40    },
41    #[error(
42        "classified peer response {interaction_id} has unsupported machine response terminality"
43    )]
44    UnsupportedResponseTerminality {
45        interaction_id: meerkat_core::InteractionId,
46    },
47}
48
49/// Convert a classified comms interaction into the appropriate runtime-owned
50/// input family.
51pub fn classified_interaction_to_runtime_input(
52    classified: &PeerInputCandidate,
53    runtime_id: &LogicalRuntimeId,
54) -> Result<Input, PeerIngressProjectionError> {
55    let interaction = &classified.interaction;
56
57    if classified.class() == PeerInputClass::PlainEvent {
58        let source_name = classified
59            .ingress
60            .plain_event_source_name()
61            .unwrap_or("unknown");
62        let blocks = external_event_blocks(interaction);
63        return Ok(Input::ExternalEvent(ExternalEventInput {
64            header: InputHeader {
65                id: InputId::new(),
66                timestamp: Utc::now(),
67                source: InputOrigin::External {
68                    source_name: source_name.to_string(),
69                },
70                durability: InputDurability::Durable,
71                visibility: InputVisibility {
72                    transcript_eligible: true,
73                    operator_eligible: true,
74                },
75                idempotency_key: None,
76                supersession_key: None,
77                correlation_id: Some(CorrelationId::from_uuid(interaction.id.0)),
78            },
79            event_type: source_name.to_string(),
80            payload: external_event_payload(interaction),
81            blocks,
82            handling_mode: interaction.handling_mode,
83            render_metadata: interaction.render_metadata.clone(),
84            objective_id: interaction.objective_id,
85        }));
86    }
87
88    peer_candidate_to_peer_input(classified, runtime_id)
89}
90
91fn peer_candidate_to_peer_input(
92    classified: &PeerInputCandidate,
93    runtime_id: &LogicalRuntimeId,
94) -> Result<Input, PeerIngressProjectionError> {
95    peer_input_from_ingress_fact(
96        &classified.interaction,
97        runtime_id,
98        &classified.ingress,
99        classified.response_terminality,
100    )
101}
102
103fn peer_input_from_ingress_fact(
104    interaction: &InboxInteraction,
105    runtime_id: &LogicalRuntimeId,
106    ingress: &PeerIngressFact,
107    response_terminality: Option<meerkat_core::interaction::TerminalityClass>,
108) -> Result<Input, PeerIngressProjectionError> {
109    let convention = map_ingress_convention(interaction.id, ingress, response_terminality)?;
110    let transcript_correlation_id = transcript_correlation_id(interaction, &convention);
111    let durability = map_durability(&convention);
112    let handling_mode = match &convention {
113        PeerConvention::ResponseProgress { .. } => None,
114        _ => Some(interaction.handling_mode),
115    };
116    let canonical_peer_id =
117        ingress
118            .canonical_peer_id
119            .ok_or(PeerIngressProjectionError::MissingCanonicalPeerId {
120                interaction_id: interaction.id,
121            })?;
122    let peer_id = canonical_peer_id.to_string();
123    let idempotency_key =
124        matches!(&convention, PeerConvention::ResponseTerminal { .. }).then(|| {
125            peer_response_terminal_idempotency_key(
126                canonical_peer_id,
127                meerkat_core::PeerCorrelationId::from_uuid(transcript_correlation_id.0),
128            )
129        });
130    let display_identity = ingress
131        .route
132        .as_ref()
133        .map(meerkat_core::PeerRoute::label)
134        .or_else(|| ingress.display_label());
135
136    Ok(Input::Peer(PeerInput {
137        directed_interaction_id: None,
138        system_prompts: Vec::new(),
139        injected_context: Vec::new(),
140        header: InputHeader {
141            id: InputId::new(),
142            timestamp: Utc::now(),
143            source: InputOrigin::Peer {
144                peer_id,
145                display_identity,
146                runtime_id: Some(runtime_id.clone()),
147            },
148            durability,
149            visibility: InputVisibility {
150                transcript_eligible: true,
151                operator_eligible: true,
152            },
153            idempotency_key,
154            supersession_key: None,
155            correlation_id: Some(CorrelationId::from_uuid(transcript_correlation_id.0)),
156        },
157        convention: Some(convention),
158        content: match peer_blocks(interaction) {
159            Some(blocks) => meerkat_core::types::ContentInput::Blocks(blocks),
160            None => meerkat_core::types::ContentInput::Text(peer_rendered_body(interaction)),
161        },
162        payload: peer_payload(interaction),
163        handling_mode,
164        // Content-adjacent metadata from the classified interaction: the
165        // sender's signed taint declaration (when one was made) rides to the
166        // typed transcript notice.
167        sender_taint: interaction.sender_taint,
168        objective_id: interaction.objective_id,
169    }))
170}
171
172fn transcript_correlation_id(
173    interaction: &InboxInteraction,
174    convention: &PeerConvention,
175) -> meerkat_core::InteractionId {
176    match (convention, &interaction.content) {
177        (
178            PeerConvention::ResponseProgress { .. } | PeerConvention::ResponseTerminal { .. },
179            InteractionContent::Response { in_reply_to, .. },
180        ) => *in_reply_to,
181        _ => interaction.id,
182    }
183}
184
185fn map_ingress_convention(
186    interaction_id: meerkat_core::InteractionId,
187    ingress: &PeerIngressFact,
188    response_terminality: Option<meerkat_core::interaction::TerminalityClass>,
189) -> Result<PeerConvention, PeerIngressProjectionError> {
190    match &ingress.convention {
191        PeerIngressConvention::Message => Ok(PeerConvention::Message),
192        PeerIngressConvention::Request { request_id, intent } => Ok(PeerConvention::Request {
193            request_id: request_id.clone(),
194            intent: intent.clone(),
195        }),
196        PeerIngressConvention::Response {
197            in_reply_to,
198            status: _,
199        } => {
200            let terminality = response_terminality
201                .ok_or(PeerIngressProjectionError::MissingResponseTerminality { interaction_id })?;
202            map_response_convention(interaction_id, *in_reply_to, terminality)
203        }
204        PeerIngressConvention::Lifecycle { kind, .. } => Ok(PeerConvention::Request {
205            request_id: ingress.interaction_id.to_string(),
206            intent: kind.to_string(),
207        }),
208        PeerIngressConvention::Ack { .. } | PeerIngressConvention::PlainEvent { .. } => {
209            Err(PeerIngressProjectionError::UnsupportedPeerConvention {
210                interaction_id,
211                kind: ingress.kind,
212            })
213        }
214    }
215}
216
217fn map_response_convention(
218    interaction_id: meerkat_core::InteractionId,
219    in_reply_to: meerkat_core::InteractionId,
220    terminality: meerkat_core::interaction::TerminalityClass,
221) -> Result<PeerConvention, PeerIngressProjectionError> {
222    let request_id = in_reply_to.to_string();
223    Ok(match terminality {
224        meerkat_core::interaction::TerminalityClass::Progress => PeerConvention::ResponseProgress {
225            request_id,
226            phase: ResponseProgressPhase::Accepted,
227        },
228        meerkat_core::interaction::TerminalityClass::Terminal { disposition } => {
229            let term = match disposition {
230                meerkat_core::interaction::TerminalDisposition::Completed => {
231                    ResponseTerminalStatus::Completed
232                }
233                meerkat_core::interaction::TerminalDisposition::Failed => {
234                    ResponseTerminalStatus::Failed
235                }
236                _ => {
237                    return Err(PeerIngressProjectionError::UnsupportedResponseTerminality {
238                        interaction_id,
239                    });
240                }
241            };
242            PeerConvention::ResponseTerminal {
243                request_id,
244                status: term,
245            }
246        }
247        _ => {
248            return Err(PeerIngressProjectionError::UnsupportedResponseTerminality {
249                interaction_id,
250            });
251        }
252    })
253}
254
255fn peer_rendered_body(interaction: &InboxInteraction) -> String {
256    if !interaction.rendered_text.trim().is_empty() {
257        return interaction.rendered_text.clone();
258    }
259    match &interaction.content {
260        InteractionContent::Message { body, .. }
261        | InteractionContent::IncarnationFencedMessage { body, .. } => body.clone(),
262        InteractionContent::Request { params, .. } => {
263            serde_json::to_string(params).unwrap_or_default()
264        }
265        InteractionContent::Response { result, .. } => {
266            serde_json::to_string(result).unwrap_or_default()
267        }
268    }
269}
270
271fn peer_blocks(interaction: &InboxInteraction) -> Option<Vec<meerkat_core::types::ContentBlock>> {
272    match &interaction.content {
273        InteractionContent::Message { blocks, .. }
274        | InteractionContent::IncarnationFencedMessage { blocks, .. } => blocks.clone(),
275        InteractionContent::Request { blocks, .. } => blocks.clone(),
276        InteractionContent::Response { blocks, .. } => blocks.clone(),
277    }
278}
279
280fn peer_payload(interaction: &InboxInteraction) -> Option<serde_json::Value> {
281    match &interaction.content {
282        InteractionContent::Message { .. }
283        | InteractionContent::IncarnationFencedMessage { .. } => None,
284        InteractionContent::Request { params, .. } => Some(params.clone()),
285        InteractionContent::Response { result, .. } => Some(result.clone()),
286    }
287}
288
289fn external_event_payload(interaction: &InboxInteraction) -> serde_json::Value {
290    match &interaction.content {
291        InteractionContent::Message { body, .. }
292        | InteractionContent::IncarnationFencedMessage { body, .. } => {
293            serde_json::json!({ "body": body })
294        }
295        InteractionContent::Request { intent, params, .. } => {
296            serde_json::json!({ "intent": intent, "params": params })
297        }
298        InteractionContent::Response {
299            in_reply_to,
300            status,
301            result,
302            blocks,
303        } => serde_json::json!({
304            "in_reply_to": in_reply_to,
305            "status": status,
306            "result": result,
307            "blocks": blocks,
308        }),
309    }
310}
311
312fn external_event_blocks(
313    interaction: &InboxInteraction,
314) -> Option<Vec<meerkat_core::types::ContentBlock>> {
315    match &interaction.content {
316        InteractionContent::Message { blocks, .. }
317        | InteractionContent::IncarnationFencedMessage { blocks, .. } => blocks.clone(),
318        InteractionContent::Request { blocks, .. } => blocks.clone(),
319        _ => None,
320    }
321}
322
323fn map_durability(convention: &PeerConvention) -> InputDurability {
324    match convention {
325        PeerConvention::ResponseProgress { .. } => InputDurability::Ephemeral,
326        _ => InputDurability::Durable,
327    }
328}
329
330#[cfg(test)]
331#[allow(clippy::unwrap_used, clippy::panic)]
332mod tests {
333    use super::*;
334    use meerkat_core::interaction::{PeerIngressIdentity, ResponseStatus};
335
336    fn make_interaction_id() -> meerkat_core::interaction::InteractionId {
337        meerkat_core::interaction::InteractionId(meerkat_core::time_compat::new_uuid_v7())
338    }
339
340    fn plain_event_ingress(
341        id: meerkat_core::interaction::InteractionId,
342        source_name: &str,
343    ) -> PeerIngressFact {
344        PeerIngressFact::plain_event(
345            id,
346            source_name,
347            PeerInputClass::PlainEvent,
348            meerkat_core::PeerIngressKind::PlainEvent,
349        )
350    }
351
352    fn test_peer_id() -> PeerId {
353        PeerId::parse("22222222-2222-4222-8222-222222222222").expect("canonical test peer id")
354    }
355
356    fn peer_kind_for_convention(
357        convention: &PeerIngressConvention,
358    ) -> meerkat_core::PeerIngressKind {
359        match convention {
360            PeerIngressConvention::Message => meerkat_core::PeerIngressKind::Message,
361            PeerIngressConvention::Request { .. } | PeerIngressConvention::Lifecycle { .. } => {
362                meerkat_core::PeerIngressKind::Request
363            }
364            PeerIngressConvention::Response { .. } => meerkat_core::PeerIngressKind::Response,
365            PeerIngressConvention::Ack { .. } => meerkat_core::PeerIngressKind::Ack,
366            PeerIngressConvention::PlainEvent { .. } => meerkat_core::PeerIngressKind::PlainEvent,
367        }
368    }
369
370    fn peer_ingress(
371        id: meerkat_core::interaction::InteractionId,
372        peer_id: PeerId,
373        label: &str,
374        class: PeerInputClass,
375        convention: PeerIngressConvention,
376    ) -> PeerIngressFact {
377        let kind = peer_kind_for_convention(&convention);
378        PeerIngressFact::peer(
379            id,
380            class,
381            kind,
382            Some(meerkat_core::PeerIngressAuthDecision::Required),
383            PeerIngressIdentity::new(peer_id, label, convention),
384        )
385    }
386
387    fn candidate_for_interaction(interaction: InboxInteraction) -> PeerInputCandidate {
388        let peer_id = interaction.from_route.unwrap_or_else(test_peer_id);
389        crate::test_peer_input_candidate_from_interaction(interaction, peer_id)
390    }
391
392    fn peer_input_for_test(interaction: &InboxInteraction, runtime_id: &LogicalRuntimeId) -> Input {
393        let candidate = candidate_for_interaction(interaction.clone());
394        classified_interaction_to_runtime_input(&candidate, runtime_id)
395            .expect("test candidate should project to runtime input")
396    }
397
398    #[test]
399    fn message_to_peer_input() {
400        let interaction = InboxInteraction {
401            objective_id: None,
402            sender_taint: None,
403            from_route: None,
404            from: "peer-1".into(),
405            content: InteractionContent::Message {
406                body: "hello".into(),
407                blocks: None,
408            },
409            id: make_interaction_id(),
410            rendered_text: String::new(),
411            handling_mode: meerkat_core::types::HandlingMode::Queue,
412            render_metadata: None,
413        };
414        let input = peer_input_for_test(&interaction, &LogicalRuntimeId::new("test"));
415        if let Input::Peer(p) = &input {
416            assert!(matches!(p.convention, Some(PeerConvention::Message)));
417            assert_eq!(p.content.text_content(), "hello");
418            assert_eq!(
419                p.header.correlation_id,
420                Some(CorrelationId::from_uuid(interaction.id.0)),
421                "plain peer messages must use the inbound interaction id as the live/history dedup key",
422            );
423            assert_eq!(p.header.durability, InputDurability::Durable);
424            assert_eq!(
425                p.handling_mode,
426                Some(meerkat_core::types::HandlingMode::Queue),
427                "explicit queue must survive comms -> runtime projection so it can suppress running-turn interruption"
428            );
429        } else {
430            panic!("Expected PeerInput");
431        }
432    }
433
434    /// Ask 5 gate: the classified interaction's sender-declared content taint
435    /// threads through to `PeerInput.sender_taint` as content-adjacent
436    /// metadata, and `None` (no declaration) survives as `None` — never
437    /// coalesced into `Clean`.
438    #[test]
439    fn message_sender_taint_threads_to_peer_input() {
440        use meerkat_core::comms::SenderContentTaint;
441
442        for declared in [
443            Some(SenderContentTaint::Tainted),
444            Some(SenderContentTaint::Clean),
445            None,
446        ] {
447            let interaction = InboxInteraction {
448                objective_id: None,
449                sender_taint: declared,
450                from_route: None,
451                from: "peer-1".into(),
452                content: InteractionContent::Message {
453                    body: "hello".into(),
454                    blocks: None,
455                },
456                id: make_interaction_id(),
457                rendered_text: String::new(),
458                handling_mode: meerkat_core::types::HandlingMode::Queue,
459                render_metadata: None,
460            };
461            let input = peer_input_for_test(&interaction, &LogicalRuntimeId::new("test"));
462            let Input::Peer(p) = &input else {
463                panic!("Expected PeerInput");
464            };
465            assert_eq!(
466                p.sender_taint, declared,
467                "sender taint must ride the classified projection unchanged"
468            );
469        }
470    }
471
472    #[test]
473    fn request_to_peer_input() {
474        let interaction = InboxInteraction {
475            objective_id: None,
476            sender_taint: None,
477            from_route: None,
478            from: "peer-1".into(),
479            content: InteractionContent::Request {
480                intent: "mob.peer_added".into(),
481                // K15: lifecycle-classed requests carry the typed peer
482                // subject; a missing subject is rejected at ingress.
483                params: serde_json::json!({"peer": "agent-1"}),
484                blocks: None,
485            },
486            id: make_interaction_id(),
487            rendered_text: String::new(),
488            handling_mode: meerkat_core::types::HandlingMode::Queue,
489            render_metadata: None,
490        };
491        let input = peer_input_for_test(&interaction, &LogicalRuntimeId::new("test"));
492        if let Input::Peer(p) = &input {
493            assert!(matches!(p.convention, Some(PeerConvention::Request { .. })));
494            match p.convention.as_ref() {
495                Some(PeerConvention::Request { request_id, .. }) => {
496                    assert_eq!(request_id, &interaction.id.0.to_string());
497                }
498                other => panic!("Expected request convention, got {other:?}"),
499            }
500            assert_eq!(p.header.durability, InputDurability::Durable);
501            assert_eq!(
502                p.payload,
503                Some(serde_json::json!({"peer": "agent-1"})),
504                "request params must remain structured on PeerInput so runtime prompt projection does not depend on pre-rendered comms prose"
505            );
506            assert_eq!(
507                p.handling_mode,
508                Some(meerkat_core::types::HandlingMode::Queue),
509                "explicit queue request semantics must not collapse to default policy"
510            );
511        } else {
512            panic!("Expected PeerInput");
513        }
514    }
515
516    #[test]
517    fn classified_request_uses_canonical_peer_id_for_runtime_projection() {
518        let source_peer_id =
519            PeerId::parse("11111111-1111-4111-8111-111111111111").expect("canonical peer id");
520        let request_id = make_interaction_id();
521        let classified = PeerInputCandidate {
522            interaction: InboxInteraction {
523                objective_id: None,
524                sender_taint: None,
525                from_route: None,
526                from: "test-mob/lead/l-requester".into(),
527                content: InteractionContent::Request {
528                    intent: "interpret_image".into(),
529                    params: serde_json::json!({"description": "tower with a light"}),
530                    blocks: None,
531                },
532                id: request_id,
533                rendered_text: "stale helper prose".into(),
534                handling_mode: meerkat_core::types::HandlingMode::Steer,
535                render_metadata: None,
536            },
537            ingress: PeerIngressFact::peer(
538                request_id,
539                PeerInputClass::ActionableRequest,
540                meerkat_core::PeerIngressKind::Request,
541                Some(meerkat_core::PeerIngressAuthDecision::Required),
542                PeerIngressIdentity::new(
543                    source_peer_id,
544                    "test-mob/lead/l-requester",
545                    PeerIngressConvention::Request {
546                        request_id: request_id.to_string(),
547                        intent: "interpret_image".to_string(),
548                    },
549                ),
550            ),
551            lifecycle_peer: None,
552            response_terminality: None,
553        };
554
555        let input =
556            classified_interaction_to_runtime_input(&classified, &LogicalRuntimeId::new("worker"))
557                .expect("classified request should project to peer input");
558        let Input::Peer(peer) = &input else {
559            panic!("Expected PeerInput");
560        };
561        let InputOrigin::Peer { peer_id, .. } = &peer.header.source else {
562            panic!("Expected peer source");
563        };
564        assert_eq!(peer_id, "11111111-1111-4111-8111-111111111111");
565        assert_eq!(peer.content.text_content(), "stale helper prose");
566
567        let prompt = crate::input::input_prompt_text(&input);
568        assert!(prompt.starts_with(
569            "Peer request from peer_id 11111111-1111-4111-8111-111111111111 (display_name: test-mob/lead/l-requester)."
570        ));
571        assert!(prompt.contains("\"peer_id\":\"11111111-1111-4111-8111-111111111111\""));
572        assert!(prompt.contains("\"display_name\":\"test-mob/lead/l-requester\""));
573        assert!(prompt.contains(&format!("\"in_reply_to\":\"{}\"", request_id.0)));
574        assert!(prompt.contains("\"status\":\"completed\""));
575        assert!(!prompt.contains("to=\""));
576    }
577
578    #[test]
579    fn plain_event_to_external_event_input() {
580        let id = make_interaction_id();
581        let classified = PeerInputCandidate {
582            lifecycle_peer: None,
583            response_terminality: None,
584            ingress: plain_event_ingress(id, "webhook"),
585            interaction: InboxInteraction {
586                objective_id: None,
587                sender_taint: None,
588                from_route: None,
589                from: "event:webhook".into(),
590                content: InteractionContent::Message {
591                    body: "{\"ok\":true}".into(),
592                    blocks: None,
593                },
594                id,
595                rendered_text: String::new(),
596                handling_mode: meerkat_core::types::HandlingMode::Queue,
597                render_metadata: None,
598            },
599        };
600        let input =
601            classified_interaction_to_runtime_input(&classified, &LogicalRuntimeId::new("test"))
602                .expect("plain event should project to external event input");
603        match input {
604            Input::ExternalEvent(event) => {
605                assert_eq!(event.event_type, "webhook");
606                assert_eq!(event.payload["body"], "{\"ok\":true}");
607                assert_eq!(event.blocks, None);
608                assert_eq!(
609                    event.handling_mode,
610                    meerkat_core::types::HandlingMode::Queue
611                );
612                assert_eq!(event.render_metadata, None);
613            }
614            other => panic!("Expected ExternalEvent input, got {other:?}"),
615        }
616    }
617
618    #[test]
619    fn peer_named_event_prefix_stays_peer_without_plain_event_class() {
620        let id = make_interaction_id();
621        let classified = PeerInputCandidate {
622            lifecycle_peer: None,
623            response_terminality: None,
624            ingress: peer_ingress(
625                id,
626                test_peer_id(),
627                "event:webhook",
628                PeerInputClass::ActionableMessage,
629                PeerIngressConvention::Message,
630            ),
631            interaction: InboxInteraction {
632                objective_id: None,
633                sender_taint: None,
634                from_route: None,
635                from: "event:webhook".into(),
636                content: InteractionContent::Message {
637                    body: "hello".into(),
638                    blocks: None,
639                },
640                id,
641                rendered_text: "stale rendered text".into(),
642                handling_mode: meerkat_core::types::HandlingMode::Queue,
643                render_metadata: None,
644            },
645        };
646        let input =
647            classified_interaction_to_runtime_input(&classified, &LogicalRuntimeId::new("test"))
648                .expect("classified peer event should project to peer input");
649        match input {
650            Input::Peer(peer) => {
651                assert_eq!(peer.content.text_content(), "stale rendered text");
652                match peer.header.source {
653                    InputOrigin::Peer { peer_id, .. } => {
654                        assert_eq!(peer_id, test_peer_id().as_str());
655                    }
656                    other => panic!("Expected peer source, got {other:?}"),
657                }
658            }
659            other => panic!("Expected Peer input, got {other:?}"),
660        }
661    }
662
663    #[test]
664    fn classified_peer_projection_uses_ingress_canonical_peer_id_not_display_from() {
665        let id = make_interaction_id();
666        let canonical_peer_id = meerkat_core::comms::PeerId::new();
667        let classified = PeerInputCandidate {
668            lifecycle_peer: None,
669            response_terminality: None,
670            ingress: PeerIngressFact::peer(
671                id,
672                PeerInputClass::ActionableRequest,
673                meerkat_core::PeerIngressKind::Request,
674                Some(meerkat_core::PeerIngressAuthDecision::Required),
675                PeerIngressIdentity::new(
676                    canonical_peer_id,
677                    "display-agent",
678                    PeerIngressConvention::Request {
679                        request_id: id.to_string(),
680                        intent: "review".to_string(),
681                    },
682                ),
683            ),
684            interaction: InboxInteraction {
685                objective_id: None,
686                sender_taint: None,
687                from_route: None,
688                from: "display-agent".into(),
689                content: InteractionContent::Request {
690                    intent: "review".into(),
691                    params: serde_json::json!({"pr": 42}),
692                    blocks: None,
693                },
694                id,
695                rendered_text: "stale rendered text".into(),
696                handling_mode: meerkat_core::types::HandlingMode::Queue,
697                render_metadata: None,
698            },
699        };
700
701        let input =
702            classified_interaction_to_runtime_input(&classified, &LogicalRuntimeId::new("test"))
703                .expect("classified peer projection should use typed canonical id");
704        let Input::Peer(peer) = input else {
705            panic!("Expected Peer input");
706        };
707        match peer.header.source {
708            InputOrigin::Peer { peer_id, .. } => {
709                assert_eq!(peer_id, canonical_peer_id.as_str());
710                assert_ne!(peer_id, "display-agent");
711            }
712            other => panic!("Expected peer source, got {other:?}"),
713        }
714        assert_eq!(peer.content.text_content(), "stale rendered text");
715    }
716
717    #[test]
718    fn classified_peer_projection_rejects_display_only_ingress_identity() {
719        let id = make_interaction_id();
720        let classified = PeerInputCandidate {
721            lifecycle_peer: None,
722            response_terminality: None,
723            ingress: PeerIngressFact {
724                interaction_id: id,
725                class: PeerInputClass::ActionableMessage,
726                kind: meerkat_core::PeerIngressKind::Message,
727                canonical_peer_id: None,
728                display_name: meerkat_core::comms::PeerName::new("display-agent".to_string()).ok(),
729                signing_pubkey: None,
730                route: None,
731                declared_reply_endpoint: None,
732                auth: Some(meerkat_core::PeerIngressAuthDecision::Required),
733                convention: PeerIngressConvention::Message,
734            },
735            interaction: InboxInteraction {
736                objective_id: None,
737                sender_taint: None,
738                from_route: None,
739                from: "display-agent".into(),
740                content: InteractionContent::Message {
741                    body: "hello".into(),
742                    blocks: None,
743                },
744                id,
745                rendered_text: "stale rendered text".into(),
746                handling_mode: meerkat_core::types::HandlingMode::Queue,
747                render_metadata: None,
748            },
749        };
750
751        let result =
752            classified_interaction_to_runtime_input(&classified, &LogicalRuntimeId::new("test"));
753        assert!(
754            matches!(
755                result,
756                Err(PeerIngressProjectionError::MissingCanonicalPeerId { interaction_id })
757                    if interaction_id == id
758            ),
759            "display-only ingress must fail closed, got {result:?}"
760        );
761    }
762
763    #[test]
764    fn request_body_preserves_rendered_text_and_structured_payload() {
765        let interaction = InboxInteraction {
766            objective_id: None,
767            sender_taint: None,
768            from_route: None,
769            from: "event:webhook".into(),
770            content: InteractionContent::Request {
771                intent: "mob.peer_added".into(),
772                params: serde_json::json!({"peer":"agent-1"}),
773                blocks: None,
774            },
775            id: make_interaction_id(),
776            rendered_text: "stale rendered text".into(),
777            handling_mode: meerkat_core::types::HandlingMode::Queue,
778            render_metadata: None,
779        };
780        let input = peer_input_for_test(&interaction, &LogicalRuntimeId::new("test"));
781        if let Input::Peer(peer) = input {
782            assert_eq!(peer.content.text_content(), "stale rendered text");
783            assert_eq!(peer.payload, Some(serde_json::json!({"peer":"agent-1"})));
784        } else {
785            panic!("Expected PeerInput");
786        }
787    }
788
789    #[test]
790    fn message_blocks_are_preserved_on_peer_input() {
791        let blocks = vec![
792            meerkat_core::types::ContentBlock::Text {
793                text: "see image".into(),
794            },
795            meerkat_core::types::ContentBlock::Image {
796                media_type: "image/png".into(),
797                data: "abc".into(),
798            },
799        ];
800        let interaction = InboxInteraction {
801            objective_id: None,
802            sender_taint: None,
803            from_route: None,
804            from: "peer-1".into(),
805            content: InteractionContent::Message {
806                body: "see image".into(),
807                blocks: Some(blocks.clone()),
808            },
809            id: make_interaction_id(),
810            rendered_text: "stale rendered text".into(),
811            handling_mode: meerkat_core::types::HandlingMode::Queue,
812            render_metadata: None,
813        };
814        let input = peer_input_for_test(&interaction, &LogicalRuntimeId::new("test"));
815        if let Input::Peer(peer) = input {
816            // Single-owner semantics: when typed blocks are present they ARE
817            // the content; the stale rendered text is not stored beside them.
818            assert_eq!(
819                peer.content,
820                meerkat_core::types::ContentInput::Blocks(blocks)
821            );
822        } else {
823            panic!("Expected PeerInput");
824        }
825    }
826
827    #[test]
828    fn request_blocks_are_preserved_on_peer_input() {
829        let blocks = vec![
830            meerkat_core::types::ContentBlock::Text {
831                text: "describe this image".into(),
832            },
833            meerkat_core::types::ContentBlock::Image {
834                media_type: "image/png".into(),
835                data: "abc".into(),
836            },
837        ];
838        let interaction_id = make_interaction_id();
839        let peer_id = PeerId::new();
840        let classified = PeerInputCandidate {
841            interaction: InboxInteraction {
842                objective_id: None,
843                sender_taint: None,
844                from_route: Some(peer_id),
845                from: "vision-peer".into(),
846                content: InteractionContent::Request {
847                    intent: "checksum_token".into(),
848                    params: serde_json::json!({"subject": "describe-image"}),
849                    blocks: Some(blocks.clone()),
850                },
851                id: interaction_id,
852                rendered_text: String::new(),
853                handling_mode: meerkat_core::types::HandlingMode::Steer,
854                render_metadata: None,
855            },
856            ingress: PeerIngressFact::peer(
857                interaction_id,
858                PeerInputClass::ActionableRequest,
859                PeerIngressKind::Request,
860                Some(meerkat_core::interaction::PeerIngressAuthDecision::Required),
861                PeerIngressIdentity::new(
862                    peer_id,
863                    "vision-peer",
864                    meerkat_core::interaction::PeerIngressConvention::Request {
865                        request_id: interaction_id.to_string(),
866                        intent: "checksum_token".to_string(),
867                    },
868                ),
869            ),
870            lifecycle_peer: None,
871            response_terminality: None,
872        };
873
874        let input = classified_interaction_to_runtime_input(
875            &classified,
876            &LogicalRuntimeId::new("runtime-a"),
877        )
878        .expect("classified request should project");
879        if let Input::Peer(peer) = input {
880            assert_eq!(
881                peer.content,
882                meerkat_core::types::ContentInput::Blocks(blocks)
883            );
884            assert_eq!(
885                peer.payload,
886                Some(serde_json::json!({"subject": "describe-image"}))
887            );
888        } else {
889            panic!("Expected PeerInput");
890        }
891    }
892
893    #[test]
894    fn multimodal_message_blocks_own_content_with_derived_text_projection() {
895        let blocks = vec![
896            meerkat_core::types::ContentBlock::Text {
897                text: "caption text".into(),
898            },
899            meerkat_core::types::ContentBlock::Image {
900                media_type: "image/png".into(),
901                data: "abc".into(),
902            },
903        ];
904        let interaction = InboxInteraction {
905            objective_id: None,
906            sender_taint: None,
907            from_route: None,
908            from: "peer-1".into(),
909            content: InteractionContent::Message {
910                body: "please inspect this image".into(),
911                blocks: Some(blocks.clone()),
912            },
913            id: make_interaction_id(),
914            rendered_text: "stale rendered text".into(),
915            handling_mode: meerkat_core::types::HandlingMode::Queue,
916            render_metadata: None,
917        };
918        let input = peer_input_for_test(&interaction, &LogicalRuntimeId::new("test"));
919        if let Input::Peer(peer) = input {
920            // Single-owner semantics: typed blocks ARE the content; the text
921            // projection is derived from them at read time, never stored.
922            assert_eq!(
923                peer.content,
924                meerkat_core::types::ContentInput::Blocks(blocks)
925            );
926            assert_eq!(
927                peer.content.text_content(),
928                "caption text\n[image: image/png]"
929            );
930        } else {
931            panic!("Expected PeerInput");
932        }
933    }
934
935    #[test]
936    fn plain_event_blocks_are_preserved_on_external_event_input() {
937        let blocks = vec![
938            meerkat_core::types::ContentBlock::Text {
939                text: "see image".into(),
940            },
941            meerkat_core::types::ContentBlock::Image {
942                media_type: "image/png".into(),
943                data: "abc".into(),
944            },
945        ];
946        let id = make_interaction_id();
947        let classified = PeerInputCandidate {
948            lifecycle_peer: None,
949            response_terminality: None,
950            ingress: plain_event_ingress(id, "webhook"),
951            interaction: InboxInteraction {
952                objective_id: None,
953                sender_taint: None,
954                from_route: None,
955                from: "event:webhook".into(),
956                content: InteractionContent::Message {
957                    body: "see image".into(),
958                    blocks: Some(blocks.clone()),
959                },
960                id,
961                rendered_text: "stale rendered text".into(),
962                handling_mode: meerkat_core::types::HandlingMode::Queue,
963                render_metadata: None,
964            },
965        };
966        let input =
967            classified_interaction_to_runtime_input(&classified, &LogicalRuntimeId::new("test"))
968                .expect("plain event with blocks should project");
969        match input {
970            Input::ExternalEvent(event) => {
971                assert_eq!(event.payload["body"], "see image");
972                assert!(event.payload.get("blocks").is_none());
973                assert_eq!(event.blocks, Some(blocks));
974                assert_eq!(
975                    event.handling_mode,
976                    meerkat_core::types::HandlingMode::Queue
977                );
978                assert_eq!(event.render_metadata, None);
979            }
980            other => panic!("Expected ExternalEvent input, got {other:?}"),
981        }
982    }
983
984    #[test]
985    fn plain_event_preserves_handling_mode_and_render_metadata() {
986        let render_metadata = meerkat_core::types::RenderMetadata {
987            class: meerkat_core::types::RenderClass::ExternalEvent,
988            salience: meerkat_core::types::RenderSalience::Urgent,
989        };
990        let id = make_interaction_id();
991        let classified = PeerInputCandidate {
992            lifecycle_peer: None,
993            response_terminality: None,
994            ingress: plain_event_ingress(id, "webhook"),
995            interaction: InboxInteraction {
996                objective_id: None,
997                sender_taint: None,
998                from_route: None,
999                from: "event:webhook".into(),
1000                content: InteractionContent::Message {
1001                    body: "urgent".into(),
1002                    blocks: None,
1003                },
1004                id,
1005                rendered_text: "stale rendered text".into(),
1006                handling_mode: meerkat_core::types::HandlingMode::Steer,
1007                render_metadata: Some(render_metadata.clone()),
1008            },
1009        };
1010
1011        match classified_interaction_to_runtime_input(&classified, &LogicalRuntimeId::new("test"))
1012            .expect("plain event should preserve render metadata")
1013        {
1014            Input::ExternalEvent(event) => {
1015                assert_eq!(
1016                    event.handling_mode,
1017                    meerkat_core::types::HandlingMode::Steer
1018                );
1019                assert_eq!(event.render_metadata, Some(render_metadata));
1020            }
1021            other => panic!("Expected ExternalEvent input, got {other:?}"),
1022        }
1023    }
1024
1025    #[test]
1026    fn response_completed_to_terminal() {
1027        let in_reply_to = make_interaction_id();
1028        let route_id = meerkat_core::comms::PeerId::from_uuid(
1029            uuid::Uuid::parse_str("018f6f79-7a82-7c4e-a552-a3b86f9630f2").unwrap(),
1030        );
1031        let interaction = InboxInteraction {
1032            objective_id: None,
1033            sender_taint: None,
1034            from_route: Some(route_id),
1035            from: "Peer One".into(),
1036            content: InteractionContent::Response {
1037                status: ResponseStatus::Completed,
1038                result: serde_json::json!({"ok": true}),
1039                in_reply_to,
1040                blocks: None,
1041            },
1042            id: make_interaction_id(),
1043            rendered_text: String::new(),
1044            handling_mode: meerkat_core::types::HandlingMode::Queue,
1045            render_metadata: None,
1046        };
1047        let input = peer_input_for_test(&interaction, &LogicalRuntimeId::new("test"));
1048        if let Input::Peer(p) = &input {
1049            match &p.header.source {
1050                InputOrigin::Peer {
1051                    peer_id,
1052                    display_identity,
1053                    ..
1054                } => {
1055                    assert_eq!(peer_id, &route_id.to_string());
1056                    assert_eq!(display_identity.as_deref(), Some("Peer One"));
1057                }
1058                other => panic!("Expected Peer source, got {other:?}"),
1059            }
1060            assert!(matches!(
1061                p.convention,
1062                Some(PeerConvention::ResponseTerminal {
1063                    status: ResponseTerminalStatus::Completed,
1064                    ..
1065                })
1066            ));
1067            assert_eq!(
1068                p.header.correlation_id,
1069                Some(CorrelationId::from_uuid(in_reply_to.0)),
1070                "terminal peer responses must use the request interaction id that InteractionComplete reports",
1071            );
1072            assert_eq!(
1073                p.header.idempotency_key,
1074                Some(peer_response_terminal_idempotency_key(
1075                    route_id,
1076                    meerkat_core::PeerCorrelationId::from_uuid(in_reply_to.0),
1077                )),
1078                "terminal peer responses must carry one stable route/correlation replay key",
1079            );
1080            assert_eq!(p.header.durability, InputDurability::Durable);
1081            assert_eq!(
1082                p.payload,
1083                Some(serde_json::json!({"ok": true})),
1084                "terminal response result must remain structured on PeerInput so runtime prompt projection stays runtime-owned"
1085            );
1086        } else {
1087            panic!("Expected PeerInput");
1088        }
1089        let projection = crate::input::runtime_input_projection_for_machine_batch(&input);
1090        let meerkat_core::lifecycle::run_primitive::CoreRenderable::SystemNotice { blocks, .. } =
1091            projection.append.expect("durable terminal notice").content
1092        else {
1093            panic!("Expected durable terminal notice");
1094        };
1095        assert!(matches!(
1096            blocks.first(),
1097            Some(meerkat_core::types::SystemNoticeBlock::Comms { peer, .. })
1098                if peer.as_ref().and_then(|peer| peer.display_name.as_deref()) == Some("Peer One")
1099        ));
1100    }
1101
1102    #[test]
1103    fn classified_response_uses_ingress_terminal_class() {
1104        let in_reply_to = make_interaction_id();
1105        let id = make_interaction_id();
1106        let classified = PeerInputCandidate {
1107            interaction: InboxInteraction {
1108                objective_id: None,
1109                sender_taint: None,
1110                from_route: None,
1111                from: "peer-1".into(),
1112                content: InteractionContent::Response {
1113                    status: ResponseStatus::Completed,
1114                    result: serde_json::json!({"ok": true}),
1115                    in_reply_to,
1116                    blocks: None,
1117                },
1118                id,
1119                rendered_text: String::new(),
1120                handling_mode: meerkat_core::types::HandlingMode::Queue,
1121                render_metadata: None,
1122            },
1123            ingress: PeerIngressFact::peer(
1124                id,
1125                PeerInputClass::ResponseProgress,
1126                meerkat_core::PeerIngressKind::Response,
1127                Some(meerkat_core::PeerIngressAuthDecision::Required),
1128                PeerIngressIdentity::new(
1129                    test_peer_id(),
1130                    "peer-1",
1131                    PeerIngressConvention::Response {
1132                        in_reply_to,
1133                        status: ResponseStatus::Completed,
1134                    },
1135                ),
1136            ),
1137            lifecycle_peer: None,
1138            response_terminality: Some(meerkat_core::TerminalityClass::Progress),
1139        };
1140
1141        let input =
1142            classified_interaction_to_runtime_input(&classified, &LogicalRuntimeId::new("test"))
1143                .expect("classified response should project");
1144        if let Input::Peer(peer) = input {
1145            assert!(
1146                matches!(
1147                    peer.convention,
1148                    Some(PeerConvention::ResponseProgress { .. })
1149                ),
1150                "classified bridge must consume ingress-owned response class"
1151            );
1152        } else {
1153            panic!("Expected PeerInput");
1154        }
1155    }
1156
1157    #[test]
1158    fn classified_response_missing_machine_terminality_fails_closed() {
1159        let in_reply_to = make_interaction_id();
1160        let id = make_interaction_id();
1161        let classified = PeerInputCandidate {
1162            interaction: InboxInteraction {
1163                objective_id: None,
1164                sender_taint: None,
1165                from_route: None,
1166                from: "peer-1".into(),
1167                content: InteractionContent::Response {
1168                    status: ResponseStatus::Completed,
1169                    result: serde_json::json!({"ok": true}),
1170                    in_reply_to,
1171                    blocks: None,
1172                },
1173                id,
1174                rendered_text: String::new(),
1175                handling_mode: meerkat_core::types::HandlingMode::Queue,
1176                render_metadata: None,
1177            },
1178            ingress: PeerIngressFact::peer(
1179                id,
1180                PeerInputClass::ResponseTerminal,
1181                meerkat_core::PeerIngressKind::Response,
1182                Some(meerkat_core::PeerIngressAuthDecision::Required),
1183                PeerIngressIdentity::new(
1184                    test_peer_id(),
1185                    "peer-1",
1186                    PeerIngressConvention::Response {
1187                        in_reply_to,
1188                        status: ResponseStatus::Completed,
1189                    },
1190                ),
1191            ),
1192            lifecycle_peer: None,
1193            response_terminality: None,
1194        };
1195
1196        let result =
1197            classified_interaction_to_runtime_input(&classified, &LogicalRuntimeId::new("test"));
1198        assert!(
1199            matches!(
1200                result,
1201                Err(PeerIngressProjectionError::MissingResponseTerminality { interaction_id })
1202                    if interaction_id == id
1203            ),
1204            "runtime projection must not infer public terminality from raw status: {result:?}"
1205        );
1206    }
1207
1208    #[test]
1209    fn response_terminal_without_canonical_peer_id_fails_typed_projection() {
1210        let in_reply_to = make_interaction_id();
1211        let interaction_id = make_interaction_id();
1212        let candidate = PeerInputCandidate {
1213            interaction: InboxInteraction {
1214                objective_id: None,
1215                sender_taint: None,
1216                from_route: None,
1217                from: "Peer One".into(),
1218                content: InteractionContent::Response {
1219                    status: ResponseStatus::Completed,
1220                    result: serde_json::json!({"ok": true}),
1221                    in_reply_to,
1222                    blocks: None,
1223                },
1224                id: interaction_id,
1225                rendered_text: String::new(),
1226                handling_mode: meerkat_core::types::HandlingMode::Queue,
1227                render_metadata: None,
1228            },
1229            ingress: PeerIngressFact {
1230                interaction_id,
1231                class: PeerInputClass::ResponseTerminal,
1232                kind: meerkat_core::PeerIngressKind::Response,
1233                canonical_peer_id: None,
1234                display_name: meerkat_core::comms::PeerName::new("Peer One".to_string()).ok(),
1235                signing_pubkey: None,
1236                route: None,
1237                declared_reply_endpoint: None,
1238                auth: Some(meerkat_core::PeerIngressAuthDecision::Required),
1239                convention: PeerIngressConvention::Response {
1240                    in_reply_to,
1241                    status: ResponseStatus::Completed,
1242                },
1243            },
1244            lifecycle_peer: None,
1245            response_terminality: Some(meerkat_core::TerminalityClass::Terminal {
1246                disposition: meerkat_core::TerminalDisposition::Completed,
1247            }),
1248        };
1249        let err =
1250            classified_interaction_to_runtime_input(&candidate, &LogicalRuntimeId::new("test"))
1251                .unwrap_err();
1252        assert!(matches!(
1253            err,
1254            PeerIngressProjectionError::MissingCanonicalPeerId { .. }
1255        ));
1256    }
1257
1258    #[test]
1259    fn response_failed_to_terminal() {
1260        let in_reply_to = make_interaction_id();
1261        let route_id = meerkat_core::comms::PeerId::from_uuid(
1262            uuid::Uuid::parse_str("018f6f79-7a82-7c4e-a552-a3b86f9630f3").unwrap(),
1263        );
1264        let interaction = InboxInteraction {
1265            objective_id: None,
1266            sender_taint: None,
1267            from_route: Some(route_id),
1268            from: "peer-1".into(),
1269            content: InteractionContent::Response {
1270                status: ResponseStatus::Failed,
1271                result: serde_json::json!({"error": "timeout"}),
1272                in_reply_to,
1273                blocks: None,
1274            },
1275            id: make_interaction_id(),
1276            rendered_text: String::new(),
1277            handling_mode: meerkat_core::types::HandlingMode::Queue,
1278            render_metadata: None,
1279        };
1280        let input = peer_input_for_test(&interaction, &LogicalRuntimeId::new("test"));
1281        if let Input::Peer(p) = &input {
1282            assert!(matches!(
1283                p.convention,
1284                Some(PeerConvention::ResponseTerminal {
1285                    status: ResponseTerminalStatus::Failed,
1286                    ..
1287                })
1288            ));
1289        } else {
1290            panic!("Expected PeerInput");
1291        }
1292    }
1293
1294    #[test]
1295    fn response_accepted_to_progress() {
1296        let in_reply_to = make_interaction_id();
1297        let interaction = InboxInteraction {
1298            objective_id: None,
1299            sender_taint: None,
1300            from_route: None,
1301            from: "peer-1".into(),
1302            content: InteractionContent::Response {
1303                status: ResponseStatus::Accepted,
1304                result: serde_json::json!(null),
1305                in_reply_to,
1306                blocks: None,
1307            },
1308            id: make_interaction_id(),
1309            rendered_text: String::new(),
1310            handling_mode: meerkat_core::types::HandlingMode::Queue,
1311            render_metadata: None,
1312        };
1313        let input = peer_input_for_test(&interaction, &LogicalRuntimeId::new("test"));
1314        if let Input::Peer(p) = &input {
1315            assert!(matches!(
1316                p.convention,
1317                Some(PeerConvention::ResponseProgress {
1318                    phase: ResponseProgressPhase::Accepted,
1319                    ..
1320                })
1321            ));
1322            assert_eq!(
1323                p.header.correlation_id,
1324                Some(CorrelationId::from_uuid(in_reply_to.0)),
1325                "progress peer responses must share the same request correlation as terminal response completion",
1326            );
1327            assert_eq!(p.header.durability, InputDurability::Ephemeral);
1328            assert!(
1329                p.handling_mode.is_none(),
1330                "ResponseProgress inputs must not carry handling_mode"
1331            );
1332        } else {
1333            panic!("Expected PeerInput");
1334        }
1335    }
1336
1337    #[test]
1338    fn classified_response_uses_ingress_terminality_over_raw_status() {
1339        let in_reply_to = make_interaction_id();
1340        let id = make_interaction_id();
1341        let classified = PeerInputCandidate {
1342            interaction: InboxInteraction {
1343                objective_id: None,
1344                sender_taint: None,
1345                from_route: None,
1346                from: "peer-1".into(),
1347                content: InteractionContent::Response {
1348                    status: ResponseStatus::Completed,
1349                    result: serde_json::json!({"ok": true}),
1350                    in_reply_to,
1351                    blocks: None,
1352                },
1353                id,
1354                rendered_text: String::new(),
1355                handling_mode: meerkat_core::types::HandlingMode::Queue,
1356                render_metadata: None,
1357            },
1358            ingress: PeerIngressFact::peer(
1359                id,
1360                PeerInputClass::ResponseProgress,
1361                meerkat_core::PeerIngressKind::Response,
1362                Some(meerkat_core::PeerIngressAuthDecision::Required),
1363                PeerIngressIdentity::new(
1364                    test_peer_id(),
1365                    "peer-1",
1366                    PeerIngressConvention::Response {
1367                        in_reply_to,
1368                        status: ResponseStatus::Completed,
1369                    },
1370                ),
1371            ),
1372            lifecycle_peer: None,
1373            response_terminality: Some(meerkat_core::TerminalityClass::Progress),
1374        };
1375
1376        let input =
1377            classified_interaction_to_runtime_input(&classified, &LogicalRuntimeId::new("test"))
1378                .expect("classified response should project");
1379
1380        if let Input::Peer(p) = &input {
1381            assert!(matches!(
1382                p.convention,
1383                Some(PeerConvention::ResponseProgress {
1384                    phase: ResponseProgressPhase::Accepted,
1385                    ..
1386                })
1387            ));
1388            assert_eq!(p.header.durability, InputDurability::Ephemeral);
1389            assert_eq!(p.handling_mode, None);
1390        } else {
1391            panic!("Expected PeerInput");
1392        }
1393    }
1394
1395    #[test]
1396    fn peer_source_includes_runtime_id() {
1397        let interaction = InboxInteraction {
1398            objective_id: None,
1399            sender_taint: None,
1400            from_route: None,
1401            from: "peer-1".into(),
1402            content: InteractionContent::Message {
1403                body: "hi".into(),
1404                blocks: None,
1405            },
1406            id: make_interaction_id(),
1407            rendered_text: String::new(),
1408            handling_mode: meerkat_core::types::HandlingMode::Queue,
1409            render_metadata: None,
1410        };
1411        let input = peer_input_for_test(&interaction, &LogicalRuntimeId::new("agent-runtime-1"));
1412        if let Input::Peer(p) = &input {
1413            if let InputOrigin::Peer {
1414                peer_id,
1415                display_identity,
1416                runtime_id,
1417                ..
1418            } = &p.header.source
1419            {
1420                assert_eq!(peer_id, &test_peer_id().as_str());
1421                assert_eq!(display_identity.as_deref(), Some("peer-1"));
1422                assert_eq!(runtime_id.as_ref().unwrap().0, "agent-runtime-1");
1423            } else {
1424                panic!("Expected Peer source");
1425            }
1426        } else {
1427            panic!("Expected PeerInput");
1428        }
1429    }
1430
1431    #[test]
1432    fn all_interaction_types_produce_valid_inputs() {
1433        let in_reply_to = make_interaction_id();
1434        let interactions = vec![
1435            InboxInteraction {
1436                objective_id: None,
1437                sender_taint: None,
1438                from_route: None,
1439                from: "p".into(),
1440                content: InteractionContent::Message {
1441                    body: "m".into(),
1442                    blocks: None,
1443                },
1444                id: make_interaction_id(),
1445                rendered_text: String::new(),
1446                handling_mode: meerkat_core::types::HandlingMode::Queue,
1447                render_metadata: None,
1448            },
1449            InboxInteraction {
1450                objective_id: None,
1451                sender_taint: None,
1452                from_route: None,
1453                from: "p".into(),
1454                content: InteractionContent::Request {
1455                    intent: "i".into(),
1456                    params: serde_json::json!({}),
1457                    blocks: None,
1458                },
1459                id: make_interaction_id(),
1460                rendered_text: String::new(),
1461                handling_mode: meerkat_core::types::HandlingMode::Queue,
1462                render_metadata: None,
1463            },
1464            InboxInteraction {
1465                objective_id: None,
1466                sender_taint: None,
1467                from_route: Some(meerkat_core::comms::PeerId::from_uuid(
1468                    uuid::Uuid::parse_str("018f6f79-7a82-7c4e-a552-a3b86f9630f6").unwrap(),
1469                )),
1470                from: "p".into(),
1471                content: InteractionContent::Response {
1472                    status: ResponseStatus::Completed,
1473                    result: serde_json::json!(null),
1474                    in_reply_to,
1475                    blocks: None,
1476                },
1477                id: make_interaction_id(),
1478                rendered_text: String::new(),
1479                handling_mode: meerkat_core::types::HandlingMode::Queue,
1480                render_metadata: None,
1481            },
1482        ];
1483
1484        let rid = LogicalRuntimeId::new("test");
1485        for interaction in &interactions {
1486            let input = peer_input_for_test(interaction, &rid);
1487            assert!(matches!(input, Input::Peer(_)));
1488        }
1489    }
1490}