1use chrono::{DateTime, Utc};
8use meerkat_core::lifecycle::InputId;
9use meerkat_core::lifecycle::run_primitive::{
10 ConversationAppend, ConversationAppendRole, ConversationContextAppend, CoreRenderable,
11 RuntimeTurnMetadata,
12};
13use meerkat_core::ops::{OpEvent, OperationId};
14use meerkat_core::service::TurnToolOverlay;
15use meerkat_core::types::{
16 ContentInput, HandlingMode, SystemNoticeBlock, SystemNoticeDirection, SystemNoticeKind,
17 SystemNoticePeer,
18};
19use meerkat_core::{
20 BlobStore, BlobStoreError, MissingBlobBehavior, PeerConversationProjection,
21 PeerResponseProgressProjectionPhase, PeerResponseTerminalCorrelationId,
22 PeerResponseTerminalDisplayIdentity, PeerResponseTerminalFact, PeerResponseTerminalFactError,
23 PeerResponseTerminalProjectionStatus, PeerResponseTerminalRenderPayload,
24 PeerResponseTerminalRouteIdentity, PeerResponseTerminalSource,
25 PeerResponseTerminalTransportIdentity, externalize_content_blocks, hydrate_content_blocks,
26};
27use serde::{Deserialize, Serialize};
28
29use crate::identifiers::{
30 CorrelationId, IdempotencyKey, InputKind, KindId, LogicalRuntimeId, SupersessionKey,
31};
32use meerkat_core::types::RenderMetadata;
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct InputHeader {
37 pub id: InputId,
39 pub timestamp: DateTime<Utc>,
41 pub source: InputOrigin,
43 pub durability: InputDurability,
45 pub visibility: InputVisibility,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub idempotency_key: Option<IdempotencyKey>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub supersession_key: Option<SupersessionKey>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub correlation_id: Option<CorrelationId>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(tag = "type", rename_all = "snake_case")]
61#[non_exhaustive]
62pub enum InputOrigin {
63 Operator,
65 Peer {
67 peer_id: String,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
74 display_identity: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 runtime_id: Option<LogicalRuntimeId>,
77 },
78 Flow { flow_id: String, step_index: usize },
80 System,
82 External { source_name: String },
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89#[non_exhaustive]
90pub enum InputDurability {
91 Durable,
93 Ephemeral,
95 Derived,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101pub struct InputVisibility {
102 pub transcript_eligible: bool,
104 pub operator_eligible: bool,
106}
107
108impl Default for InputVisibility {
109 fn default() -> Self {
110 Self {
111 transcript_eligible: true,
112 operator_eligible: true,
113 }
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(tag = "input_type", rename_all = "snake_case")]
120#[non_exhaustive]
121pub enum Input {
122 Prompt(PromptInput),
124 Peer(PeerInput),
126 FlowStep(FlowStepInput),
128 ExternalEvent(ExternalEventInput),
130 Continuation(ContinuationInput),
132 Operation(OperationInput),
134}
135
136impl Input {
137 pub fn header(&self) -> &InputHeader {
139 match self {
140 Input::Prompt(i) => &i.header,
141 Input::Peer(i) => &i.header,
142 Input::FlowStep(i) => &i.header,
143 Input::ExternalEvent(i) => &i.header,
144 Input::Continuation(i) => &i.header,
145 Input::Operation(i) => &i.header,
146 }
147 }
148
149 pub fn id(&self) -> &InputId {
151 &self.header().id
152 }
153
154 pub fn kind(&self) -> InputKind {
156 match self {
157 Input::Prompt(_) => InputKind::Prompt,
158 Input::Peer(p) => match &p.convention {
159 Some(PeerConvention::Message) | None => InputKind::PeerMessage,
160 Some(PeerConvention::Request { .. }) => InputKind::PeerRequest,
161 Some(PeerConvention::ResponseProgress { .. }) => InputKind::PeerResponseProgress,
162 Some(PeerConvention::ResponseTerminal { .. }) => InputKind::PeerResponseTerminal,
163 },
164 Input::FlowStep(_) => InputKind::FlowStep,
165 Input::ExternalEvent(_) => InputKind::ExternalEvent,
166 Input::Continuation(_) => InputKind::Continuation,
167 Input::Operation(_) => InputKind::Operation,
168 }
169 }
170
171 pub fn kind_id(&self) -> KindId {
173 KindId::new(self.kind())
174 }
175
176 pub fn handling_mode(&self) -> Option<HandlingMode> {
178 match self {
179 Input::Prompt(prompt) => prompt.turn_metadata.as_ref()?.handling_mode,
180 Input::FlowStep(flow_step) => flow_step.turn_metadata.as_ref()?.handling_mode,
181 Input::ExternalEvent(event) => Some(event.handling_mode),
182 Input::Continuation(continuation) => Some(continuation.handling_mode),
183 Input::Peer(peer) => peer.handling_mode,
184 Input::Operation(_) => None,
185 }
186 }
187
188 pub fn continuation_kind(&self) -> ContinuationKind {
194 match self {
195 Input::Continuation(continuation) => continuation.continuation_kind,
196 _ => ContinuationKind::Ordinary,
197 }
198 }
199}
200
201fn reject_legacy_payload_blocks(event: &ExternalEventInput) -> Result<(), BlobStoreError> {
206 if event
207 .payload
208 .as_object()
209 .is_some_and(|obj| obj.contains_key("blocks"))
210 {
211 return Err(BlobStoreError::Internal(format!(
212 "external-event payload for event_type `{}` carries the retired payload-level \
213 `blocks` key; multimodal content must use the typed `ExternalEventInput.blocks` owner",
214 event.event_type
215 )));
216 }
217 Ok(())
218}
219
220pub async fn externalize_input_images(
221 blob_store: &dyn BlobStore,
222 input: &mut Input,
223) -> Result<(), BlobStoreError> {
224 match input {
225 Input::Prompt(prompt) => {
226 if let ContentInput::Blocks(blocks) = &mut prompt.content {
227 externalize_content_blocks(blob_store, blocks).await?;
228 }
229 }
230 Input::Peer(peer) => {
231 if let ContentInput::Blocks(blocks) = &mut peer.content {
232 externalize_content_blocks(blob_store, blocks).await?;
233 }
234 }
235 Input::FlowStep(flow_step) => {
236 if let ContentInput::Blocks(blocks) = &mut flow_step.content {
237 externalize_content_blocks(blob_store, blocks).await?;
238 }
239 }
240 Input::ExternalEvent(event) => {
241 reject_legacy_payload_blocks(event)?;
242 if let Some(blocks) = event.blocks.as_mut() {
243 externalize_content_blocks(blob_store, blocks).await?;
244 }
245 }
246 Input::Continuation(_) | Input::Operation(_) => {}
247 }
248 Ok(())
249}
250
251pub async fn hydrate_input_images(
252 blob_store: &dyn BlobStore,
253 input: &mut Input,
254 missing_behavior: MissingBlobBehavior,
255) -> Result<(), BlobStoreError> {
256 match input {
257 Input::Prompt(prompt) => {
258 if let ContentInput::Blocks(blocks) = &mut prompt.content {
259 hydrate_content_blocks(blob_store, blocks, missing_behavior).await?;
260 }
261 }
262 Input::Peer(peer) => {
263 if let ContentInput::Blocks(blocks) = &mut peer.content {
264 hydrate_content_blocks(blob_store, blocks, missing_behavior).await?;
265 }
266 }
267 Input::FlowStep(flow_step) => {
268 if let ContentInput::Blocks(blocks) = &mut flow_step.content {
269 hydrate_content_blocks(blob_store, blocks, missing_behavior).await?;
270 }
271 }
272 Input::ExternalEvent(event) => {
273 reject_legacy_payload_blocks(event)?;
274 if let Some(blocks) = event.blocks.as_mut() {
275 hydrate_content_blocks(blob_store, blocks, missing_behavior).await?;
276 }
277 }
278 Input::Continuation(_) | Input::Operation(_) => {}
279 }
280 Ok(())
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct PromptInput {
286 pub header: InputHeader,
287 pub content: ContentInput,
292 #[serde(default, skip_serializing_if = "Vec::is_empty")]
297 pub typed_turn_appends: Vec<ConversationAppend>,
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
305 pub injected_context: Vec<ContentInput>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub turn_metadata: Option<RuntimeTurnMetadata>,
308}
309
310impl PromptInput {
311 pub fn new(text: impl Into<String>, turn_metadata: Option<RuntimeTurnMetadata>) -> Self {
313 Self {
314 header: InputHeader {
315 id: meerkat_core::lifecycle::InputId::new(),
316 timestamp: chrono::Utc::now(),
317 source: InputOrigin::Operator,
318 durability: InputDurability::Durable,
319 visibility: InputVisibility::default(),
320 idempotency_key: None,
321 supersession_key: None,
322 correlation_id: None,
323 },
324 content: ContentInput::Text(text.into()),
325 typed_turn_appends: Vec::new(),
326 injected_context: Vec::new(),
327 turn_metadata,
328 }
329 }
330
331 pub fn from_content_input(
333 input: ContentInput,
334 turn_metadata: Option<RuntimeTurnMetadata>,
335 ) -> Self {
336 Self {
337 header: InputHeader {
338 id: meerkat_core::lifecycle::InputId::new(),
339 timestamp: chrono::Utc::now(),
340 source: InputOrigin::Operator,
341 durability: InputDurability::Durable,
342 visibility: InputVisibility::default(),
343 idempotency_key: None,
344 supersession_key: None,
345 correlation_id: None,
346 },
347 content: input,
348 typed_turn_appends: Vec::new(),
349 injected_context: Vec::new(),
350 turn_metadata,
351 }
352 }
353
354 pub fn with_injected_context(mut self, injected_context: Vec<ContentInput>) -> Self {
356 self.injected_context = injected_context;
357 self
358 }
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct PeerInput {
364 pub header: InputHeader,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub directed_interaction_id: Option<meerkat_core::interaction::InteractionId>,
375 #[serde(skip_serializing_if = "Option::is_none")]
377 pub convention: Option<PeerConvention>,
378 pub content: ContentInput,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub payload: Option<serde_json::Value>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub handling_mode: Option<HandlingMode>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub sender_taint: Option<meerkat_core::comms::SenderContentTaint>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub objective_id: Option<meerkat_core::interaction::ObjectiveId>,
408 #[serde(default, skip_serializing_if = "Vec::is_empty")]
418 pub injected_context: Vec<ContentInput>,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423#[serde(tag = "convention_type", rename_all = "snake_case")]
424#[non_exhaustive]
425pub enum PeerConvention {
426 Message,
428 Request { request_id: String, intent: String },
430 ResponseProgress {
432 request_id: String,
433 phase: ResponseProgressPhase,
434 },
435 ResponseTerminal {
437 request_id: String,
438 status: ResponseTerminalStatus,
439 },
440}
441
442pub type ResponseProgressPhase = PeerResponseProgressProjectionPhase;
445
446pub type ResponseTerminalStatus = PeerResponseTerminalProjectionStatus;
449
450pub fn response_terminal_status_from_wire(
451 status: meerkat_contracts::PeerResponseTerminalStatusWire,
452) -> ResponseTerminalStatus {
453 match status {
454 meerkat_contracts::PeerResponseTerminalStatusWire::Completed => {
455 PeerResponseTerminalProjectionStatus::Completed
456 }
457 meerkat_contracts::PeerResponseTerminalStatusWire::Failed => {
458 PeerResponseTerminalProjectionStatus::Failed
459 }
460 meerkat_contracts::PeerResponseTerminalStatusWire::Cancelled => {
461 PeerResponseTerminalProjectionStatus::Cancelled
462 }
463 }
464}
465
466pub fn peer_response_terminal_input(
467 peer_id: meerkat_core::comms::PeerId,
468 display_name: Option<meerkat_core::comms::PeerName>,
469 request_id: meerkat_core::PeerCorrelationId,
470 status: meerkat_contracts::PeerResponseTerminalStatusWire,
471 result: serde_json::Value,
472) -> Input {
473 let correlation_id = CorrelationId::from_uuid(request_id.as_uuid());
474 let request_id = request_id.to_string();
475 let peer_id = peer_id.to_string();
476 let display_identity = display_name.map_or_else(|| peer_id.clone(), |name| name.as_string());
477
478 Input::Peer(PeerInput {
479 directed_interaction_id: None,
480 objective_id: None,
481 injected_context: Vec::new(),
482 header: InputHeader {
483 id: InputId::new(),
484 timestamp: Utc::now(),
485 source: InputOrigin::Peer {
486 peer_id,
487 display_identity: Some(display_identity),
488 runtime_id: None,
489 },
490 durability: InputDurability::Durable,
491 visibility: InputVisibility::default(),
492 idempotency_key: None,
493 supersession_key: None,
494 correlation_id: Some(correlation_id),
495 },
496 convention: Some(PeerConvention::ResponseTerminal {
497 request_id,
498 status: response_terminal_status_from_wire(status),
499 }),
500 content: ContentInput::Text(String::new()),
501 payload: Some(result),
502 handling_mode: None,
503 sender_taint: None,
506 })
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct FlowStepInput {
512 pub header: InputHeader,
513 pub step_id: String,
515 pub content: ContentInput,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub directed_interaction_id: Option<meerkat_core::interaction::InteractionId>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub turn_metadata: Option<RuntimeTurnMetadata>,
526}
527
528fn validate_directed_interaction_header(
529 header: &InputHeader,
530 interaction_id: meerkat_core::interaction::InteractionId,
531 input_kind: &str,
532) -> Result<(), String> {
533 if header.id.0 != interaction_id.0 {
534 return Err(format!(
535 "directed {input_kind} interaction id does not match input id"
536 ));
537 }
538 if header.correlation_id.as_ref().map(|id| id.0) != Some(interaction_id.0) {
539 return Err(format!(
540 "directed {input_kind} correlation id does not match interaction id"
541 ));
542 }
543 let canonical_id = interaction_id.to_string();
544 if header
545 .idempotency_key
546 .as_ref()
547 .map(ToString::to_string)
548 .as_deref()
549 != Some(canonical_id.as_str())
550 {
551 return Err(format!(
552 "directed {input_kind} idempotency key does not match interaction id"
553 ));
554 }
555 if header.durability != InputDurability::Durable {
556 return Err(format!("directed {input_kind} input must be durable"));
557 }
558 Ok(())
559}
560
561pub(crate) fn validate_directed_flow_step_correlation(input: &Input) -> Result<(), String> {
568 let Input::FlowStep(flow_step) = input else {
569 return Ok(());
570 };
571 let Some(interaction_id) = flow_step.directed_interaction_id else {
572 return Ok(());
573 };
574 let header = &flow_step.header;
575 validate_directed_interaction_header(header, interaction_id, "flow-step")?;
576 match &header.source {
577 InputOrigin::Flow {
578 flow_id,
579 step_index: 0,
580 } if !flow_id.trim().is_empty() => Ok(()),
581 _ => Err(
582 "directed flow-step input must carry a non-empty flow origin with remote step index 0"
583 .to_string(),
584 ),
585 }
586}
587
588pub(crate) fn validated_directed_interaction_id(
593 input: &Input,
594) -> Result<Option<meerkat_core::interaction::InteractionId>, String> {
595 match input {
596 Input::FlowStep(flow_step) => {
597 validate_directed_flow_step_correlation(input)?;
598 Ok(flow_step.directed_interaction_id)
599 }
600 Input::Peer(peer) => {
601 let Some(interaction_id) = peer.directed_interaction_id else {
602 return Ok(None);
603 };
604 validate_directed_interaction_header(&peer.header, interaction_id, "peer input")?;
605 match &peer.header.source {
606 InputOrigin::Peer {
607 peer_id,
608 runtime_id: Some(runtime_id),
609 ..
610 } if !peer_id.trim().is_empty() && !runtime_id.0.trim().is_empty() => {}
611 _ => {
612 return Err(
613 "directed peer input must carry a non-empty peer origin and runtime id"
614 .to_string(),
615 );
616 }
617 }
618 if !matches!(peer.convention, Some(PeerConvention::Message)) {
619 return Err("directed peer input must use the message convention".to_string());
620 }
621 if peer.payload.is_some() {
622 return Err("directed peer input must not carry a structured peer payload".into());
623 }
624 if peer.sender_taint.is_some() {
625 return Err("directed peer input must not carry sender-declared taint".into());
626 }
627 if peer.header.supersession_key.is_some() {
628 return Err("directed peer input must not carry a supersession key".into());
629 }
630 Ok(Some(interaction_id))
631 }
632 _ => Ok(None),
633 }
634}
635
636#[derive(Debug, Clone, Serialize, Deserialize)]
638pub struct ExternalEventInput {
639 pub header: InputHeader,
640 pub event_type: String,
642 pub payload: serde_json::Value,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub blocks: Option<Vec<meerkat_core::types::ContentBlock>>,
650 #[serde(default)]
652 pub handling_mode: HandlingMode,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub render_metadata: Option<RenderMetadata>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub objective_id: Option<meerkat_core::interaction::ObjectiveId>,
658}
659
660#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
670#[serde(rename_all = "snake_case")]
671pub enum ContinuationKind {
672 #[default]
674 Ordinary,
675 WorkgraphAttention,
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize)]
683pub struct ContinuationInput {
684 pub header: InputHeader,
685 pub reason: String,
687 #[serde(default)]
691 pub continuation_kind: ContinuationKind,
692 #[serde(default)]
694 pub handling_mode: HandlingMode,
695 #[serde(default, skip_serializing_if = "Option::is_none")]
697 pub request_id: Option<String>,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub turn_tool_overlay: Option<TurnToolOverlay>,
701 #[serde(default, skip_serializing_if = "Option::is_none")]
703 pub context_append: Option<ConversationContextAppend>,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub turn_append: Option<ConversationAppend>,
707}
708
709impl ContinuationInput {
710 pub fn detached_background_op_completed() -> Self {
716 Self {
717 header: InputHeader {
718 id: meerkat_core::lifecycle::InputId::new(),
719 timestamp: chrono::Utc::now(),
720 source: InputOrigin::System,
721 durability: InputDurability::Derived,
722 visibility: InputVisibility {
723 transcript_eligible: false,
724 operator_eligible: false,
725 },
726 idempotency_key: None,
727 supersession_key: None,
728 correlation_id: None,
729 },
730 reason: "detached_background_op_completed".to_string(),
731 continuation_kind: ContinuationKind::Ordinary,
732 handling_mode: HandlingMode::Steer,
733 request_id: None,
734 turn_tool_overlay: None,
735 context_append: None,
736 turn_append: None,
737 }
738 }
739}
740
741#[derive(Debug, Clone, Serialize, Deserialize)]
744pub struct OperationInput {
745 pub header: InputHeader,
746 pub operation_id: OperationId,
748 pub event: OpEvent,
750}
751
752pub(crate) fn peer_projection_from_peer_input(
759 peer: &PeerInput,
760) -> Option<PeerConversationProjection> {
761 peer_projection_from_peer_input_with_id(peer, peer_canonical_id(peer)?.as_str())
762}
763
764fn peer_projection_from_peer_input_with_id(
765 peer: &PeerInput,
766 peer_id: &str,
767) -> Option<PeerConversationProjection> {
768 let peer_id = peer_id.to_string();
769
770 match &peer.convention {
771 Some(PeerConvention::Message) => Some(PeerConversationProjection::Message { peer_id }),
772 Some(PeerConvention::Request { request_id, intent }) => {
773 let peer_id = match meerkat_core::comms::PeerId::parse(peer_id.as_str()) {
774 Ok(peer_id) => peer_id,
775 Err(error) => {
776 tracing::warn!(
777 peer_id,
778 error = %error,
779 "dropping peer request projection with non-canonical peer_id"
780 );
781 return None;
782 }
783 };
784 Some(PeerConversationProjection::Request {
785 peer_id,
786 display_name: peer_display_label(peer),
787 request_id: request_id.clone(),
788 intent: intent.clone(),
789 payload: peer.payload.clone(),
790 })
791 }
792 Some(PeerConvention::ResponseProgress { request_id, phase }) => {
793 Some(PeerConversationProjection::ResponseProgress {
794 peer_id,
795 request_id: request_id.clone(),
796 phase: *phase,
797 payload: peer.payload.clone(),
798 })
799 }
800 Some(PeerConvention::ResponseTerminal { .. }) => None,
801 None => None,
802 }
803}
804
805pub(crate) fn peer_response_terminal_fact(
806 peer: &PeerInput,
807) -> Result<Option<PeerResponseTerminalFact>, PeerResponseTerminalFactError> {
808 let InputOrigin::Peer {
809 peer_id,
810 display_identity,
811 runtime_id,
812 } = &peer.header.source
813 else {
814 return Ok(None);
815 };
816 let Some(PeerConvention::ResponseTerminal { request_id, status }) = &peer.convention else {
817 return Ok(None);
818 };
819
820 let transport_identity = runtime_id
821 .as_ref()
822 .map(ToString::to_string)
823 .map(PeerResponseTerminalTransportIdentity::parse)
824 .transpose()?;
825 let source = PeerResponseTerminalSource::new(
826 transport_identity,
827 PeerResponseTerminalRouteIdentity::parse(peer_id.clone())?,
828 PeerResponseTerminalDisplayIdentity::parse(
829 display_identity
830 .as_ref()
831 .ok_or(PeerResponseTerminalFactError::MissingDisplayIdentity)?
832 .clone(),
833 )?,
834 );
835 Ok(Some(PeerResponseTerminalFact::new(
836 source,
837 PeerResponseTerminalCorrelationId::parse(request_id)?,
838 *status,
839 PeerResponseTerminalRenderPayload::new(peer.payload.clone()),
840 )))
841}
842
843pub(crate) fn validate_peer_response_terminal_fact(
844 input: &Input,
845) -> Result<(), PeerResponseTerminalFactError> {
846 let Input::Peer(peer) = input else {
847 return Ok(());
848 };
849 peer_response_terminal_fact(peer).map(|_| ())
850}
851
852#[cfg(test)]
855pub(crate) fn peer_projection(input: &Input) -> Option<PeerConversationProjection> {
856 let Input::Peer(peer) = input else {
857 return None;
858 };
859 peer_projection_from_peer_input(peer)
860}
861
862fn peer_canonical_id(peer: &PeerInput) -> Option<String> {
863 let InputOrigin::Peer { peer_id, .. } = &peer.header.source else {
864 return None;
865 };
866 Some(peer_id.clone())
867}
868
869fn peer_display_label(peer: &PeerInput) -> Option<String> {
870 let InputOrigin::Peer {
871 display_identity, ..
872 } = &peer.header.source
873 else {
874 return None;
875 };
876
877 display_identity
878 .as_ref()
879 .map(|label| label.trim())
880 .filter(|label| !label.is_empty())
881 .map(ToOwned::to_owned)
882}
883
884pub(crate) fn peer_reply_capability(
896 input: &Input,
897) -> Option<meerkat_core::comms::PeerReplyCapability> {
898 if input.kind() != InputKind::PeerMessage {
899 return None;
900 }
901 let Input::Peer(peer) = input else {
902 return None;
903 };
904 let InputOrigin::Peer { peer_id, .. } = &peer.header.source else {
905 return None;
906 };
907 let peer_id = match meerkat_core::comms::PeerId::parse(peer_id) {
908 Ok(peer_id) => peer_id,
909 Err(error) => {
910 tracing::error!(
911 peer_id,
912 error = %error,
913 "dropping peer reply capability with non-canonical peer_id"
914 );
915 return None;
916 }
917 };
918 let correlation_id = peer.header.correlation_id.as_ref()?;
919 Some(meerkat_core::comms::PeerReplyCapability {
920 in_reply_to: meerkat_core::InteractionId(correlation_id.0),
921 peer_id,
922 display_name: peer_display_label(peer),
923 kind: meerkat_core::comms::PeerReplyDeliveryKind::Message,
924 })
925}
926
927pub(crate) fn peer_prompt_text(peer: &PeerInput) -> String {
929 peer_projection_from_peer_input(peer)
930 .map(|projection| {
931 let prompt = projection.prompt_text();
932 if prompt.is_empty() {
933 peer.content.text_content()
934 } else {
935 prompt
936 }
937 })
938 .unwrap_or_else(|| peer.content.text_content())
939}
940
941pub(crate) fn input_prompt_text(input: &Input) -> String {
942 match input {
943 Input::Prompt(p) => p.content.text_content(),
944 Input::Peer(p) => peer_prompt_text(p),
945 Input::FlowStep(f) => f.content.text_content(),
946 Input::ExternalEvent(e) => external_event_projection_text(e),
947 Input::Continuation(continuation) => format!("[Continuation] {}", continuation.reason),
948 Input::Operation(operation) => {
949 format!(
950 "[Operation {}] {:?}",
951 operation.operation_id, operation.event
952 )
953 }
954 }
955}
956
957fn external_event_projection_text(event: &ExternalEventInput) -> String {
958 let source_name = match &event.header.source {
959 InputOrigin::External { source_name } if !source_name.trim().is_empty() => {
960 source_name.as_str()
961 }
962 _ => event.event_type.as_str(),
963 };
964 let body = event
965 .payload
966 .get("body")
967 .and_then(serde_json::Value::as_str)
968 .map(str::trim);
969
970 meerkat_core::interaction::format_external_event_projection(source_name, body)
971}
972
973fn peer_notice_renderable(peer: &PeerInput) -> Option<CoreRenderable> {
974 let (peer_id, display_name) = match &peer.header.source {
975 InputOrigin::Peer {
976 peer_id,
977 display_identity,
978 ..
979 } => (peer_id.clone(), display_identity.clone()),
980 _ => return None,
981 };
982 use meerkat_core::types::CommsNoticeKind;
983 let (kind, request_id, intent, status) = match &peer.convention {
984 Some(PeerConvention::Message) | None => (CommsNoticeKind::Message, None, None, None),
985 Some(PeerConvention::Request { request_id, intent }) => (
986 CommsNoticeKind::Request,
987 Some(request_id.clone()),
988 Some(intent.clone()),
989 None,
990 ),
991 Some(PeerConvention::ResponseProgress { request_id, phase }) => (
992 CommsNoticeKind::ResponseProgress,
993 Some(request_id.clone()),
994 None,
995 Some(format!("{phase:?}")),
996 ),
997 Some(PeerConvention::ResponseTerminal { request_id, status }) => (
998 CommsNoticeKind::ResponseTerminal,
999 Some(request_id.clone()),
1000 None,
1001 Some(format!("{status:?}")),
1002 ),
1003 };
1004 let summary = match kind {
1005 CommsNoticeKind::Request => intent.as_ref().map_or_else(
1006 || "Peer request".to_string(),
1007 |intent| format!("Peer request: {intent}"),
1008 ),
1009 CommsNoticeKind::ResponseProgress => "Peer response progress".to_string(),
1010 CommsNoticeKind::ResponseTerminal => "Peer response terminal".to_string(),
1011 CommsNoticeKind::Message | CommsNoticeKind::Other(_) => "Peer message".to_string(),
1012 };
1013 let content = match &peer.content {
1014 ContentInput::Text(body) if body.is_empty() => Vec::new(),
1015 ContentInput::Text(body) => {
1016 vec![meerkat_core::types::ContentBlock::Text { text: body.clone() }]
1017 }
1018 ContentInput::Blocks(blocks) => blocks.clone(),
1019 };
1020 let notice_peer = meerkat_core::comms::PeerId::parse(&peer_id)
1027 .ok()
1028 .map(|id| SystemNoticePeer { id, display_name });
1029 Some(CoreRenderable::SystemNotice {
1030 kind: SystemNoticeKind::Comms,
1031 body: Some(summary.clone()),
1032 blocks: vec![SystemNoticeBlock::Comms {
1033 kind,
1034 direction: SystemNoticeDirection::Incoming,
1035 peer: notice_peer,
1036 sender_taint: peer.sender_taint,
1040 request_id,
1041 intent,
1042 status,
1043 summary: Some(summary),
1044 payload: peer.payload.clone(),
1045 content,
1046 }],
1047 })
1048}
1049
1050fn external_event_notice_renderable(event: &ExternalEventInput) -> CoreRenderable {
1051 let source = match &event.header.source {
1052 InputOrigin::External { source_name } if !source_name.trim().is_empty() => {
1053 source_name.clone()
1054 }
1055 _ => event.event_type.clone(),
1056 };
1057 let body = event
1058 .payload
1059 .get("body")
1060 .and_then(serde_json::Value::as_str)
1061 .map(str::trim)
1062 .filter(|body| !body.is_empty())
1063 .map(ToOwned::to_owned);
1064 let summary = body.as_ref().map_or_else(
1065 || format!("External event via {source}"),
1066 std::clone::Clone::clone,
1067 );
1068 CoreRenderable::SystemNotice {
1069 kind: SystemNoticeKind::ExternalEvent,
1070 body: Some(summary.clone()),
1071 blocks: vec![SystemNoticeBlock::ExternalEvent {
1072 source,
1073 event_type: event.event_type.clone(),
1074 summary: Some(summary),
1075 body,
1076 payload: Some(event.payload.clone()),
1077 content: event.blocks.clone().unwrap_or_default(),
1078 }],
1079 }
1080}
1081
1082fn input_to_append(input: &Input) -> Option<ConversationAppend> {
1083 let (role, content) = match input {
1091 Input::Prompt(p)
1092 if !p.typed_turn_appends.is_empty()
1093 && match &p.content {
1094 ContentInput::Text(text) => text.trim().is_empty(),
1095 ContentInput::Blocks(blocks) => blocks.is_empty(),
1096 } =>
1097 {
1098 return None;
1099 }
1100 Input::Prompt(p) => match &p.content {
1101 ContentInput::Blocks(blocks) => (
1102 ConversationAppendRole::User,
1103 CoreRenderable::Blocks {
1104 blocks: blocks.clone(),
1105 },
1106 ),
1107 ContentInput::Text(_) => (
1108 ConversationAppendRole::User,
1109 CoreRenderable::Text {
1110 text: input_prompt_text(input),
1111 },
1112 ),
1113 },
1114 Input::Peer(p) => peer_notice_renderable(p)
1115 .map(|content| (ConversationAppendRole::SystemNotice, content))?,
1116 Input::FlowStep(f) => (
1117 ConversationAppendRole::SystemNotice,
1118 flow_step_run_renderable(f),
1119 ),
1120 Input::ExternalEvent(e) => (
1121 ConversationAppendRole::SystemNotice,
1122 external_event_notice_renderable(e),
1123 ),
1124 Input::Continuation(continuation) => return continuation.turn_append.clone(),
1125 Input::Operation(_) => return None,
1126 };
1127
1128 Some(ConversationAppend { role, content })
1129}
1130
1131fn flow_step_run_renderable(flow_step: &FlowStepInput) -> CoreRenderable {
1132 CoreRenderable::SystemNotice {
1133 kind: SystemNoticeKind::Generic,
1134 body: Some(format!("Flow step {}", flow_step.step_id)),
1135 blocks: vec![SystemNoticeBlock::RuntimeNotice {
1136 category: "flow_step".to_string(),
1137 detail: Some(flow_step.content.text_content()),
1138 payload: None,
1139 }],
1140 }
1141}
1142
1143pub fn runtime_input_run_started_content(input: &Input) -> Option<ContentInput> {
1152 let projection = runtime_input_projection(input);
1153 let appends = projection
1154 .injected_context_appends
1155 .into_iter()
1156 .chain(projection.append)
1157 .chain(projection.additional_appends)
1158 .collect::<Vec<_>>();
1159 (!appends.is_empty()).then(|| {
1160 meerkat_core::lifecycle::run_primitive::model_projection_content_input_from_conversation_appends(
1161 &appends,
1162 )
1163 })
1164}
1165
1166pub fn directed_input_run_started_content(input: &Input) -> Result<ContentInput, String> {
1174 if validated_directed_interaction_id(input)?.is_none() {
1175 return Err("persisted runtime input does not carry directed interaction custody".into());
1176 }
1177 runtime_input_run_started_content(input)
1178 .ok_or_else(|| "directed runtime input has no turn-start projection".to_string())
1179}
1180
1181fn input_to_context_append(input: &Input) -> Option<ConversationContextAppend> {
1182 let (projection, content) = match input {
1183 Input::Continuation(continuation) => {
1184 return continuation.context_append.clone();
1185 }
1186 Input::Peer(peer) => {
1187 let projection = peer_projection_from_peer_input(peer)?;
1188 let content = peer_notice_renderable(peer)?;
1189 (projection, content)
1190 }
1191 _ => return None,
1192 };
1193
1194 Some(ConversationContextAppend {
1195 key: projection.context_key()?,
1196 content,
1197 })
1198}
1199
1200fn peer_response_terminal_context_append(
1201 peer: &PeerInput,
1202) -> Result<Option<ConversationContextAppend>, PeerResponseTerminalFactError> {
1203 let Some(fact) = peer_response_terminal_fact(peer)? else {
1204 return Ok(None);
1205 };
1206
1207 Ok(Some(ConversationContextAppend {
1208 key: fact.context_key(),
1209 content: CoreRenderable::SystemNotice {
1210 kind: SystemNoticeKind::Comms,
1211 body: Some("Peer terminal response context".to_string()),
1212 blocks: vec![SystemNoticeBlock::Comms {
1213 kind: meerkat_core::types::CommsNoticeKind::ResponseTerminal,
1214 direction: SystemNoticeDirection::Incoming,
1215 peer: Some(SystemNoticePeer {
1216 id: fact.source.route_identity.peer_id(),
1217 display_name: Some(fact.source.display_identity.to_string()),
1218 }),
1219 sender_taint: None,
1223 request_id: Some(fact.correlation_id.to_string()),
1224 intent: None,
1225 status: Some(fact.status.label().to_string()),
1226 summary: Some("Peer terminal response".to_string()),
1227 payload: fact.render_payload.as_ref().cloned(),
1228 content: Vec::new(),
1229 }],
1230 },
1231 }))
1232}
1233
1234fn injected_context_appends(entries: &[ContentInput]) -> Vec<ConversationAppend> {
1238 entries
1239 .iter()
1240 .map(|entry| ConversationAppend {
1241 role: ConversationAppendRole::InjectedContext,
1242 content: match entry {
1243 ContentInput::Blocks(blocks) => CoreRenderable::Blocks {
1244 blocks: blocks.clone(),
1245 },
1246 ContentInput::Text(text) => CoreRenderable::Text { text: text.clone() },
1247 },
1248 })
1249 .collect()
1250}
1251
1252pub(crate) fn runtime_input_projection(
1253 input: &Input,
1254) -> crate::ingress_types::RuntimeInputProjection {
1255 crate::ingress_types::RuntimeInputProjection {
1256 injected_context_appends: match input {
1257 Input::Prompt(prompt) => injected_context_appends(&prompt.injected_context),
1258 Input::Peer(peer) => injected_context_appends(&peer.injected_context),
1259 _ => Vec::new(),
1260 },
1261 append: input_to_append(input),
1262 additional_appends: match input {
1263 Input::Prompt(prompt) => prompt.typed_turn_appends.clone(),
1264 _ => Vec::new(),
1265 },
1266 context_append: input_to_context_append(input),
1267 peer_response_terminal: None,
1268 }
1269}
1270
1271pub(crate) fn runtime_input_projection_for_machine_batch(
1272 input: &Input,
1273) -> crate::ingress_types::RuntimeInputProjection {
1274 let mut projection = runtime_input_projection(input);
1275 if let Input::Peer(peer) = input
1276 && let Ok(Some(context_append)) = peer_response_terminal_context_append(peer)
1277 {
1278 projection.context_append = Some(context_append);
1279 if let Ok(fact) = peer_response_terminal_fact(peer) {
1283 projection.peer_response_terminal = fact;
1284 }
1285 }
1286 projection
1287}
1288
1289pub(crate) fn context_append_to_pending_system_context_append(
1290 append: &ConversationContextAppend,
1291 peer_response_terminal: Option<&meerkat_core::PeerResponseTerminalFact>,
1292) -> meerkat_core::PendingSystemContextAppend {
1293 meerkat_core::PendingSystemContextAppend {
1294 content: append.content.clone(),
1295 source: Some(append.key.clone()),
1296 idempotency_key: Some(append.key.clone()),
1297 source_kind: meerkat_core::session::SystemContextSource::Normal,
1299 peer_response_terminal: peer_response_terminal.cloned(),
1304 accepted_at: meerkat_core::time_compat::SystemTime::now(),
1305 }
1306}
1307
1308pub(crate) fn projection_to_pending_system_context_appends(
1309 input_id: &InputId,
1310 projection: &crate::ingress_types::RuntimeInputProjection,
1311) -> Vec<meerkat_core::PendingSystemContextAppend> {
1312 if let Some(append) = projection.context_append.as_ref() {
1313 return std::iter::once(context_append_to_pending_system_context_append(
1314 append,
1315 projection.peer_response_terminal.as_ref(),
1316 ))
1317 .filter(|append| !append.content.render_text().trim().is_empty())
1318 .collect();
1319 }
1320
1321 projection
1322 .append
1323 .as_ref()
1324 .map(|append| {
1325 let key = format!("runtime:steer:{input_id}");
1331 meerkat_core::PendingSystemContextAppend {
1332 content: append.content.clone(),
1333 source: Some(key.clone()),
1334 idempotency_key: Some(key),
1335 source_kind: meerkat_core::session::SystemContextSource::RuntimeSteer,
1336 peer_response_terminal: None,
1338 accepted_at: meerkat_core::time_compat::SystemTime::now(),
1339 }
1340 })
1341 .into_iter()
1342 .filter(|append| !append.content.render_text().trim().is_empty())
1343 .collect()
1344}
1345
1346#[cfg(test)]
1347#[allow(clippy::unwrap_used, clippy::panic)]
1348mod tests {
1349 use super::*;
1350 use chrono::Utc;
1351
1352 fn make_header() -> InputHeader {
1353 InputHeader {
1354 id: InputId::new(),
1355 timestamp: Utc::now(),
1356 source: InputOrigin::Operator,
1357 durability: InputDurability::Durable,
1358 visibility: InputVisibility::default(),
1359 idempotency_key: None,
1360 supersession_key: None,
1361 correlation_id: None,
1362 }
1363 }
1364
1365 fn typed_runtime_notice_append(detail: &str) -> ConversationAppend {
1366 ConversationAppend {
1367 role: ConversationAppendRole::SystemNotice,
1368 content: CoreRenderable::SystemNotice {
1369 kind: meerkat_core::types::SystemNoticeKind::Generic,
1370 body: Some(detail.to_string()),
1371 blocks: vec![meerkat_core::types::SystemNoticeBlock::RuntimeNotice {
1372 category: "test".to_string(),
1373 detail: Some(detail.to_string()),
1374 payload: None,
1375 }],
1376 },
1377 }
1378 }
1379
1380 #[test]
1381 fn prompt_input_serde() {
1382 let input = Input::Prompt(PromptInput {
1383 injected_context: Vec::new(),
1384 header: make_header(),
1385 content: "hello".into(),
1386 typed_turn_appends: Vec::new(),
1387 turn_metadata: None,
1388 });
1389 let json = serde_json::to_value(&input).unwrap();
1390 assert_eq!(json["input_type"], "prompt");
1391 let parsed: Input = serde_json::from_value(json).unwrap();
1392 assert!(matches!(parsed, Input::Prompt(_)));
1393 }
1394
1395 #[test]
1396 fn prompt_input_typed_turn_appends_project_without_user_text() {
1397 let append = typed_runtime_notice_append("peer delivery");
1398 let input = Input::Prompt(PromptInput {
1399 injected_context: Vec::new(),
1400 header: make_header(),
1401 content: ContentInput::Text(String::new()),
1402 typed_turn_appends: vec![append.clone()],
1403 turn_metadata: None,
1404 });
1405
1406 let projection = runtime_input_projection(&input);
1407 assert!(
1408 projection.append.is_none(),
1409 "empty runtime-authored prompt carrier must not synthesize a user append"
1410 );
1411 assert_eq!(projection.additional_appends, vec![append]);
1412 }
1413
1414 #[test]
1419 fn prompt_input_injected_context_projects_before_user_append() {
1420 let input = Input::Prompt(PromptInput {
1421 injected_context: vec![
1422 ContentInput::Text("ambient alpha".to_string()),
1423 ContentInput::Text("ambient beta".to_string()),
1424 ],
1425 header: make_header(),
1426 content: "the prompt".into(),
1427 typed_turn_appends: Vec::new(),
1428 turn_metadata: None,
1429 });
1430
1431 let projection = runtime_input_projection(&input);
1432 assert_eq!(projection.injected_context_appends.len(), 2);
1433 assert!(
1434 projection
1435 .injected_context_appends
1436 .iter()
1437 .all(|append| { append.role == ConversationAppendRole::InjectedContext })
1438 );
1439 assert_eq!(
1440 projection.injected_context_appends[0].content,
1441 CoreRenderable::Text {
1442 text: "ambient alpha".to_string()
1443 }
1444 );
1445 assert_eq!(
1446 projection.injected_context_appends[1].content,
1447 CoreRenderable::Text {
1448 text: "ambient beta".to_string()
1449 }
1450 );
1451 assert!(
1452 projection.additional_appends.is_empty(),
1453 "injected context must not ride the generic typed_turn_appends carrier"
1454 );
1455 assert!(projection.append.is_some(), "user append must survive");
1456 }
1457
1458 #[test]
1461 fn peer_input_injected_context_projects_before_peer_append() {
1462 let mut header = make_header();
1463 header.source = InputOrigin::Peer {
1464 peer_id: "peer-1".into(),
1465 display_identity: Some("Peer One".into()),
1466 runtime_id: None,
1467 };
1468 let input = Input::Peer(PeerInput {
1469 directed_interaction_id: None,
1470 objective_id: None,
1471 injected_context: vec![ContentInput::Text("supervisor ambient".to_string())],
1472 sender_taint: None,
1473 header,
1474 convention: Some(PeerConvention::Message),
1475 content: "work content".into(),
1476 payload: None,
1477 handling_mode: None,
1478 });
1479
1480 let projection = runtime_input_projection(&input);
1481 assert_eq!(projection.injected_context_appends.len(), 1);
1482 assert_eq!(
1483 projection.injected_context_appends[0].role,
1484 ConversationAppendRole::InjectedContext
1485 );
1486 assert!(
1487 projection.append.is_some(),
1488 "peer work append must survive alongside injected context"
1489 );
1490 }
1491
1492 #[test]
1495 fn prompt_input_injected_context_serde_default_and_omission() {
1496 let input = Input::Prompt(PromptInput {
1497 injected_context: vec![ContentInput::Text("ambient".to_string())],
1498 header: make_header(),
1499 content: "hello".into(),
1500 typed_turn_appends: Vec::new(),
1501 turn_metadata: None,
1502 });
1503 let json = serde_json::to_value(&input).unwrap();
1504 assert!(json.get("injected_context").is_some());
1505 let parsed: Input = serde_json::from_value(json).unwrap();
1506 let Input::Prompt(prompt) = parsed else {
1507 panic!("expected prompt input");
1508 };
1509 assert_eq!(prompt.injected_context.len(), 1);
1510
1511 let empty = Input::Prompt(PromptInput {
1512 injected_context: Vec::new(),
1513 header: make_header(),
1514 content: "hello".into(),
1515 typed_turn_appends: Vec::new(),
1516 turn_metadata: None,
1517 });
1518 let mut json = serde_json::to_value(&empty).unwrap();
1519 assert!(
1520 json.get("injected_context").is_none(),
1521 "empty injected context must be omitted on the wire"
1522 );
1523 json.as_object_mut().unwrap().remove("injected_context");
1525 let parsed: Input = serde_json::from_value(json).unwrap();
1526 let Input::Prompt(prompt) = parsed else {
1527 panic!("expected prompt input");
1528 };
1529 assert!(prompt.injected_context.is_empty());
1530 }
1531
1532 #[test]
1533 fn prompt_input_typed_turn_appends_serde_roundtrip() {
1534 let append = typed_runtime_notice_append("typed appends persist");
1535 let input = Input::Prompt(PromptInput {
1536 injected_context: Vec::new(),
1537 header: make_header(),
1538 content: ContentInput::Text(String::new()),
1539 typed_turn_appends: vec![append.clone()],
1540 turn_metadata: None,
1541 });
1542
1543 let json = serde_json::to_value(&input).unwrap();
1544 let parsed: Input = serde_json::from_value(json).unwrap();
1545 let Input::Prompt(prompt) = parsed else {
1546 panic!("expected prompt input");
1547 };
1548 assert_eq!(prompt.content.text_content(), "");
1549 assert_eq!(prompt.typed_turn_appends, vec![append]);
1550 }
1551
1552 #[test]
1553 fn peer_input_message_serde() {
1554 let input = Input::Peer(PeerInput {
1555 directed_interaction_id: None,
1556 objective_id: None,
1557 injected_context: Vec::new(),
1558 sender_taint: None,
1559 header: make_header(),
1560 convention: Some(PeerConvention::Message),
1561 content: "hi there".into(),
1562 payload: None,
1563 handling_mode: None,
1564 });
1565 let json = serde_json::to_value(&input).unwrap();
1566 assert_eq!(json["input_type"], "peer");
1567 let parsed: Input = serde_json::from_value(json).unwrap();
1568 assert!(matches!(parsed, Input::Peer(_)));
1569 }
1570
1571 fn peer_input_with(
1572 peer_id: &str,
1573 convention: Option<PeerConvention>,
1574 correlation_id: Option<CorrelationId>,
1575 ) -> Input {
1576 let mut header = make_header();
1577 header.source = InputOrigin::Peer {
1578 peer_id: peer_id.into(),
1579 display_identity: Some(" display-agent ".into()),
1580 runtime_id: None,
1581 };
1582 header.correlation_id = correlation_id;
1583 Input::Peer(PeerInput {
1584 directed_interaction_id: None,
1585 objective_id: None,
1586 injected_context: Vec::new(),
1587 sender_taint: None,
1588 header,
1589 convention,
1590 content: "hi there".into(),
1591 payload: None,
1592 handling_mode: None,
1593 })
1594 }
1595
1596 #[test]
1600 fn non_message_conventions_mint_no_reply_capability() {
1601 let peer_id = "018f6f79-7a82-7c4e-a552-a3b86f963005";
1602 let correlation = CorrelationId::from_uuid(uuid::Uuid::from_u128(9));
1603
1604 let message = peer_input_with(
1605 peer_id,
1606 Some(PeerConvention::Message),
1607 Some(correlation.clone()),
1608 );
1609 let capability = peer_reply_capability(&message)
1610 .expect("message convention with correlation must mint a capability");
1611 assert_eq!(
1612 capability.peer_id,
1613 meerkat_core::comms::PeerId::parse(peer_id).expect("canonical id")
1614 );
1615 assert_eq!(
1616 capability.in_reply_to,
1617 meerkat_core::InteractionId(uuid::Uuid::from_u128(9))
1618 );
1619 assert_eq!(
1620 capability.display_name.as_deref(),
1621 Some("display-agent"),
1622 "display identity must be trimmed"
1623 );
1624 assert_eq!(
1625 capability.kind,
1626 meerkat_core::comms::PeerReplyDeliveryKind::Message
1627 );
1628
1629 let bare = peer_input_with(peer_id, None, Some(correlation.clone()));
1630 assert!(
1631 peer_reply_capability(&bare).is_some(),
1632 "bare peer input groups as PeerMessage and must mint"
1633 );
1634
1635 let request = peer_input_with(
1636 peer_id,
1637 Some(PeerConvention::Request {
1638 request_id: "req-1".into(),
1639 intent: "review".into(),
1640 }),
1641 Some(correlation.clone()),
1642 );
1643 assert!(peer_reply_capability(&request).is_none());
1644
1645 let progress = peer_input_with(
1646 peer_id,
1647 Some(PeerConvention::ResponseProgress {
1648 request_id: "req-1".into(),
1649 phase: ResponseProgressPhase::Accepted,
1650 }),
1651 Some(correlation.clone()),
1652 );
1653 assert!(peer_reply_capability(&progress).is_none());
1654
1655 let terminal = peer_input_with(
1656 peer_id,
1657 Some(PeerConvention::ResponseTerminal {
1658 request_id: "req-1".into(),
1659 status: ResponseTerminalStatus::Completed,
1660 }),
1661 Some(correlation),
1662 );
1663 assert!(peer_reply_capability(&terminal).is_none());
1664
1665 let no_correlation = peer_input_with(peer_id, Some(PeerConvention::Message), None);
1666 assert!(
1667 peer_reply_capability(&no_correlation).is_none(),
1668 "a delivery without a correlation id has no reply selector"
1669 );
1670
1671 let prompt = Input::Prompt(PromptInput::new("hello", None));
1672 assert!(peer_reply_capability(&prompt).is_none());
1673 }
1674
1675 #[test]
1676 fn non_canonical_peer_id_mints_no_reply_capability() {
1677 let input = peer_input_with(
1678 "peer-1",
1679 Some(PeerConvention::Message),
1680 Some(CorrelationId::from_uuid(uuid::Uuid::from_u128(9))),
1681 );
1682 assert!(
1683 peer_reply_capability(&input).is_none(),
1684 "a non-canonical peer id must fail the mint, never smuggle a raw string"
1685 );
1686 }
1687
1688 #[test]
1689 fn peer_message_blocks_preserve_typed_comms_content_without_prefix_injection() {
1690 let peer_id = "018f6f79-7a82-7c4e-a552-a3b86f963005";
1691 let mut header = make_header();
1692 header.source = InputOrigin::Peer {
1693 peer_id: peer_id.into(),
1694 display_identity: Some("display-agent".into()),
1695 runtime_id: None,
1696 };
1697 let input = Input::Peer(PeerInput {
1698 directed_interaction_id: None,
1699 objective_id: None,
1700 injected_context: Vec::new(),
1701 sender_taint: None,
1702 header,
1703 convention: Some(PeerConvention::Message),
1704 content: ContentInput::Blocks(vec![
1705 meerkat_core::types::ContentBlock::Text {
1706 text: "caption".into(),
1707 },
1708 meerkat_core::types::ContentBlock::Image {
1709 media_type: "image/png".into(),
1710 data: "abc".into(),
1711 },
1712 ]),
1713 payload: None,
1714 handling_mode: None,
1715 });
1716
1717 let Input::Peer(peer) = &input else {
1718 panic!("expected peer input");
1719 };
1720 assert_eq!(
1721 peer_projection_from_peer_input(peer)
1722 .and_then(|projection| projection.block_prefix_text())
1723 .as_deref(),
1724 Some(format!("Peer message from {peer_id}").as_str())
1725 );
1726
1727 let projection = runtime_input_projection(&input);
1728 let append = projection.append.expect("conversation append");
1729 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1730 panic!("expected typed system notice");
1731 };
1732 let Some(meerkat_core::types::SystemNoticeBlock::Comms { content, peer, .. }) =
1733 blocks.first()
1734 else {
1735 panic!("expected comms block");
1736 };
1737 assert_eq!(
1738 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1739 Some("display-agent")
1740 );
1741 assert_eq!(
1742 content.first(),
1743 Some(&meerkat_core::types::ContentBlock::Text {
1744 text: "caption".into()
1745 })
1746 );
1747 }
1748
1749 #[test]
1756 fn peer_message_sender_taint_reaches_typed_comms_notice_and_model_projection() {
1757 use meerkat_core::comms::SenderContentTaint;
1758
1759 let notice_block = |declared: Option<SenderContentTaint>| {
1760 let mut header = make_header();
1761 header.source = InputOrigin::Peer {
1762 peer_id: "018f6f79-7a82-7c4e-a552-a3b86f963005".into(),
1763 display_identity: Some("display-agent".into()),
1764 runtime_id: None,
1765 };
1766 let input = Input::Peer(PeerInput {
1767 directed_interaction_id: None,
1768 objective_id: None,
1769 injected_context: Vec::new(),
1770 sender_taint: declared,
1771 header,
1772 convention: Some(PeerConvention::Message),
1773 content: "hello from peer".into(),
1774 payload: None,
1775 handling_mode: None,
1776 });
1777 let projection = runtime_input_projection(&input);
1778 let append = projection.append.expect("conversation append");
1779 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1780 panic!("expected typed system notice");
1781 };
1782 blocks.first().cloned().expect("comms block")
1783 };
1784
1785 let tainted_block = notice_block(Some(SenderContentTaint::Tainted));
1786 let clean_block = notice_block(Some(SenderContentTaint::Clean));
1787 let undeclared_block = notice_block(None);
1788
1789 let taint_of = |block: &meerkat_core::types::SystemNoticeBlock| {
1790 let meerkat_core::types::SystemNoticeBlock::Comms { sender_taint, .. } = block else {
1791 panic!("expected comms block");
1792 };
1793 *sender_taint
1794 };
1795 assert_eq!(taint_of(&tainted_block), Some(SenderContentTaint::Tainted));
1796 assert_eq!(taint_of(&clean_block), Some(SenderContentTaint::Clean));
1797 assert_eq!(
1798 taint_of(&undeclared_block),
1799 None,
1800 "no declaration must stay None in the transcript, never coalesced into Clean"
1801 );
1802
1803 let tainted_text = tainted_block.model_projection_text();
1804 let clean_text = clean_block.model_projection_text();
1805 let undeclared_text = undeclared_block.model_projection_text();
1806 assert!(
1807 tainted_text.contains("[sender declared this content tainted]"),
1808 "declared taint must be model-visible: {tainted_text}"
1809 );
1810 assert_eq!(
1811 clean_text, undeclared_text,
1812 "Clean and no-declaration deliberately render identically; the typed field is the carrier"
1813 );
1814 assert!(!clean_text.contains("tainted"));
1815 }
1816
1817 #[test]
1818 fn peer_response_terminal_context_is_deferred_to_machine_batch_projection() {
1819 let route_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f2";
1820 let request_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f1";
1821 let mut header = make_header();
1822 header.source = InputOrigin::Peer {
1823 peer_id: route_id.into(),
1824 display_identity: Some("display-agent".into()),
1825 runtime_id: None,
1826 };
1827 let input = Input::Peer(PeerInput {
1828 directed_interaction_id: None,
1829 objective_id: None,
1830 injected_context: Vec::new(),
1831 sender_taint: None,
1832 header,
1833 convention: Some(PeerConvention::ResponseTerminal {
1834 request_id: request_id.into(),
1835 status: ResponseTerminalStatus::Completed,
1836 }),
1837 content: "response body".into(),
1838 payload: Some(serde_json::json!({"answer":"ok"})),
1839 handling_mode: None,
1840 });
1841
1842 let Input::Peer(peer) = &input else {
1843 panic!("expected peer input");
1844 };
1845 let expected_canonical_key = format!("peer_response_terminal:{route_id}:{request_id}");
1846 assert!(
1847 peer_projection_from_peer_input(peer).is_none(),
1848 "terminal peer response projection must not be built before machine batch selection"
1849 );
1850
1851 let projection = runtime_input_projection(&input);
1852 assert!(
1853 projection.context_append.is_none(),
1854 "admission projection must not store terminal peer response context"
1855 );
1856 let projection = runtime_input_projection_for_machine_batch(&input);
1857 let context = projection.context_append.expect("context append");
1858 assert_eq!(context.key, expected_canonical_key);
1859 let CoreRenderable::SystemNotice { blocks, .. } = context.content else {
1860 panic!("expected typed context");
1861 };
1862 let Some(meerkat_core::types::SystemNoticeBlock::Comms { peer, .. }) = blocks.first()
1863 else {
1864 panic!("expected comms block");
1865 };
1866 assert_eq!(
1867 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1868 Some("display-agent")
1869 );
1870 assert_eq!(
1871 peer.as_ref().map(|peer| peer.id),
1872 Some(meerkat_core::comms::PeerId::parse(route_id).expect("valid route id"))
1873 );
1874 }
1875
1876 #[test]
1877 fn steer_projection_uses_context_append_as_pending_system_context() {
1878 let input_id = InputId::new();
1879 let projection = crate::ingress_types::RuntimeInputProjection {
1880 injected_context_appends: Vec::new(),
1881 append: Some(ConversationAppend {
1882 role: ConversationAppendRole::SystemNotice,
1883 content: CoreRenderable::Text {
1884 text: "ordinary append must lose to context append".into(),
1885 },
1886 }),
1887 additional_appends: Vec::new(),
1888 context_append: Some(ConversationContextAppend {
1889 key: "peer_response_terminal:peer:req".into(),
1890 content: CoreRenderable::Text {
1891 text: "terminal response is ready".into(),
1892 },
1893 }),
1894 peer_response_terminal: None,
1895 };
1896
1897 let appends = projection_to_pending_system_context_appends(&input_id, &projection);
1898
1899 assert_eq!(appends.len(), 1);
1900 assert_eq!(
1901 appends[0].content.render_text(),
1902 "terminal response is ready"
1903 );
1904 assert_eq!(
1905 appends[0].source.as_deref(),
1906 Some("peer_response_terminal:peer:req")
1907 );
1908 assert_eq!(
1909 appends[0].idempotency_key.as_deref(),
1910 Some("peer_response_terminal:peer:req")
1911 );
1912 }
1913
1914 #[test]
1915 fn continuation_projection_can_carry_runtime_context_append() {
1916 let input = Input::Continuation(ContinuationInput {
1917 header: make_header(),
1918 reason: "workgraph_attention".into(),
1919 continuation_kind: ContinuationKind::WorkgraphAttention,
1920 handling_mode: HandlingMode::Steer,
1921 request_id: Some("binding-1".into()),
1922 turn_tool_overlay: Some(TurnToolOverlay {
1923 allowed_tools: Some(vec!["workgraph_add_evidence".into()]),
1924 blocked_tools: None,
1925 dispatch_context: Default::default(),
1926 }),
1927 context_append: Some(ConversationContextAppend {
1928 key: "workgraph_attention:binding-1:2:5".into(),
1929 content: CoreRenderable::Text {
1930 text: "WorkGraph attention projection".into(),
1931 },
1932 }),
1933 turn_append: None,
1934 });
1935 let projection = runtime_input_projection_for_machine_batch(&input);
1936 let appends = projection_to_pending_system_context_appends(input.id(), &projection);
1937
1938 assert_eq!(appends.len(), 1);
1939 assert_eq!(
1940 appends[0].content.render_text(),
1941 "WorkGraph attention projection"
1942 );
1943 assert_eq!(
1944 appends[0].source.as_deref(),
1945 Some("workgraph_attention:binding-1:2:5")
1946 );
1947 let metadata = crate::runtime_loop::for_input(
1948 &input,
1949 crate::ingress_types::RuntimeInputSemantics {
1950 boundary: meerkat_core::lifecycle::run_primitive::RunApplyBoundary::RunStart,
1951 execution_kind: meerkat_core::lifecycle::RuntimeExecutionKind::ContentTurn,
1952 execution_handling_mode: None,
1953 peer_response_terminal_apply_intent: None,
1954 live_interrupt_required: false,
1955 },
1956 );
1957 assert_eq!(
1958 metadata
1959 .turn_tool_overlay
1960 .and_then(|overlay| overlay.allowed_tools),
1961 Some(vec!["workgraph_add_evidence".into()])
1962 );
1963 }
1964
1965 #[test]
1966 fn steer_projection_falls_back_to_ordinary_peer_append() {
1967 let mut header = make_header();
1968 header.source = InputOrigin::Peer {
1969 peer_id: "peer-a".into(),
1970 display_identity: Some("Peer A".into()),
1971 runtime_id: None,
1972 };
1973 let input = Input::Peer(PeerInput {
1974 directed_interaction_id: None,
1975 objective_id: None,
1976 injected_context: Vec::new(),
1977 sender_taint: None,
1978 header,
1979 convention: Some(PeerConvention::Message),
1980 content: "please look at this while you work".into(),
1981 payload: None,
1982 handling_mode: Some(HandlingMode::Steer),
1983 });
1984 let input_id = input.id().clone();
1985 let projection = runtime_input_projection(&input);
1986
1987 let appends = projection_to_pending_system_context_appends(&input_id, &projection);
1988
1989 assert_eq!(appends.len(), 1);
1990 let rendered = appends[0].content.render_text();
1991 assert!(
1992 rendered.contains("please look at this while you work"),
1993 "peer message append should be renderable as live system context: {rendered:?}"
1994 );
1995 assert_eq!(
1996 appends[0].source.as_deref(),
1997 Some(format!("runtime:steer:{input_id}").as_str())
1998 );
1999 assert_eq!(
2000 appends[0].idempotency_key.as_deref(),
2001 Some(format!("runtime:steer:{input_id}").as_str())
2002 );
2003 }
2004
2005 #[test]
2006 fn steer_projection_filters_empty_context_and_empty_append() {
2007 let input_id = InputId::new();
2008 let context_projection = crate::ingress_types::RuntimeInputProjection {
2009 injected_context_appends: Vec::new(),
2010 append: None,
2011 additional_appends: Vec::new(),
2012 context_append: Some(ConversationContextAppend {
2013 key: "empty-context".into(),
2014 content: CoreRenderable::Text { text: " ".into() },
2015 }),
2016 peer_response_terminal: None,
2017 };
2018 assert!(
2019 projection_to_pending_system_context_appends(&input_id, &context_projection).is_empty()
2020 );
2021
2022 let append_projection = crate::ingress_types::RuntimeInputProjection {
2023 injected_context_appends: Vec::new(),
2024 append: Some(ConversationAppend {
2025 role: ConversationAppendRole::SystemNotice,
2026 content: CoreRenderable::Text { text: "\n".into() },
2027 }),
2028 additional_appends: Vec::new(),
2029 context_append: None,
2030 peer_response_terminal: None,
2031 };
2032 assert!(
2033 projection_to_pending_system_context_appends(&input_id, &append_projection).is_empty()
2034 );
2035 }
2036
2037 #[test]
2038 fn peer_response_terminal_with_blocks_projects_append_and_context() {
2039 let route_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f2";
2040 let request_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f1";
2041 let mut header = make_header();
2042 header.source = InputOrigin::Peer {
2043 peer_id: route_id.into(),
2044 display_identity: Some("display-agent".into()),
2045 runtime_id: None,
2046 };
2047 let input = Input::Peer(PeerInput {
2048 directed_interaction_id: None,
2049 objective_id: None,
2050 injected_context: Vec::new(),
2051 sender_taint: None,
2052 header,
2053 convention: Some(PeerConvention::ResponseTerminal {
2054 request_id: request_id.into(),
2055 status: ResponseTerminalStatus::Completed,
2056 }),
2057 content: ContentInput::Blocks(vec![meerkat_core::types::ContentBlock::Image {
2058 media_type: "image/jpeg".into(),
2059 data: "abc".into(),
2060 }]),
2061 payload: Some(serde_json::json!({"answer":"ok"})),
2062 handling_mode: None,
2063 });
2064
2065 let projection = runtime_input_projection_for_machine_batch(&input);
2066 let append = projection.append.expect("conversation append");
2067 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
2068 panic!("expected typed append");
2069 };
2070 let Some(meerkat_core::types::SystemNoticeBlock::Comms { content, peer, .. }) =
2071 blocks.first()
2072 else {
2073 panic!("expected comms block");
2074 };
2075 assert_eq!(
2076 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
2077 Some("display-agent")
2078 );
2079 assert!(matches!(
2080 content.first(),
2081 Some(meerkat_core::types::ContentBlock::Image { media_type, .. })
2082 if media_type == "image/jpeg"
2083 ));
2084 assert!(
2085 projection.context_append.is_some(),
2086 "terminal response must still apply runtime-owned context"
2087 );
2088 }
2089
2090 #[test]
2091 fn peer_input_request_serde() {
2092 let input = Input::Peer(PeerInput {
2093 directed_interaction_id: None,
2094 objective_id: None,
2095 injected_context: Vec::new(),
2096 sender_taint: None,
2097 header: make_header(),
2098 convention: Some(PeerConvention::Request {
2099 request_id: "req-1".into(),
2100 intent: "mob.peer_added".into(),
2101 }),
2102 content: "Agent joined".into(),
2103 payload: Some(serde_json::json!({"name": "agent-1"})),
2104 handling_mode: None,
2105 });
2106 let json = serde_json::to_value(&input).unwrap();
2107 let parsed: Input = serde_json::from_value(json).unwrap();
2108 if let Input::Peer(p) = parsed {
2109 assert!(matches!(p.convention, Some(PeerConvention::Request { .. })));
2110 } else {
2111 panic!("Expected PeerInput");
2112 }
2113 }
2114
2115 #[test]
2116 fn peer_input_response_terminal_serde() {
2117 let input = Input::Peer(PeerInput {
2118 directed_interaction_id: None,
2119 objective_id: None,
2120 injected_context: Vec::new(),
2121 sender_taint: None,
2122 header: make_header(),
2123 convention: Some(PeerConvention::ResponseTerminal {
2124 request_id: "req-1".into(),
2125 status: ResponseTerminalStatus::Completed,
2126 }),
2127 content: "Done".into(),
2128 payload: Some(serde_json::json!({"ok": true})),
2129 handling_mode: None,
2130 });
2131 let json = serde_json::to_value(&input).unwrap();
2132 let parsed: Input = serde_json::from_value(json).unwrap();
2133 assert!(matches!(parsed, Input::Peer(_)));
2134 }
2135
2136 #[test]
2137 fn peer_input_response_progress_serde() {
2138 let input = Input::Peer(PeerInput {
2139 directed_interaction_id: None,
2140 objective_id: None,
2141 injected_context: Vec::new(),
2142 sender_taint: None,
2143 header: make_header(),
2144 convention: Some(PeerConvention::ResponseProgress {
2145 request_id: "req-1".into(),
2146 phase: ResponseProgressPhase::InProgress,
2147 }),
2148 content: "Working...".into(),
2149 payload: Some(serde_json::json!({"progress": "working"})),
2150 handling_mode: None,
2151 });
2152 let json = serde_json::to_value(&input).unwrap();
2153 let parsed: Input = serde_json::from_value(json).unwrap();
2154 assert!(matches!(parsed, Input::Peer(_)));
2155 }
2156
2157 #[test]
2158 fn flow_step_input_serde() {
2159 let input = Input::FlowStep(FlowStepInput {
2160 header: make_header(),
2161 step_id: "step-1".into(),
2162 content: ContentInput::Blocks(vec![
2163 meerkat_core::types::ContentBlock::Text {
2164 text: "analyze the data".into(),
2165 },
2166 meerkat_core::types::ContentBlock::Image {
2167 media_type: "image/png".into(),
2168 data: meerkat_core::types::ImageData::Inline {
2169 data: "abc123".into(),
2170 },
2171 },
2172 ]),
2173 directed_interaction_id: None,
2174 turn_metadata: None,
2175 });
2176 let json = serde_json::to_value(&input).unwrap();
2177 assert_eq!(json["input_type"], "flow_step");
2178 let parsed: Input = serde_json::from_value(json).unwrap();
2179 assert!(matches!(parsed, Input::FlowStep(_)));
2180 }
2181
2182 #[test]
2183 fn flow_step_uses_the_canonical_runtime_run_started_projection() {
2184 let flow_step = FlowStepInput {
2185 header: make_header(),
2186 step_id: "step-1".into(),
2187 content: ContentInput::Text("go\n\"quoted\" \\ path".into()),
2188 directed_interaction_id: None,
2189 turn_metadata: None,
2190 };
2191 let input = Input::FlowStep(flow_step);
2192 let projected = runtime_input_projection(&input)
2193 .append
2194 .expect("flow step projects a run append")
2195 .content
2196 .render_text();
2197
2198 assert_eq!(projected, "Flow step step-1\ngo\n\"quoted\" \\ path");
2199 assert_eq!(
2200 runtime_input_run_started_content(&input)
2201 .expect("flow step starts a model-visible run"),
2202 ContentInput::Text(projected),
2203 );
2204 }
2205
2206 #[test]
2207 fn multimodal_flow_step_uses_the_canonical_runtime_run_started_projection() {
2208 let flow_step = FlowStepInput {
2209 header: make_header(),
2210 step_id: "vision-step".into(),
2211 content: ContentInput::Blocks(vec![
2212 meerkat_core::types::ContentBlock::Text {
2213 text: "inspect this\nimage".into(),
2214 },
2215 meerkat_core::types::ContentBlock::Image {
2216 media_type: "image/png".into(),
2217 data: meerkat_core::types::ImageData::Inline {
2218 data: "abc123".into(),
2219 },
2220 },
2221 ]),
2222 directed_interaction_id: None,
2223 turn_metadata: None,
2224 };
2225 let input = Input::FlowStep(flow_step);
2226 let projected = runtime_input_projection(&input)
2227 .append
2228 .expect("multimodal flow step projects a run append")
2229 .content
2230 .render_text();
2231
2232 assert_eq!(
2233 runtime_input_run_started_content(&input)
2234 .expect("multimodal flow step starts a model-visible run"),
2235 ContentInput::Text(projected),
2236 );
2237 }
2238
2239 #[test]
2240 fn directed_peer_run_started_content_preserves_context_and_multimodal_projection() {
2241 let stable = uuid::Uuid::from_u128(0x00000000000040008000000000000123);
2242 let interaction_id = meerkat_core::interaction::InteractionId(stable);
2243 let input = Input::Peer(PeerInput {
2244 directed_interaction_id: Some(interaction_id),
2245 objective_id: Some(meerkat_core::interaction::ObjectiveId::new()),
2246 injected_context: vec![ContentInput::Text("ambient context".to_string())],
2247 sender_taint: None,
2248 header: InputHeader {
2249 id: InputId::from_uuid(stable),
2250 timestamp: Utc::now(),
2251 source: InputOrigin::Peer {
2252 peer_id: uuid::Uuid::from_u128(7).to_string(),
2253 display_identity: Some("supervisor".to_string()),
2254 runtime_id: Some(LogicalRuntimeId::new("rt:session:placed")),
2255 },
2256 durability: InputDurability::Durable,
2257 visibility: InputVisibility::default(),
2258 idempotency_key: Some(IdempotencyKey::new(stable.to_string())),
2259 supersession_key: None,
2260 correlation_id: Some(CorrelationId::from_uuid(stable)),
2261 },
2262 convention: Some(PeerConvention::Message),
2263 content: ContentInput::Blocks(vec![
2264 meerkat_core::types::ContentBlock::Text {
2265 text: "inspect this image".to_string(),
2266 },
2267 meerkat_core::types::ContentBlock::Image {
2268 media_type: "image/png".to_string(),
2269 data: meerkat_core::types::ImageData::Inline {
2270 data: "abc123".to_string(),
2271 },
2272 },
2273 ]),
2274 payload: None,
2275 handling_mode: Some(meerkat_core::types::HandlingMode::Queue),
2276 });
2277
2278 let projection = runtime_input_projection(&input);
2279 let appends = projection
2280 .injected_context_appends
2281 .into_iter()
2282 .chain(projection.append)
2283 .chain(projection.additional_appends)
2284 .collect::<Vec<_>>();
2285 let expected = meerkat_core::lifecycle::run_primitive::model_projection_content_input_from_conversation_appends(
2286 &appends,
2287 );
2288 let actual = directed_input_run_started_content(&input)
2289 .expect("valid directed peer owns a turn-start projection");
2290
2291 assert_eq!(actual, expected);
2292 assert!(actual.text_content().contains("ambient context"));
2293 assert!(actual.text_content().contains("inspect this image"));
2294 assert!(
2295 matches!(actual, ContentInput::Blocks(ref blocks) if blocks.iter().any(|block| matches!(block, meerkat_core::types::ContentBlock::Image { .. })))
2296 );
2297 }
2298
2299 #[test]
2300 fn external_event_input_serde() {
2301 let input = Input::ExternalEvent(ExternalEventInput {
2302 objective_id: None,
2303 header: make_header(),
2304 event_type: "webhook.received".into(),
2305 payload: serde_json::json!({"url": "https://example.com"}),
2306 blocks: Some(vec![
2307 meerkat_core::types::ContentBlock::Text {
2308 text: "look".into(),
2309 },
2310 meerkat_core::types::ContentBlock::Image {
2311 media_type: "image/png".into(),
2312 data: meerkat_core::types::ImageData::Inline {
2313 data: "abc123".into(),
2314 },
2315 },
2316 ]),
2317 handling_mode: HandlingMode::Queue,
2318 render_metadata: None,
2319 });
2320 let json = serde_json::to_value(&input).unwrap();
2321 assert_eq!(json["input_type"], "external_event");
2322 let parsed: Input = serde_json::from_value(json).unwrap();
2323 assert!(matches!(parsed, Input::ExternalEvent(_)));
2324 }
2325
2326 #[test]
2327 fn legacy_external_event_payload_blocks_are_rejected() {
2328 let event = ExternalEventInput {
2331 objective_id: None,
2332 header: make_header(),
2333 event_type: "webhook.received".into(),
2334 payload: serde_json::json!({
2335 "body": "see image",
2336 "blocks": [
2337 { "type": "text", "text": "caption text" },
2338 { "type": "image", "media_type": "image/png", "source": "inline", "data": "abc123" }
2339 ]
2340 }),
2341 blocks: None,
2342 handling_mode: HandlingMode::Queue,
2343 render_metadata: None,
2344 };
2345
2346 let err = reject_legacy_payload_blocks(&event)
2347 .expect_err("payload-level blocks must fail closed");
2348 assert!(matches!(err, BlobStoreError::Internal(_)));
2349 assert!(event.payload.get("blocks").is_some());
2351 assert!(event.blocks.is_none());
2352 }
2353
2354 #[test]
2355 fn external_event_payload_without_blocks_key_passes_rejection_gate() {
2356 let event = ExternalEventInput {
2357 objective_id: None,
2358 header: make_header(),
2359 event_type: "webhook.received".into(),
2360 payload: serde_json::json!({ "body": "plain payload" }),
2361 blocks: Some(vec![meerkat_core::types::ContentBlock::Text {
2362 text: "typed owner content".into(),
2363 }]),
2364 handling_mode: HandlingMode::Queue,
2365 render_metadata: None,
2366 };
2367
2368 reject_legacy_payload_blocks(&event)
2369 .expect("payload without a legacy blocks key must pass");
2370 }
2371
2372 #[test]
2373 fn continuation_input_serde() {
2374 let input = Input::Continuation(ContinuationInput::detached_background_op_completed());
2375 let json = serde_json::to_value(&input).unwrap();
2376 assert_eq!(json["input_type"], "continuation");
2377 let parsed: Input = serde_json::from_value(json).unwrap();
2378 match parsed {
2379 Input::Continuation(continuation) => {
2380 assert_eq!(continuation.handling_mode, HandlingMode::Steer);
2381 assert_eq!(continuation.reason, "detached_background_op_completed");
2382 }
2383 other => panic!("Expected Continuation, got {other:?}"),
2384 }
2385 }
2386
2387 #[test]
2388 fn continuation_input_rejects_legacy_system_generated_tag() {
2389 let input = Input::Continuation(ContinuationInput::detached_background_op_completed());
2392 let mut json = serde_json::to_value(&input).unwrap();
2393 json["input_type"] = serde_json::Value::String("system_generated".into());
2394 serde_json::from_value::<Input>(json)
2395 .expect_err("legacy system_generated input_type tag must be rejected");
2396 }
2397
2398 #[test]
2399 fn operation_input_serde() {
2400 let input = Input::Operation(OperationInput {
2401 header: InputHeader {
2402 durability: InputDurability::Derived,
2403 ..make_header()
2404 },
2405 operation_id: OperationId::new(),
2406 event: OpEvent::Cancelled {
2407 id: OperationId::new(),
2408 },
2409 });
2410 let json = serde_json::to_value(&input).unwrap();
2411 assert_eq!(json["input_type"], "operation");
2412 let parsed: Input = serde_json::from_value(json).unwrap();
2413 assert!(matches!(parsed, Input::Operation(_)));
2414 }
2415
2416 #[test]
2417 fn operation_input_rejects_legacy_projected_tag() {
2418 let input = Input::Operation(OperationInput {
2421 header: InputHeader {
2422 durability: InputDurability::Derived,
2423 ..make_header()
2424 },
2425 operation_id: OperationId::new(),
2426 event: OpEvent::Cancelled {
2427 id: OperationId::new(),
2428 },
2429 });
2430 let mut json = serde_json::to_value(&input).unwrap();
2431 json["input_type"] = serde_json::Value::String("projected".into());
2432 serde_json::from_value::<Input>(json)
2433 .expect_err("legacy projected input_type tag must be rejected");
2434 }
2435
2436 #[test]
2437 fn legacy_dual_carrier_input_shapes_are_rejected() {
2438 let header = serde_json::to_value(make_header()).unwrap();
2443
2444 let legacy_prompt = serde_json::json!({
2445 "input_type": "prompt",
2446 "header": header.clone(),
2447 "text": "hello",
2448 "blocks": null
2449 });
2450 serde_json::from_value::<Input>(legacy_prompt)
2451 .expect_err("legacy prompt text+blocks shape must be rejected");
2452
2453 let legacy_peer = serde_json::json!({
2454 "input_type": "peer",
2455 "header": header.clone(),
2456 "convention": { "convention_type": "message" },
2457 "body": "hi there"
2458 });
2459 serde_json::from_value::<Input>(legacy_peer)
2460 .expect_err("legacy peer body+blocks shape must be rejected");
2461
2462 let legacy_flow_step = serde_json::json!({
2463 "input_type": "flow_step",
2464 "header": header,
2465 "step_id": "step-1",
2466 "instructions": "analyze the data"
2467 });
2468 serde_json::from_value::<Input>(legacy_flow_step)
2469 .expect_err("legacy flow-step instructions+blocks shape must be rejected");
2470 }
2471
2472 #[test]
2473 fn input_kind_id() {
2474 let prompt = Input::Prompt(PromptInput {
2475 injected_context: Vec::new(),
2476 header: make_header(),
2477 content: "hi".into(),
2478 typed_turn_appends: Vec::new(),
2479 turn_metadata: None,
2480 });
2481 assert_eq!(prompt.kind(), InputKind::Prompt);
2482
2483 let peer_msg = Input::Peer(PeerInput {
2484 directed_interaction_id: None,
2485 objective_id: None,
2486 injected_context: Vec::new(),
2487 sender_taint: None,
2488 header: make_header(),
2489 convention: Some(PeerConvention::Message),
2490 content: "hi".into(),
2491 payload: None,
2492 handling_mode: None,
2493 });
2494 assert_eq!(peer_msg.kind(), InputKind::PeerMessage);
2495
2496 let peer_req = Input::Peer(PeerInput {
2497 directed_interaction_id: None,
2498 objective_id: None,
2499 injected_context: Vec::new(),
2500 sender_taint: None,
2501 header: make_header(),
2502 convention: Some(PeerConvention::Request {
2503 request_id: "r".into(),
2504 intent: "i".into(),
2505 }),
2506 content: "hi".into(),
2507 payload: Some(serde_json::json!({"subject": "x"})),
2508 handling_mode: None,
2509 });
2510 assert_eq!(peer_req.kind(), InputKind::PeerRequest);
2511
2512 let continuation = Input::Continuation(ContinuationInput {
2513 header: make_header(),
2514 reason: "continue".into(),
2515 continuation_kind: ContinuationKind::Ordinary,
2516 handling_mode: HandlingMode::Steer,
2517 request_id: None,
2518 turn_tool_overlay: None,
2519 context_append: None,
2520 turn_append: None,
2521 });
2522 assert_eq!(continuation.kind(), InputKind::Continuation);
2523
2524 let operation = Input::Operation(OperationInput {
2525 header: make_header(),
2526 operation_id: OperationId::new(),
2527 event: OpEvent::Cancelled {
2528 id: OperationId::new(),
2529 },
2530 });
2531 assert_eq!(operation.kind(), InputKind::Operation);
2532 }
2533
2534 #[test]
2535 fn input_source_variants() {
2536 let sources = vec![
2537 InputOrigin::Operator,
2538 InputOrigin::Peer {
2539 peer_id: "p1".into(),
2540 display_identity: None,
2541 runtime_id: None,
2542 },
2543 InputOrigin::Flow {
2544 flow_id: "f1".into(),
2545 step_index: 0,
2546 },
2547 InputOrigin::System,
2548 InputOrigin::External {
2549 source_name: "webhook".into(),
2550 },
2551 ];
2552 for source in sources {
2553 let json = serde_json::to_value(&source).unwrap();
2554 let parsed: InputOrigin = serde_json::from_value(json).unwrap();
2555 assert_eq!(source, parsed);
2556 }
2557 }
2558
2559 #[test]
2560 fn input_durability_serde() {
2561 for d in [
2562 InputDurability::Durable,
2563 InputDurability::Ephemeral,
2564 InputDurability::Derived,
2565 ] {
2566 let json = serde_json::to_value(d).unwrap();
2567 let parsed: InputDurability = serde_json::from_value(json).unwrap();
2568 assert_eq!(d, parsed);
2569 }
2570 }
2571
2572 #[test]
2573 fn peer_input_without_handling_mode_deserializes_as_none() {
2574 let json = serde_json::json!({
2576 "input_type": "peer",
2577 "header": serde_json::to_value(make_header()).unwrap(),
2578 "convention": { "convention_type": "message" },
2579 "content": "hello"
2580 });
2581 let parsed: Input = serde_json::from_value(json).unwrap();
2582 match parsed {
2583 Input::Peer(p) => assert!(p.handling_mode.is_none()),
2584 other => panic!("Expected Peer, got {other:?}"),
2585 }
2586 }
2587
2588 #[test]
2589 fn peer_input_with_queue_handling_mode_roundtrips() {
2590 let input = Input::Peer(PeerInput {
2591 directed_interaction_id: None,
2592 objective_id: None,
2593 injected_context: Vec::new(),
2594 sender_taint: None,
2595 header: make_header(),
2596 convention: Some(PeerConvention::Message),
2597 content: "hi".into(),
2598 payload: None,
2599 handling_mode: Some(HandlingMode::Queue),
2600 });
2601 let json = serde_json::to_value(&input).unwrap();
2602 assert_eq!(json["handling_mode"], "queue");
2603 let parsed: Input = serde_json::from_value(json).unwrap();
2604 match parsed {
2605 Input::Peer(p) => assert_eq!(p.handling_mode, Some(HandlingMode::Queue)),
2606 other => panic!("Expected Peer, got {other:?}"),
2607 }
2608 }
2609
2610 #[test]
2611 fn peer_response_terminal_input_owns_wire_status_mapping() {
2612 let peer_id = meerkat_core::comms::PeerId::from_uuid(
2613 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000161").unwrap(),
2614 );
2615 let display_name = meerkat_core::comms::PeerName::new("analyst").unwrap();
2616 let request_id = meerkat_core::PeerCorrelationId::from_uuid(
2617 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap(),
2618 );
2619 let input = peer_response_terminal_input(
2620 peer_id,
2621 Some(display_name),
2622 request_id,
2623 meerkat_contracts::PeerResponseTerminalStatusWire::Completed,
2624 serde_json::json!({"ok": true}),
2625 );
2626
2627 match input {
2628 Input::Peer(PeerInput {
2629 header:
2630 InputHeader {
2631 source:
2632 InputOrigin::Peer {
2633 peer_id,
2634 display_identity,
2635 runtime_id,
2636 },
2637 durability: InputDurability::Durable,
2638 correlation_id,
2639 ..
2640 },
2641 convention: Some(PeerConvention::ResponseTerminal { request_id, status }),
2642 payload: Some(payload),
2643 handling_mode: None,
2644 ..
2645 }) => {
2646 assert_eq!(peer_id, "00000000-0000-4000-8000-000000000161");
2647 assert_eq!(display_identity.as_deref(), Some("analyst"));
2648 assert_eq!(runtime_id, None);
2649 assert_eq!(request_id, "00000000-0000-4000-8000-000000000162");
2650 assert_eq!(
2651 correlation_id,
2652 Some(CorrelationId::from_uuid(
2653 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap()
2654 ))
2655 );
2656 assert_eq!(status, ResponseTerminalStatus::Completed);
2657 assert_eq!(payload["ok"], true);
2658 }
2659 other => panic!("expected terminal peer input, got {other:?}"),
2660 }
2661 }
2662
2663 #[test]
2664 fn absent_peer_directed_interaction_defaults_none_and_none_is_omitted() {
2665 let input = peer_response_terminal_input(
2666 meerkat_core::comms::PeerId::from_uuid(uuid::Uuid::new_v4()),
2667 None,
2668 meerkat_core::PeerCorrelationId::from_uuid(uuid::Uuid::new_v4()),
2669 meerkat_contracts::PeerResponseTerminalStatusWire::Completed,
2670 serde_json::json!({"ok": true}),
2671 );
2672 let encoded = serde_json::to_value(&input).expect("serialize ordinary peer input");
2673 assert!(
2674 encoded.get("directed_interaction_id").is_none(),
2675 "ordinary peer persistence must retain the pre-field wire shape"
2676 );
2677
2678 let decoded: Input = serde_json::from_value(encoded).expect("deserialize absent field");
2679 let Input::Peer(peer) = decoded else {
2680 panic!("peer input round-trips as peer input");
2681 };
2682 assert_eq!(peer.directed_interaction_id, None);
2683 }
2684
2685 #[test]
2686 fn peer_response_terminal_validation_is_structural_only() {
2687 let peer_id = meerkat_core::comms::PeerId::from_uuid(
2688 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000161").unwrap(),
2689 );
2690 let display_name = meerkat_core::comms::PeerName::new("analyst").unwrap();
2691 let request_id = meerkat_core::PeerCorrelationId::from_uuid(
2692 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap(),
2693 );
2694 let input = peer_response_terminal_input(
2695 peer_id,
2696 Some(display_name),
2697 request_id,
2698 meerkat_contracts::PeerResponseTerminalStatusWire::Cancelled,
2699 serde_json::json!({"ok": false}),
2700 );
2701
2702 validate_peer_response_terminal_fact(&input)
2703 .expect("status support is generated admission authority, structural fact validation should pass");
2704 }
2705
2706 #[test]
2707 fn peer_input_with_steer_handling_mode_roundtrips() {
2708 let input = Input::Peer(PeerInput {
2709 directed_interaction_id: None,
2710 objective_id: None,
2711 injected_context: Vec::new(),
2712 sender_taint: None,
2713 header: make_header(),
2714 convention: Some(PeerConvention::Message),
2715 content: "hi".into(),
2716 payload: None,
2717 handling_mode: Some(HandlingMode::Steer),
2718 });
2719 let json = serde_json::to_value(&input).unwrap();
2720 assert_eq!(json["handling_mode"], "steer");
2721 let parsed: Input = serde_json::from_value(json).unwrap();
2722 match parsed {
2723 Input::Peer(p) => assert_eq!(p.handling_mode, Some(HandlingMode::Steer)),
2724 other => panic!("Expected Peer, got {other:?}"),
2725 }
2726 }
2727
2728 #[test]
2729 fn peer_input_handling_mode_not_serialized_when_none() {
2730 let input = Input::Peer(PeerInput {
2731 directed_interaction_id: None,
2732 objective_id: None,
2733 injected_context: Vec::new(),
2734 sender_taint: None,
2735 header: make_header(),
2736 convention: Some(PeerConvention::Message),
2737 content: "hi".into(),
2738 payload: None,
2739 handling_mode: None,
2740 });
2741 let json = serde_json::to_value(&input).unwrap();
2742 assert!(json.get("handling_mode").is_none());
2743 }
2744}