1use chrono::{DateTime, Utc};
8use meerkat_core::lifecycle::InputId;
9use meerkat_core::lifecycle::run_primitive::{
10 ConversationAppend, ConversationAppendRole, CoreRenderable, RuntimeTurnMetadata,
11};
12use meerkat_core::ops::{OpEvent, OperationId};
13use meerkat_core::service::TurnToolOverlay;
14use meerkat_core::types::{
15 ContentInput, HandlingMode, ImageData, SystemNoticeBlock, SystemNoticeDirection,
16 SystemNoticeKind, SystemNoticePeer,
17};
18use meerkat_core::{
19 BlobStore, BlobStoreError, MissingBlobBehavior, PeerConversationProjection,
20 PeerResponseProgressProjectionPhase, PeerResponseTerminalCorrelationId,
21 PeerResponseTerminalDisplayIdentity, PeerResponseTerminalFact, PeerResponseTerminalFactError,
22 PeerResponseTerminalProjectionStatus, PeerResponseTerminalRenderPayload,
23 PeerResponseTerminalRouteIdentity, PeerResponseTerminalSource,
24 PeerResponseTerminalTransportIdentity, externalize_content_blocks, hydrate_content_blocks,
25};
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
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")]
414 pub system_prompts: Vec<String>,
415 #[serde(default, skip_serializing_if = "Vec::is_empty")]
425 pub injected_context: Vec<ContentInput>,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
430#[serde(tag = "convention_type", rename_all = "snake_case")]
431#[non_exhaustive]
432pub enum PeerConvention {
433 Message,
435 Request { request_id: String, intent: String },
437 ResponseProgress {
439 request_id: String,
440 phase: ResponseProgressPhase,
441 },
442 ResponseTerminal {
444 request_id: String,
445 status: ResponseTerminalStatus,
446 },
447}
448
449pub type ResponseProgressPhase = PeerResponseProgressProjectionPhase;
452
453pub type ResponseTerminalStatus = PeerResponseTerminalProjectionStatus;
456
457pub fn response_terminal_status_from_wire(
458 status: meerkat_contracts::PeerResponseTerminalStatusWire,
459) -> ResponseTerminalStatus {
460 match status {
461 meerkat_contracts::PeerResponseTerminalStatusWire::Completed => {
462 PeerResponseTerminalProjectionStatus::Completed
463 }
464 meerkat_contracts::PeerResponseTerminalStatusWire::Failed => {
465 PeerResponseTerminalProjectionStatus::Failed
466 }
467 meerkat_contracts::PeerResponseTerminalStatusWire::Cancelled => {
468 PeerResponseTerminalProjectionStatus::Cancelled
469 }
470 }
471}
472
473pub fn peer_response_terminal_input(
474 peer_id: meerkat_core::comms::PeerId,
475 display_name: Option<meerkat_core::comms::PeerName>,
476 request_id: meerkat_core::PeerCorrelationId,
477 status: meerkat_contracts::PeerResponseTerminalStatusWire,
478 result: serde_json::Value,
479) -> Input {
480 let idempotency_key = peer_response_terminal_idempotency_key(peer_id, request_id);
481 let correlation_id = CorrelationId::from_uuid(request_id.as_uuid());
482 let request_id = request_id.to_string();
483 let peer_id = peer_id.to_string();
484 let display_identity = display_name.map_or_else(|| peer_id.clone(), |name| name.as_string());
485
486 Input::Peer(PeerInput {
487 directed_interaction_id: None,
488 objective_id: None,
489 system_prompts: Vec::new(),
490 injected_context: Vec::new(),
491 header: InputHeader {
492 id: InputId::new(),
493 timestamp: Utc::now(),
494 source: InputOrigin::Peer {
495 peer_id,
496 display_identity: Some(display_identity),
497 runtime_id: None,
498 },
499 durability: InputDurability::Durable,
500 visibility: InputVisibility::default(),
501 idempotency_key: Some(idempotency_key),
502 supersession_key: None,
503 correlation_id: Some(correlation_id),
504 },
505 convention: Some(PeerConvention::ResponseTerminal {
506 request_id,
507 status: response_terminal_status_from_wire(status),
508 }),
509 content: ContentInput::Text(String::new()),
510 payload: Some(result),
511 handling_mode: None,
512 sender_taint: None,
515 })
516}
517
518pub(crate) fn peer_response_terminal_idempotency_key(
519 peer_id: meerkat_core::comms::PeerId,
520 request_id: meerkat_core::PeerCorrelationId,
521) -> IdempotencyKey {
522 let route_identity = PeerResponseTerminalRouteIdentity::from_peer_id(peer_id);
523 let correlation_id = PeerResponseTerminalCorrelationId::from_peer_correlation_id(request_id);
524 IdempotencyKey::new(PeerResponseTerminalFact::context_key_for(
525 &route_identity,
526 correlation_id,
527 ))
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct FlowStepInput {
533 pub header: InputHeader,
534 pub step_id: String,
536 pub content: ContentInput,
541 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub directed_interaction_id: Option<meerkat_core::interaction::InteractionId>,
545 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub turn_metadata: Option<RuntimeTurnMetadata>,
547}
548
549fn validate_directed_interaction_header(
550 header: &InputHeader,
551 interaction_id: meerkat_core::interaction::InteractionId,
552 input_kind: &str,
553) -> Result<(), String> {
554 if header.id.0 != interaction_id.0 {
555 return Err(format!(
556 "directed {input_kind} interaction id does not match input id"
557 ));
558 }
559 if header.correlation_id.as_ref().map(|id| id.0) != Some(interaction_id.0) {
560 return Err(format!(
561 "directed {input_kind} correlation id does not match interaction id"
562 ));
563 }
564 let canonical_id = interaction_id.to_string();
565 if header
566 .idempotency_key
567 .as_ref()
568 .map(ToString::to_string)
569 .as_deref()
570 != Some(canonical_id.as_str())
571 {
572 return Err(format!(
573 "directed {input_kind} idempotency key does not match interaction id"
574 ));
575 }
576 if header.durability != InputDurability::Durable {
577 return Err(format!("directed {input_kind} input must be durable"));
578 }
579 Ok(())
580}
581
582pub(crate) fn validate_directed_flow_step_correlation(input: &Input) -> Result<(), String> {
589 let Input::FlowStep(flow_step) = input else {
590 return Ok(());
591 };
592 let Some(interaction_id) = flow_step.directed_interaction_id else {
593 return Ok(());
594 };
595 let header = &flow_step.header;
596 validate_directed_interaction_header(header, interaction_id, "flow-step")?;
597 match &header.source {
598 InputOrigin::Flow {
599 flow_id,
600 step_index: 0,
601 } if !flow_id.trim().is_empty() => Ok(()),
602 _ => Err(
603 "directed flow-step input must carry a non-empty flow origin with remote step index 0"
604 .to_string(),
605 ),
606 }
607}
608
609pub(crate) fn validated_directed_interaction_id(
614 input: &Input,
615) -> Result<Option<meerkat_core::interaction::InteractionId>, String> {
616 match input {
617 Input::FlowStep(flow_step) => {
618 validate_directed_flow_step_correlation(input)?;
619 Ok(flow_step.directed_interaction_id)
620 }
621 Input::Peer(peer) => {
622 let Some(interaction_id) = peer.directed_interaction_id else {
623 return Ok(None);
624 };
625 validate_directed_interaction_header(&peer.header, interaction_id, "peer input")?;
626 match &peer.header.source {
627 InputOrigin::Peer {
628 peer_id,
629 runtime_id: Some(runtime_id),
630 ..
631 } if !peer_id.trim().is_empty() && !runtime_id.0.trim().is_empty() => {}
632 _ => {
633 return Err(
634 "directed peer input must carry a non-empty peer origin and runtime id"
635 .to_string(),
636 );
637 }
638 }
639 if !matches!(peer.convention, Some(PeerConvention::Message)) {
640 return Err("directed peer input must use the message convention".to_string());
641 }
642 if peer.payload.is_some() {
643 return Err("directed peer input must not carry a structured peer payload".into());
644 }
645 if peer.sender_taint.is_some() {
646 return Err("directed peer input must not carry sender-declared taint".into());
647 }
648 if peer.header.supersession_key.is_some() {
649 return Err("directed peer input must not carry a supersession key".into());
650 }
651 Ok(Some(interaction_id))
652 }
653 _ => Ok(None),
654 }
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize)]
659pub struct ExternalEventInput {
660 pub header: InputHeader,
661 pub event_type: String,
663 pub payload: serde_json::Value,
667 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub blocks: Option<Vec<meerkat_core::types::ContentBlock>>,
671 #[serde(default)]
673 pub handling_mode: HandlingMode,
674 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub render_metadata: Option<RenderMetadata>,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
678 pub objective_id: Option<meerkat_core::interaction::ObjectiveId>,
679}
680
681#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
691#[serde(rename_all = "snake_case")]
692pub enum ContinuationKind {
693 #[default]
695 Ordinary,
696 WorkgraphAttention,
698}
699
700#[derive(Debug, Clone, Serialize, Deserialize)]
704pub struct ContinuationInput {
705 pub header: InputHeader,
706 pub reason: String,
708 #[serde(default)]
712 pub continuation_kind: ContinuationKind,
713 #[serde(default)]
715 pub handling_mode: HandlingMode,
716 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub request_id: Option<String>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub turn_tool_overlay: Option<TurnToolOverlay>,
722 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub turn_append: Option<ConversationAppend>,
725}
726
727impl ContinuationInput {
728 pub fn detached_background_op_completed() -> Self {
734 Self {
735 header: InputHeader {
736 id: meerkat_core::lifecycle::InputId::new(),
737 timestamp: chrono::Utc::now(),
738 source: InputOrigin::System,
739 durability: InputDurability::Derived,
740 visibility: InputVisibility {
741 transcript_eligible: false,
742 operator_eligible: false,
743 },
744 idempotency_key: None,
745 supersession_key: None,
746 correlation_id: None,
747 },
748 reason: "detached_background_op_completed".to_string(),
749 continuation_kind: ContinuationKind::Ordinary,
750 handling_mode: HandlingMode::Steer,
751 request_id: None,
752 turn_tool_overlay: None,
753 turn_append: None,
754 }
755 }
756}
757
758#[derive(Debug, Clone, Serialize, Deserialize)]
761pub struct OperationInput {
762 pub header: InputHeader,
763 pub operation_id: OperationId,
765 pub event: OpEvent,
767}
768
769pub(crate) fn peer_projection_from_peer_input(
775 peer: &PeerInput,
776) -> Option<PeerConversationProjection> {
777 peer_projection_from_peer_input_with_id(peer, peer_canonical_id(peer)?.as_str())
778}
779
780fn peer_projection_from_peer_input_with_id(
781 peer: &PeerInput,
782 peer_id: &str,
783) -> Option<PeerConversationProjection> {
784 let peer_id = peer_id.to_string();
785
786 match &peer.convention {
787 Some(PeerConvention::Message) => Some(PeerConversationProjection::Message { peer_id }),
788 Some(PeerConvention::Request { request_id, intent }) => {
789 let peer_id = match meerkat_core::comms::PeerId::parse(peer_id.as_str()) {
790 Ok(peer_id) => peer_id,
791 Err(error) => {
792 tracing::warn!(
793 peer_id,
794 error = %error,
795 "dropping peer request projection with non-canonical peer_id"
796 );
797 return None;
798 }
799 };
800 Some(PeerConversationProjection::Request {
801 peer_id,
802 display_name: peer_display_label(peer),
803 request_id: request_id.clone(),
804 intent: intent.clone(),
805 payload: peer.payload.clone(),
806 })
807 }
808 Some(PeerConvention::ResponseProgress { request_id, phase }) => {
809 Some(PeerConversationProjection::ResponseProgress {
810 peer_id,
811 request_id: request_id.clone(),
812 phase: *phase,
813 payload: peer.payload.clone(),
814 })
815 }
816 Some(PeerConvention::ResponseTerminal { .. }) => None,
817 None => None,
818 }
819}
820
821pub(crate) fn peer_response_terminal_fact(
822 peer: &PeerInput,
823) -> Result<Option<PeerResponseTerminalFact>, PeerResponseTerminalFactError> {
824 let InputOrigin::Peer {
825 peer_id,
826 display_identity,
827 runtime_id,
828 } = &peer.header.source
829 else {
830 return Ok(None);
831 };
832 let Some(PeerConvention::ResponseTerminal { request_id, status }) = &peer.convention else {
833 return Ok(None);
834 };
835
836 let transport_identity = runtime_id
837 .as_ref()
838 .map(ToString::to_string)
839 .map(PeerResponseTerminalTransportIdentity::parse)
840 .transpose()?;
841 let source = PeerResponseTerminalSource::new(
842 transport_identity,
843 PeerResponseTerminalRouteIdentity::parse(peer_id.clone())?,
844 PeerResponseTerminalDisplayIdentity::parse(
845 display_identity
846 .as_ref()
847 .ok_or(PeerResponseTerminalFactError::MissingDisplayIdentity)?
848 .clone(),
849 )?,
850 );
851 Ok(Some(PeerResponseTerminalFact::new(
852 source,
853 PeerResponseTerminalCorrelationId::parse(request_id)?,
854 *status,
855 PeerResponseTerminalRenderPayload::new(peer.payload.clone()),
856 )))
857}
858
859pub(crate) fn validate_peer_response_terminal_fact(
860 input: &Input,
861) -> Result<(), PeerResponseTerminalFactError> {
862 let Input::Peer(peer) = input else {
863 return Ok(());
864 };
865 peer_response_terminal_fact(peer).map(|_| ())
866}
867
868#[cfg(test)]
871pub(crate) fn peer_projection(input: &Input) -> Option<PeerConversationProjection> {
872 let Input::Peer(peer) = input else {
873 return None;
874 };
875 peer_projection_from_peer_input(peer)
876}
877
878fn peer_canonical_id(peer: &PeerInput) -> Option<String> {
879 let InputOrigin::Peer { peer_id, .. } = &peer.header.source else {
880 return None;
881 };
882 Some(peer_id.clone())
883}
884
885fn peer_display_label(peer: &PeerInput) -> Option<String> {
886 let InputOrigin::Peer {
887 display_identity, ..
888 } = &peer.header.source
889 else {
890 return None;
891 };
892
893 display_identity
894 .as_ref()
895 .map(|label| label.trim())
896 .filter(|label| !label.is_empty())
897 .map(ToOwned::to_owned)
898}
899
900pub(crate) fn peer_reply_capability(
912 input: &Input,
913) -> Option<meerkat_core::comms::PeerReplyCapability> {
914 if input.kind() != InputKind::PeerMessage {
915 return None;
916 }
917 let Input::Peer(peer) = input else {
918 return None;
919 };
920 let InputOrigin::Peer { peer_id, .. } = &peer.header.source else {
921 return None;
922 };
923 let peer_id = match meerkat_core::comms::PeerId::parse(peer_id) {
924 Ok(peer_id) => peer_id,
925 Err(error) => {
926 tracing::error!(
927 peer_id,
928 error = %error,
929 "dropping peer reply capability with non-canonical peer_id"
930 );
931 return None;
932 }
933 };
934 let correlation_id = peer.header.correlation_id.as_ref()?;
935 Some(meerkat_core::comms::PeerReplyCapability {
936 in_reply_to: meerkat_core::InteractionId(correlation_id.0),
937 peer_id,
938 display_name: peer_display_label(peer),
939 kind: meerkat_core::comms::PeerReplyDeliveryKind::Message,
940 })
941}
942
943pub(crate) fn peer_prompt_text(peer: &PeerInput) -> String {
945 peer_projection_from_peer_input(peer)
946 .map(|projection| {
947 let prompt = projection.prompt_text();
948 if prompt.is_empty() {
949 peer.content.text_content()
950 } else {
951 prompt
952 }
953 })
954 .unwrap_or_else(|| peer.content.text_content())
955}
956
957pub(crate) fn input_prompt_text(input: &Input) -> String {
958 match input {
959 Input::Prompt(p) => p.content.text_content(),
960 Input::Peer(p) => peer_prompt_text(p),
961 Input::FlowStep(f) => f.content.text_content(),
962 Input::ExternalEvent(e) => external_event_projection_text(e),
963 Input::Continuation(continuation) => format!("[Continuation] {}", continuation.reason),
964 Input::Operation(operation) => {
965 format!(
966 "[Operation {}] {:?}",
967 operation.operation_id, operation.event
968 )
969 }
970 }
971}
972
973fn external_event_projection_text(event: &ExternalEventInput) -> String {
974 let source_name = match &event.header.source {
975 InputOrigin::External { source_name } if !source_name.trim().is_empty() => {
976 source_name.as_str()
977 }
978 _ => event.event_type.as_str(),
979 };
980 let body = event
981 .payload
982 .get("body")
983 .and_then(serde_json::Value::as_str)
984 .map(str::trim);
985
986 meerkat_core::interaction::format_external_event_projection(source_name, body)
987}
988
989fn peer_notice_renderable(peer: &PeerInput) -> Option<CoreRenderable> {
990 let (peer_id, display_name) = match &peer.header.source {
991 InputOrigin::Peer {
992 peer_id,
993 display_identity,
994 ..
995 } => (peer_id.clone(), display_identity.clone()),
996 _ => return None,
997 };
998 use meerkat_core::types::CommsNoticeKind;
999 let (kind, request_id, intent, status) = match &peer.convention {
1000 Some(PeerConvention::Message) | None => (CommsNoticeKind::Message, None, None, None),
1001 Some(PeerConvention::Request { request_id, intent }) => (
1002 CommsNoticeKind::Request,
1003 Some(request_id.clone()),
1004 Some(intent.clone()),
1005 None,
1006 ),
1007 Some(PeerConvention::ResponseProgress { request_id, phase }) => (
1008 CommsNoticeKind::ResponseProgress,
1009 Some(request_id.clone()),
1010 None,
1011 Some(format!("{phase:?}")),
1012 ),
1013 Some(PeerConvention::ResponseTerminal { request_id, status }) => (
1014 CommsNoticeKind::ResponseTerminal,
1015 Some(request_id.clone()),
1016 None,
1017 Some(status.label().to_owned()),
1018 ),
1019 };
1020 let summary = match kind {
1021 CommsNoticeKind::Request => intent.as_ref().map_or_else(
1022 || "Peer request".to_string(),
1023 |intent| format!("Peer request: {intent}"),
1024 ),
1025 CommsNoticeKind::ResponseProgress => "Peer response progress".to_string(),
1026 CommsNoticeKind::ResponseTerminal => "Peer response terminal".to_string(),
1027 CommsNoticeKind::Message | CommsNoticeKind::Other(_) => "Peer message".to_string(),
1028 };
1029 let content = match &peer.content {
1030 ContentInput::Text(body) if body.is_empty() => Vec::new(),
1031 ContentInput::Text(body) => {
1032 vec![meerkat_core::types::ContentBlock::Text { text: body.clone() }]
1033 }
1034 ContentInput::Blocks(blocks) => blocks.clone(),
1035 };
1036 let notice_peer = meerkat_core::comms::PeerId::parse(&peer_id)
1043 .ok()
1044 .map(|id| SystemNoticePeer { id, display_name });
1045 Some(CoreRenderable::SystemNotice {
1046 kind: SystemNoticeKind::Comms,
1047 body: Some(summary.clone()),
1048 blocks: vec![SystemNoticeBlock::Comms {
1049 kind,
1050 direction: SystemNoticeDirection::Incoming,
1051 peer: notice_peer,
1052 sender_taint: peer.sender_taint,
1056 request_id,
1057 intent,
1058 status,
1059 summary: Some(summary),
1060 payload: peer.payload.clone(),
1061 content,
1062 }],
1063 })
1064}
1065
1066fn external_event_notice_renderable(event: &ExternalEventInput) -> CoreRenderable {
1067 let source = match &event.header.source {
1068 InputOrigin::External { source_name } if !source_name.trim().is_empty() => {
1069 source_name.clone()
1070 }
1071 _ => event.event_type.clone(),
1072 };
1073 let body = event
1074 .payload
1075 .get("body")
1076 .and_then(serde_json::Value::as_str)
1077 .map(str::trim)
1078 .filter(|body| !body.is_empty())
1079 .map(ToOwned::to_owned);
1080 let summary = body.as_ref().map_or_else(
1081 || format!("External event via {source}"),
1082 std::clone::Clone::clone,
1083 );
1084 CoreRenderable::SystemNotice {
1085 kind: SystemNoticeKind::ExternalEvent,
1086 body: Some(summary.clone()),
1087 blocks: vec![SystemNoticeBlock::ExternalEvent {
1088 source,
1089 event_type: event.event_type.clone(),
1090 summary: Some(summary),
1091 body,
1092 payload: Some(event.payload.clone()),
1093 content: event.blocks.clone().unwrap_or_default(),
1094 }],
1095 }
1096}
1097
1098fn input_to_append(input: &Input) -> Option<ConversationAppend> {
1099 let (role, content) = match input {
1107 Input::Prompt(p)
1108 if !p.typed_turn_appends.is_empty()
1109 && match &p.content {
1110 ContentInput::Text(text) => text.trim().is_empty(),
1111 ContentInput::Blocks(blocks) => blocks.is_empty(),
1112 } =>
1113 {
1114 return None;
1115 }
1116 Input::Prompt(p) => match &p.content {
1117 ContentInput::Blocks(blocks) => (
1118 ConversationAppendRole::User,
1119 CoreRenderable::Blocks {
1120 blocks: blocks.clone(),
1121 },
1122 ),
1123 ContentInput::Text(_) => (
1124 ConversationAppendRole::User,
1125 CoreRenderable::Text {
1126 text: input_prompt_text(input),
1127 },
1128 ),
1129 },
1130 Input::Peer(p) => peer_notice_renderable(p)
1131 .map(|content| (ConversationAppendRole::SystemNotice, content))?,
1132 Input::FlowStep(f) => (
1133 ConversationAppendRole::SystemNotice,
1134 flow_step_run_renderable(f),
1135 ),
1136 Input::ExternalEvent(e) => (
1137 ConversationAppendRole::SystemNotice,
1138 external_event_notice_renderable(e),
1139 ),
1140 Input::Continuation(continuation) => return continuation.turn_append.clone(),
1141 Input::Operation(_) => return None,
1142 };
1143
1144 Some(ConversationAppend {
1145 role,
1146 content,
1147 identity: None,
1148 })
1149}
1150
1151fn flow_step_run_renderable(flow_step: &FlowStepInput) -> CoreRenderable {
1152 CoreRenderable::SystemNotice {
1153 kind: SystemNoticeKind::Generic,
1154 body: Some(format!("Flow step {}", flow_step.step_id)),
1155 blocks: vec![SystemNoticeBlock::RuntimeNotice {
1156 category: "flow_step".to_string(),
1157 detail: Some(flow_step.content.text_content()),
1158 payload: None,
1159 }],
1160 }
1161}
1162
1163pub fn runtime_input_run_started_content(input: &Input) -> Option<ContentInput> {
1172 let projection = runtime_input_projection(input);
1173 let appends = projection
1174 .injected_context_appends
1175 .into_iter()
1176 .chain(projection.append)
1177 .chain(projection.additional_appends)
1178 .collect::<Vec<_>>();
1179 (!appends.is_empty()).then(|| {
1180 meerkat_core::lifecycle::run_primitive::model_projection_content_input_from_conversation_appends(
1181 &appends,
1182 )
1183 })
1184}
1185
1186pub fn directed_input_run_started_content(input: &Input) -> Result<ContentInput, String> {
1194 if validated_directed_interaction_id(input)?.is_none() {
1195 return Err("persisted runtime input does not carry directed interaction custody".into());
1196 }
1197 runtime_input_run_started_content(input)
1198 .ok_or_else(|| "directed runtime input has no turn-start projection".to_string())
1199}
1200
1201pub fn run_started_content_digest(content: &ContentInput) -> Result<String, String> {
1208 let mut canonical = content.clone();
1209 if let ContentInput::Blocks(blocks) = &mut canonical {
1210 for block in blocks {
1211 if let meerkat_core::types::ContentBlock::Image {
1212 media_type,
1213 data: ImageData::Inline { data },
1214 } = block
1215 {
1216 let canonical_media_type = media_type.clone();
1217 let blob_id = meerkat_core::blob::content_blob_id(media_type, data);
1218 *block = meerkat_core::types::ContentBlock::Image {
1219 media_type: canonical_media_type,
1220 data: ImageData::Blob { blob_id },
1221 };
1222 }
1223 }
1224 }
1225 let encoded = serde_json::to_vec(&canonical)
1226 .map_err(|error| format!("failed to encode canonical RunStarted content: {error}"))?;
1227 let mut digest = Sha256::new();
1228 digest.update(b"meerkat:run-started-content:v1\0");
1229 digest.update(encoded);
1230 Ok(format!("{:x}", digest.finalize()))
1231}
1232
1233pub fn directed_input_run_started_content_digest(input: &Input) -> Result<String, String> {
1234 directed_input_run_started_content(input)
1235 .and_then(|content| run_started_content_digest(&content))
1236}
1237
1238fn injected_context_appends(entries: &[ContentInput]) -> Vec<ConversationAppend> {
1242 entries
1243 .iter()
1244 .map(|entry| ConversationAppend {
1245 role: ConversationAppendRole::InjectedContext,
1246 content: match entry {
1247 ContentInput::Blocks(blocks) => CoreRenderable::Blocks {
1248 blocks: blocks.clone(),
1249 },
1250 ContentInput::Text(text) => CoreRenderable::Text { text: text.clone() },
1251 },
1252 identity: None,
1253 })
1254 .collect()
1255}
1256
1257pub(crate) fn runtime_input_projection(
1258 input: &Input,
1259) -> crate::ingress_types::RuntimeInputProjection {
1260 crate::ingress_types::RuntimeInputProjection {
1261 injected_context_appends: match input {
1262 Input::Prompt(prompt) => injected_context_appends(&prompt.injected_context),
1263 Input::Peer(peer) => injected_context_appends(&peer.injected_context),
1264 _ => Vec::new(),
1265 },
1266 append: input_to_append(input),
1267 additional_appends: match input {
1268 Input::Prompt(prompt) => prompt.typed_turn_appends.clone(),
1269 _ => Vec::new(),
1270 },
1271 }
1272}
1273
1274pub(crate) fn runtime_input_projection_for_machine_batch(
1275 input: &Input,
1276) -> crate::ingress_types::RuntimeInputProjection {
1277 runtime_input_projection(input)
1278}
1279
1280pub(crate) fn projection_to_transient_turn_context(
1289 projection: &crate::ingress_types::RuntimeInputProjection,
1290 semantics: crate::ingress_types::RuntimeInputSemantics,
1291) -> Option<meerkat_core::lifecycle::run_primitive::TurnRequestContext> {
1292 if !semantics.live_interrupt_required
1293 || semantics.peer_response_terminal_apply_intent.is_some()
1294 || semantics.execution_handling_mode == Some(HandlingMode::Queue)
1295 {
1296 return None;
1297 }
1298
1299 let rendered = projection
1300 .append
1301 .as_ref()
1302 .map(|append| append.content.render_text())?;
1303 meerkat_core::lifecycle::run_primitive::TurnRequestContext::new(rendered).ok()
1306}
1307
1308pub(crate) fn input_to_transient_turn_context(
1309 input: &Input,
1310 semantics: crate::ingress_types::RuntimeInputSemantics,
1311) -> Option<meerkat_core::lifecycle::run_primitive::TurnRequestContext> {
1312 projection_to_transient_turn_context(
1313 &runtime_input_projection_for_machine_batch(input),
1314 semantics,
1315 )
1316}
1317
1318pub(crate) fn projection_has_transient_turn_context(
1319 projection: &crate::ingress_types::RuntimeInputProjection,
1320 semantics: crate::ingress_types::RuntimeInputSemantics,
1321) -> bool {
1322 projection_to_transient_turn_context(projection, semantics).is_some()
1323}
1324
1325pub(crate) fn projection_conversation_appends(
1326 projection: &crate::ingress_types::RuntimeInputProjection,
1327 semantics: crate::ingress_types::RuntimeInputSemantics,
1328) -> Vec<ConversationAppend> {
1329 if projection_has_transient_turn_context(projection, semantics) {
1330 return Vec::new();
1331 }
1332 projection
1333 .injected_context_appends
1334 .clone()
1335 .into_iter()
1336 .chain(projection.append.clone())
1337 .chain(projection.additional_appends.clone())
1338 .collect()
1339}
1340
1341#[cfg(test)]
1342fn projection_transient_context_text(
1343 projection: &crate::ingress_types::RuntimeInputProjection,
1344 semantics: crate::ingress_types::RuntimeInputSemantics,
1345) -> Option<String> {
1346 projection_to_transient_turn_context(projection, semantics)
1347 .map(|context| context.as_str().to_owned())
1348}
1349
1350#[cfg(test)]
1351fn live_steer_semantics() -> crate::ingress_types::RuntimeInputSemantics {
1352 crate::ingress_types::RuntimeInputSemantics {
1353 boundary: meerkat_core::lifecycle::run_primitive::RunApplyBoundary::RunCheckpoint,
1354 execution_kind: meerkat_core::lifecycle::RuntimeExecutionKind::ContentTurn,
1355 execution_handling_mode: None,
1356 peer_response_terminal_apply_intent: None,
1357 live_interrupt_required: true,
1358 }
1359}
1360
1361#[cfg(test)]
1362fn terminal_semantics() -> crate::ingress_types::RuntimeInputSemantics {
1363 crate::ingress_types::RuntimeInputSemantics {
1364 boundary: meerkat_core::lifecycle::run_primitive::RunApplyBoundary::RunStart,
1365 execution_kind: meerkat_core::lifecycle::RuntimeExecutionKind::ContentTurn,
1366 execution_handling_mode: None,
1367 peer_response_terminal_apply_intent: Some(
1368 meerkat_core::lifecycle::run_primitive::PeerResponseTerminalApplyIntent::AppendContentAndRun,
1369 ),
1370 live_interrupt_required: true,
1371 }
1372}
1373
1374#[cfg(test)]
1375fn projection_durable_notice_count(
1376 projection: &crate::ingress_types::RuntimeInputProjection,
1377 semantics: crate::ingress_types::RuntimeInputSemantics,
1378) -> usize {
1379 projection_conversation_appends(projection, semantics)
1380 .iter()
1381 .filter(|append| append.role == ConversationAppendRole::SystemNotice)
1382 .count()
1383}
1384
1385#[cfg(test)]
1386#[allow(clippy::unwrap_used, clippy::panic)]
1387mod tests {
1388 use super::*;
1389 use chrono::Utc;
1390
1391 fn make_header() -> InputHeader {
1392 InputHeader {
1393 id: InputId::new(),
1394 timestamp: Utc::now(),
1395 source: InputOrigin::Operator,
1396 durability: InputDurability::Durable,
1397 visibility: InputVisibility::default(),
1398 idempotency_key: None,
1399 supersession_key: None,
1400 correlation_id: None,
1401 }
1402 }
1403
1404 fn typed_runtime_notice_append(detail: &str) -> ConversationAppend {
1405 ConversationAppend {
1406 role: ConversationAppendRole::SystemNotice,
1407 content: CoreRenderable::SystemNotice {
1408 kind: meerkat_core::types::SystemNoticeKind::Generic,
1409 body: Some(detail.to_string()),
1410 blocks: vec![meerkat_core::types::SystemNoticeBlock::RuntimeNotice {
1411 category: "test".to_string(),
1412 detail: Some(detail.to_string()),
1413 payload: None,
1414 }],
1415 },
1416 identity: None,
1417 }
1418 }
1419
1420 #[test]
1421 fn prompt_input_serde() {
1422 let input = Input::Prompt(PromptInput {
1423 injected_context: Vec::new(),
1424 header: make_header(),
1425 content: "hello".into(),
1426 typed_turn_appends: Vec::new(),
1427 turn_metadata: None,
1428 });
1429 let json = serde_json::to_value(&input).unwrap();
1430 assert_eq!(json["input_type"], "prompt");
1431 let parsed: Input = serde_json::from_value(json).unwrap();
1432 assert!(matches!(parsed, Input::Prompt(_)));
1433 }
1434
1435 #[test]
1436 fn prompt_input_typed_turn_appends_project_without_user_text() {
1437 let append = typed_runtime_notice_append("peer delivery");
1438 let input = Input::Prompt(PromptInput {
1439 injected_context: Vec::new(),
1440 header: make_header(),
1441 content: ContentInput::Text(String::new()),
1442 typed_turn_appends: vec![append.clone()],
1443 turn_metadata: None,
1444 });
1445
1446 let projection = runtime_input_projection(&input);
1447 assert!(
1448 projection.append.is_none(),
1449 "empty runtime-authored prompt carrier must not synthesize a user append"
1450 );
1451 assert_eq!(projection.additional_appends, vec![append]);
1452 }
1453
1454 #[test]
1459 fn prompt_input_injected_context_projects_before_user_append() {
1460 let input = Input::Prompt(PromptInput {
1461 injected_context: vec![
1462 ContentInput::Text("ambient alpha".to_string()),
1463 ContentInput::Text("ambient beta".to_string()),
1464 ],
1465 header: make_header(),
1466 content: "the prompt".into(),
1467 typed_turn_appends: Vec::new(),
1468 turn_metadata: None,
1469 });
1470
1471 let projection = runtime_input_projection(&input);
1472 assert_eq!(projection.injected_context_appends.len(), 2);
1473 assert!(
1474 projection
1475 .injected_context_appends
1476 .iter()
1477 .all(|append| { append.role == ConversationAppendRole::InjectedContext })
1478 );
1479 assert_eq!(
1480 projection.injected_context_appends[0].content,
1481 CoreRenderable::Text {
1482 text: "ambient alpha".to_string()
1483 }
1484 );
1485 assert_eq!(
1486 projection.injected_context_appends[1].content,
1487 CoreRenderable::Text {
1488 text: "ambient beta".to_string()
1489 }
1490 );
1491 assert!(
1492 projection.additional_appends.is_empty(),
1493 "injected context must not ride the generic typed_turn_appends carrier"
1494 );
1495 assert!(projection.append.is_some(), "user append must survive");
1496 }
1497
1498 #[test]
1501 fn peer_input_injected_context_projects_before_peer_append() {
1502 let mut header = make_header();
1503 header.source = InputOrigin::Peer {
1504 peer_id: "peer-1".into(),
1505 display_identity: Some("Peer One".into()),
1506 runtime_id: None,
1507 };
1508 let input = Input::Peer(PeerInput {
1509 directed_interaction_id: None,
1510 objective_id: None,
1511 system_prompts: Vec::new(),
1512 injected_context: vec![ContentInput::Text("supervisor ambient".to_string())],
1513 sender_taint: None,
1514 header,
1515 convention: Some(PeerConvention::Message),
1516 content: "work content".into(),
1517 payload: None,
1518 handling_mode: None,
1519 });
1520
1521 let projection = runtime_input_projection(&input);
1522 assert_eq!(projection.injected_context_appends.len(), 1);
1523 assert_eq!(
1524 projection.injected_context_appends[0].role,
1525 ConversationAppendRole::InjectedContext
1526 );
1527 assert!(
1528 projection.append.is_some(),
1529 "peer work append must survive alongside injected context"
1530 );
1531 }
1532
1533 #[test]
1536 fn prompt_input_injected_context_serde_default_and_omission() {
1537 let input = Input::Prompt(PromptInput {
1538 injected_context: vec![ContentInput::Text("ambient".to_string())],
1539 header: make_header(),
1540 content: "hello".into(),
1541 typed_turn_appends: Vec::new(),
1542 turn_metadata: None,
1543 });
1544 let json = serde_json::to_value(&input).unwrap();
1545 assert!(json.get("injected_context").is_some());
1546 let parsed: Input = serde_json::from_value(json).unwrap();
1547 let Input::Prompt(prompt) = parsed else {
1548 panic!("expected prompt input");
1549 };
1550 assert_eq!(prompt.injected_context.len(), 1);
1551
1552 let empty = Input::Prompt(PromptInput {
1553 injected_context: Vec::new(),
1554 header: make_header(),
1555 content: "hello".into(),
1556 typed_turn_appends: Vec::new(),
1557 turn_metadata: None,
1558 });
1559 let mut json = serde_json::to_value(&empty).unwrap();
1560 assert!(
1561 json.get("injected_context").is_none(),
1562 "empty injected context must be omitted on the wire"
1563 );
1564 json.as_object_mut().unwrap().remove("injected_context");
1566 let parsed: Input = serde_json::from_value(json).unwrap();
1567 let Input::Prompt(prompt) = parsed else {
1568 panic!("expected prompt input");
1569 };
1570 assert!(prompt.injected_context.is_empty());
1571 }
1572
1573 #[test]
1574 fn prompt_input_typed_turn_appends_serde_roundtrip() {
1575 let append = typed_runtime_notice_append("typed appends persist");
1576 let input = Input::Prompt(PromptInput {
1577 injected_context: Vec::new(),
1578 header: make_header(),
1579 content: ContentInput::Text(String::new()),
1580 typed_turn_appends: vec![append.clone()],
1581 turn_metadata: None,
1582 });
1583
1584 let json = serde_json::to_value(&input).unwrap();
1585 let parsed: Input = serde_json::from_value(json).unwrap();
1586 let Input::Prompt(prompt) = parsed else {
1587 panic!("expected prompt input");
1588 };
1589 assert_eq!(prompt.content.text_content(), "");
1590 assert_eq!(prompt.typed_turn_appends, vec![append]);
1591 }
1592
1593 #[test]
1594 fn peer_input_message_serde() {
1595 let input = Input::Peer(PeerInput {
1596 directed_interaction_id: None,
1597 objective_id: None,
1598 system_prompts: Vec::new(),
1599 injected_context: Vec::new(),
1600 sender_taint: None,
1601 header: make_header(),
1602 convention: Some(PeerConvention::Message),
1603 content: "hi there".into(),
1604 payload: None,
1605 handling_mode: None,
1606 });
1607 let json = serde_json::to_value(&input).unwrap();
1608 assert_eq!(json["input_type"], "peer");
1609 let parsed: Input = serde_json::from_value(json).unwrap();
1610 assert!(matches!(parsed, Input::Peer(_)));
1611 }
1612
1613 fn peer_input_with(
1614 peer_id: &str,
1615 convention: Option<PeerConvention>,
1616 correlation_id: Option<CorrelationId>,
1617 ) -> Input {
1618 let mut header = make_header();
1619 header.source = InputOrigin::Peer {
1620 peer_id: peer_id.into(),
1621 display_identity: Some(" display-agent ".into()),
1622 runtime_id: None,
1623 };
1624 header.correlation_id = correlation_id;
1625 Input::Peer(PeerInput {
1626 directed_interaction_id: None,
1627 objective_id: None,
1628 system_prompts: Vec::new(),
1629 injected_context: Vec::new(),
1630 sender_taint: None,
1631 header,
1632 convention,
1633 content: "hi there".into(),
1634 payload: None,
1635 handling_mode: None,
1636 })
1637 }
1638
1639 #[test]
1643 fn non_message_conventions_mint_no_reply_capability() {
1644 let peer_id = "018f6f79-7a82-7c4e-a552-a3b86f963005";
1645 let correlation = CorrelationId::from_uuid(uuid::Uuid::from_u128(9));
1646
1647 let message = peer_input_with(
1648 peer_id,
1649 Some(PeerConvention::Message),
1650 Some(correlation.clone()),
1651 );
1652 let capability = peer_reply_capability(&message)
1653 .expect("message convention with correlation must mint a capability");
1654 assert_eq!(
1655 capability.peer_id,
1656 meerkat_core::comms::PeerId::parse(peer_id).expect("canonical id")
1657 );
1658 assert_eq!(
1659 capability.in_reply_to,
1660 meerkat_core::InteractionId(uuid::Uuid::from_u128(9))
1661 );
1662 assert_eq!(
1663 capability.display_name.as_deref(),
1664 Some("display-agent"),
1665 "display identity must be trimmed"
1666 );
1667 assert_eq!(
1668 capability.kind,
1669 meerkat_core::comms::PeerReplyDeliveryKind::Message
1670 );
1671
1672 let bare = peer_input_with(peer_id, None, Some(correlation.clone()));
1673 assert!(
1674 peer_reply_capability(&bare).is_some(),
1675 "bare peer input groups as PeerMessage and must mint"
1676 );
1677
1678 let request = peer_input_with(
1679 peer_id,
1680 Some(PeerConvention::Request {
1681 request_id: "req-1".into(),
1682 intent: "review".into(),
1683 }),
1684 Some(correlation.clone()),
1685 );
1686 assert!(peer_reply_capability(&request).is_none());
1687
1688 let progress = peer_input_with(
1689 peer_id,
1690 Some(PeerConvention::ResponseProgress {
1691 request_id: "req-1".into(),
1692 phase: ResponseProgressPhase::Accepted,
1693 }),
1694 Some(correlation.clone()),
1695 );
1696 assert!(peer_reply_capability(&progress).is_none());
1697
1698 let terminal = peer_input_with(
1699 peer_id,
1700 Some(PeerConvention::ResponseTerminal {
1701 request_id: "req-1".into(),
1702 status: ResponseTerminalStatus::Completed,
1703 }),
1704 Some(correlation),
1705 );
1706 assert!(peer_reply_capability(&terminal).is_none());
1707
1708 let no_correlation = peer_input_with(peer_id, Some(PeerConvention::Message), None);
1709 assert!(
1710 peer_reply_capability(&no_correlation).is_none(),
1711 "a delivery without a correlation id has no reply selector"
1712 );
1713
1714 let prompt = Input::Prompt(PromptInput::new("hello", None));
1715 assert!(peer_reply_capability(&prompt).is_none());
1716 }
1717
1718 #[test]
1719 fn non_canonical_peer_id_mints_no_reply_capability() {
1720 let input = peer_input_with(
1721 "peer-1",
1722 Some(PeerConvention::Message),
1723 Some(CorrelationId::from_uuid(uuid::Uuid::from_u128(9))),
1724 );
1725 assert!(
1726 peer_reply_capability(&input).is_none(),
1727 "a non-canonical peer id must fail the mint, never smuggle a raw string"
1728 );
1729 }
1730
1731 #[test]
1732 fn peer_message_blocks_preserve_typed_comms_content_without_prefix_injection() {
1733 let peer_id = "018f6f79-7a82-7c4e-a552-a3b86f963005";
1734 let mut header = make_header();
1735 header.source = InputOrigin::Peer {
1736 peer_id: peer_id.into(),
1737 display_identity: Some("display-agent".into()),
1738 runtime_id: None,
1739 };
1740 let input = Input::Peer(PeerInput {
1741 directed_interaction_id: None,
1742 objective_id: None,
1743 system_prompts: Vec::new(),
1744 injected_context: Vec::new(),
1745 sender_taint: None,
1746 header,
1747 convention: Some(PeerConvention::Message),
1748 content: ContentInput::Blocks(vec![
1749 meerkat_core::types::ContentBlock::Text {
1750 text: "caption".into(),
1751 },
1752 meerkat_core::types::ContentBlock::Image {
1753 media_type: "image/png".into(),
1754 data: "abc".into(),
1755 },
1756 ]),
1757 payload: None,
1758 handling_mode: None,
1759 });
1760
1761 let Input::Peer(peer) = &input else {
1762 panic!("expected peer input");
1763 };
1764 assert_eq!(
1765 peer_projection_from_peer_input(peer)
1766 .and_then(|projection| projection.block_prefix_text())
1767 .as_deref(),
1768 Some(format!("Peer message from {peer_id}").as_str())
1769 );
1770
1771 let projection = runtime_input_projection(&input);
1772 let append = projection.append.expect("conversation append");
1773 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1774 panic!("expected typed system notice");
1775 };
1776 let Some(meerkat_core::types::SystemNoticeBlock::Comms { content, peer, .. }) =
1777 blocks.first()
1778 else {
1779 panic!("expected comms block");
1780 };
1781 assert_eq!(
1782 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1783 Some("display-agent")
1784 );
1785 assert_eq!(
1786 content.first(),
1787 Some(&meerkat_core::types::ContentBlock::Text {
1788 text: "caption".into()
1789 })
1790 );
1791 }
1792
1793 #[test]
1800 fn peer_message_sender_taint_reaches_typed_comms_notice_and_model_projection() {
1801 use meerkat_core::comms::SenderContentTaint;
1802
1803 let notice_block = |declared: Option<SenderContentTaint>| {
1804 let mut header = make_header();
1805 header.source = InputOrigin::Peer {
1806 peer_id: "018f6f79-7a82-7c4e-a552-a3b86f963005".into(),
1807 display_identity: Some("display-agent".into()),
1808 runtime_id: None,
1809 };
1810 let input = Input::Peer(PeerInput {
1811 directed_interaction_id: None,
1812 objective_id: None,
1813 system_prompts: Vec::new(),
1814 injected_context: Vec::new(),
1815 sender_taint: declared,
1816 header,
1817 convention: Some(PeerConvention::Message),
1818 content: "hello from peer".into(),
1819 payload: None,
1820 handling_mode: None,
1821 });
1822 let projection = runtime_input_projection(&input);
1823 let append = projection.append.expect("conversation append");
1824 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1825 panic!("expected typed system notice");
1826 };
1827 blocks.first().cloned().expect("comms block")
1828 };
1829
1830 let tainted_block = notice_block(Some(SenderContentTaint::Tainted));
1831 let clean_block = notice_block(Some(SenderContentTaint::Clean));
1832 let undeclared_block = notice_block(None);
1833
1834 let taint_of = |block: &meerkat_core::types::SystemNoticeBlock| {
1835 let meerkat_core::types::SystemNoticeBlock::Comms { sender_taint, .. } = block else {
1836 panic!("expected comms block");
1837 };
1838 *sender_taint
1839 };
1840 assert_eq!(taint_of(&tainted_block), Some(SenderContentTaint::Tainted));
1841 assert_eq!(taint_of(&clean_block), Some(SenderContentTaint::Clean));
1842 assert_eq!(
1843 taint_of(&undeclared_block),
1844 None,
1845 "no declaration must stay None in the transcript, never coalesced into Clean"
1846 );
1847
1848 let tainted_text = tainted_block.model_projection_text();
1849 let clean_text = clean_block.model_projection_text();
1850 let undeclared_text = undeclared_block.model_projection_text();
1851 assert!(
1852 tainted_text.contains("[sender declared this content tainted]"),
1853 "declared taint must be model-visible: {tainted_text}"
1854 );
1855 assert_eq!(
1856 clean_text, undeclared_text,
1857 "Clean and no-declaration deliberately render identically; the typed field is the carrier"
1858 );
1859 assert!(!clean_text.contains("tainted"));
1860 }
1861
1862 #[test]
1863 fn peer_response_terminal_projects_one_durable_notice_without_sidecar_context() {
1864 let route_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f2";
1865 let request_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f1";
1866 let mut header = make_header();
1867 header.source = InputOrigin::Peer {
1868 peer_id: route_id.into(),
1869 display_identity: Some("display-agent".into()),
1870 runtime_id: None,
1871 };
1872 let input = Input::Peer(PeerInput {
1873 directed_interaction_id: None,
1874 objective_id: None,
1875 system_prompts: Vec::new(),
1876 injected_context: Vec::new(),
1877 sender_taint: None,
1878 header,
1879 convention: Some(PeerConvention::ResponseTerminal {
1880 request_id: request_id.into(),
1881 status: ResponseTerminalStatus::Completed,
1882 }),
1883 content: "response body".into(),
1884 payload: Some(serde_json::json!({"answer":"ok"})),
1885 handling_mode: None,
1886 });
1887
1888 let Input::Peer(peer) = &input else {
1889 panic!("expected peer input");
1890 };
1891 assert!(
1892 peer_projection_from_peer_input(peer).is_none(),
1893 "terminal peer response projection must not be built before machine batch selection"
1894 );
1895
1896 let projection = runtime_input_projection_for_machine_batch(&input);
1897 assert_eq!(
1898 projection_durable_notice_count(&projection, terminal_semantics()),
1899 1
1900 );
1901 let CoreRenderable::SystemNotice { blocks, .. } =
1902 projection.append.expect("durable notice").content
1903 else {
1904 panic!("expected typed notice");
1905 };
1906 let Some(meerkat_core::types::SystemNoticeBlock::Comms { peer, .. }) = blocks.first()
1907 else {
1908 panic!("expected comms block");
1909 };
1910 assert_eq!(
1911 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1912 Some("display-agent")
1913 );
1914 assert_eq!(
1915 peer.as_ref().map(|peer| peer.id),
1916 Some(meerkat_core::comms::PeerId::parse(route_id).expect("valid route id"))
1917 );
1918 }
1919
1920 #[test]
1921 fn live_steer_projects_ordinary_append_as_request_only_user_context() {
1922 let projection = crate::ingress_types::RuntimeInputProjection {
1923 injected_context_appends: Vec::new(),
1924 append: Some(ConversationAppend {
1925 role: ConversationAppendRole::User,
1926 content: CoreRenderable::Text {
1927 text: "steer at the active turn".into(),
1928 },
1929 identity: None,
1930 }),
1931 additional_appends: Vec::new(),
1932 };
1933
1934 assert_eq!(
1935 projection_transient_context_text(&projection, live_steer_semantics()).as_deref(),
1936 Some("steer at the active turn")
1937 );
1938 assert!(projection_conversation_appends(&projection, live_steer_semantics()).is_empty());
1939 }
1940
1941 #[test]
1942 fn continuation_projection_uses_ordinary_turn_append_for_request_context() {
1943 let input = Input::Continuation(ContinuationInput {
1944 header: make_header(),
1945 reason: "workgraph_attention".into(),
1946 continuation_kind: ContinuationKind::WorkgraphAttention,
1947 handling_mode: HandlingMode::Steer,
1948 request_id: Some("binding-1".into()),
1949 turn_tool_overlay: Some(TurnToolOverlay {
1950 allowed_tools: Some(vec!["workgraph_add_evidence".into()]),
1951 blocked_tools: None,
1952 dispatch_context: Default::default(),
1953 }),
1954 turn_append: Some(ConversationAppend {
1955 role: ConversationAppendRole::User,
1956 content: CoreRenderable::Text {
1957 text: "WorkGraph attention projection".into(),
1958 },
1959 identity: None,
1960 }),
1961 });
1962 let projection = runtime_input_projection_for_machine_batch(&input);
1963 assert_eq!(
1964 projection_transient_context_text(&projection, live_steer_semantics()).as_deref(),
1965 Some("WorkGraph attention projection")
1966 );
1967 let metadata = crate::runtime_loop::for_input(
1968 &input,
1969 crate::ingress_types::RuntimeInputSemantics {
1970 boundary: meerkat_core::lifecycle::run_primitive::RunApplyBoundary::RunStart,
1971 execution_kind: meerkat_core::lifecycle::RuntimeExecutionKind::ContentTurn,
1972 execution_handling_mode: None,
1973 peer_response_terminal_apply_intent: None,
1974 live_interrupt_required: false,
1975 },
1976 );
1977 assert_eq!(
1978 metadata
1979 .turn_tool_overlay
1980 .and_then(|overlay| overlay.allowed_tools),
1981 Some(vec!["workgraph_add_evidence".into()])
1982 );
1983 }
1984
1985 #[test]
1986 fn live_peer_steer_is_request_only_and_idle_normalization_is_durable() {
1987 let mut header = make_header();
1988 header.source = InputOrigin::Peer {
1989 peer_id: "peer-a".into(),
1990 display_identity: Some("Peer A".into()),
1991 runtime_id: None,
1992 };
1993 let input = Input::Peer(PeerInput {
1994 directed_interaction_id: None,
1995 objective_id: None,
1996 system_prompts: Vec::new(),
1997 injected_context: Vec::new(),
1998 sender_taint: None,
1999 header,
2000 convention: Some(PeerConvention::Message),
2001 content: "please look at this while you work".into(),
2002 payload: None,
2003 handling_mode: Some(HandlingMode::Steer),
2004 });
2005 let projection = runtime_input_projection(&input);
2006 let live_semantics =
2007 crate::ingress_types::RuntimeInputSemantics::try_from_generated_admission(
2008 &input, false,
2009 )
2010 .expect("running steer admission");
2011 let idle_semantics =
2012 crate::ingress_types::RuntimeInputSemantics::try_from_generated_admission(&input, true)
2013 .expect("idle steer admission");
2014
2015 let rendered = projection_transient_context_text(&projection, live_semantics).unwrap();
2016 assert!(
2017 rendered.contains("please look at this while you work"),
2018 "peer message should be renderable as request-only steer context: {rendered:?}"
2019 );
2020 assert!(projection_conversation_appends(&projection, live_semantics).is_empty());
2021 assert!(projection_transient_context_text(&projection, idle_semantics).is_none());
2022 assert_eq!(
2023 projection_conversation_appends(&projection, idle_semantics).len(),
2024 1
2025 );
2026 }
2027
2028 #[test]
2029 fn live_steer_refuses_empty_context_but_preserves_whitespace_exactly() {
2030 let whitespace_projection = crate::ingress_types::RuntimeInputProjection {
2031 injected_context_appends: Vec::new(),
2032 append: Some(ConversationAppend {
2033 role: ConversationAppendRole::User,
2034 content: CoreRenderable::Text { text: " ".into() },
2035 identity: None,
2036 }),
2037 additional_appends: Vec::new(),
2038 };
2039 assert_eq!(
2040 projection_transient_context_text(&whitespace_projection, live_steer_semantics())
2041 .as_deref(),
2042 Some(" ")
2043 );
2044
2045 let append_projection = crate::ingress_types::RuntimeInputProjection {
2046 injected_context_appends: Vec::new(),
2047 append: Some(ConversationAppend {
2048 role: ConversationAppendRole::SystemNotice,
2049 content: CoreRenderable::Text {
2050 text: String::new(),
2051 },
2052 identity: None,
2053 }),
2054 additional_appends: Vec::new(),
2055 };
2056 assert!(
2057 projection_transient_context_text(&append_projection, live_steer_semantics()).is_none()
2058 );
2059 }
2060
2061 #[test]
2062 fn peer_response_terminal_with_blocks_projects_single_durable_notice() {
2063 let route_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f2";
2064 let request_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f1";
2065 let mut header = make_header();
2066 header.source = InputOrigin::Peer {
2067 peer_id: route_id.into(),
2068 display_identity: Some("display-agent".into()),
2069 runtime_id: None,
2070 };
2071 let input = Input::Peer(PeerInput {
2072 directed_interaction_id: None,
2073 objective_id: None,
2074 system_prompts: Vec::new(),
2075 injected_context: Vec::new(),
2076 sender_taint: None,
2077 header,
2078 convention: Some(PeerConvention::ResponseTerminal {
2079 request_id: request_id.into(),
2080 status: ResponseTerminalStatus::Completed,
2081 }),
2082 content: ContentInput::Blocks(vec![meerkat_core::types::ContentBlock::Image {
2083 media_type: "image/jpeg".into(),
2084 data: "abc".into(),
2085 }]),
2086 payload: Some(serde_json::json!({"answer":"ok"})),
2087 handling_mode: None,
2088 });
2089
2090 let projection = runtime_input_projection_for_machine_batch(&input);
2091 let append = projection.append.expect("conversation append");
2092 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
2093 panic!("expected typed append");
2094 };
2095 let Some(meerkat_core::types::SystemNoticeBlock::Comms { content, peer, .. }) =
2096 blocks.first()
2097 else {
2098 panic!("expected comms block");
2099 };
2100 assert_eq!(
2101 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
2102 Some("display-agent")
2103 );
2104 assert!(matches!(
2105 content.first(),
2106 Some(meerkat_core::types::ContentBlock::Image { media_type, .. })
2107 if media_type == "image/jpeg"
2108 ));
2109 }
2110
2111 #[test]
2112 fn peer_input_request_serde() {
2113 let input = Input::Peer(PeerInput {
2114 directed_interaction_id: None,
2115 objective_id: None,
2116 system_prompts: Vec::new(),
2117 injected_context: Vec::new(),
2118 sender_taint: None,
2119 header: make_header(),
2120 convention: Some(PeerConvention::Request {
2121 request_id: "req-1".into(),
2122 intent: "mob.peer_added".into(),
2123 }),
2124 content: "Agent joined".into(),
2125 payload: Some(serde_json::json!({"name": "agent-1"})),
2126 handling_mode: None,
2127 });
2128 let json = serde_json::to_value(&input).unwrap();
2129 let parsed: Input = serde_json::from_value(json).unwrap();
2130 if let Input::Peer(p) = parsed {
2131 assert!(matches!(p.convention, Some(PeerConvention::Request { .. })));
2132 } else {
2133 panic!("Expected PeerInput");
2134 }
2135 }
2136
2137 #[test]
2138 fn peer_input_response_terminal_serde() {
2139 let input = Input::Peer(PeerInput {
2140 directed_interaction_id: None,
2141 objective_id: None,
2142 system_prompts: Vec::new(),
2143 injected_context: Vec::new(),
2144 sender_taint: None,
2145 header: make_header(),
2146 convention: Some(PeerConvention::ResponseTerminal {
2147 request_id: "req-1".into(),
2148 status: ResponseTerminalStatus::Completed,
2149 }),
2150 content: "Done".into(),
2151 payload: Some(serde_json::json!({"ok": true})),
2152 handling_mode: None,
2153 });
2154 let json = serde_json::to_value(&input).unwrap();
2155 let parsed: Input = serde_json::from_value(json).unwrap();
2156 assert!(matches!(parsed, Input::Peer(_)));
2157 }
2158
2159 #[test]
2160 fn peer_input_response_progress_serde() {
2161 let input = Input::Peer(PeerInput {
2162 directed_interaction_id: None,
2163 objective_id: None,
2164 system_prompts: Vec::new(),
2165 injected_context: Vec::new(),
2166 sender_taint: None,
2167 header: make_header(),
2168 convention: Some(PeerConvention::ResponseProgress {
2169 request_id: "req-1".into(),
2170 phase: ResponseProgressPhase::InProgress,
2171 }),
2172 content: "Working...".into(),
2173 payload: Some(serde_json::json!({"progress": "working"})),
2174 handling_mode: None,
2175 });
2176 let json = serde_json::to_value(&input).unwrap();
2177 let parsed: Input = serde_json::from_value(json).unwrap();
2178 assert!(matches!(parsed, Input::Peer(_)));
2179 }
2180
2181 #[test]
2182 fn flow_step_input_serde() {
2183 let input = Input::FlowStep(FlowStepInput {
2184 header: make_header(),
2185 step_id: "step-1".into(),
2186 content: ContentInput::Blocks(vec![
2187 meerkat_core::types::ContentBlock::Text {
2188 text: "analyze the data".into(),
2189 },
2190 meerkat_core::types::ContentBlock::Image {
2191 media_type: "image/png".into(),
2192 data: meerkat_core::types::ImageData::Inline {
2193 data: "abc123".into(),
2194 },
2195 },
2196 ]),
2197 directed_interaction_id: None,
2198 turn_metadata: None,
2199 });
2200 let json = serde_json::to_value(&input).unwrap();
2201 assert_eq!(json["input_type"], "flow_step");
2202 let parsed: Input = serde_json::from_value(json).unwrap();
2203 assert!(matches!(parsed, Input::FlowStep(_)));
2204 }
2205
2206 #[test]
2207 fn flow_step_uses_the_canonical_runtime_run_started_projection() {
2208 let flow_step = FlowStepInput {
2209 header: make_header(),
2210 step_id: "step-1".into(),
2211 content: ContentInput::Text("go\n\"quoted\" \\ path".into()),
2212 directed_interaction_id: None,
2213 turn_metadata: None,
2214 };
2215 let input = Input::FlowStep(flow_step);
2216 let projected = runtime_input_projection(&input)
2217 .append
2218 .expect("flow step projects a run append")
2219 .content
2220 .render_text();
2221
2222 assert_eq!(projected, "Flow step step-1\ngo\n\"quoted\" \\ path");
2223 assert_eq!(
2224 runtime_input_run_started_content(&input)
2225 .expect("flow step starts a model-visible run"),
2226 ContentInput::Text(projected),
2227 );
2228 }
2229
2230 #[test]
2231 fn multimodal_flow_step_uses_the_canonical_runtime_run_started_projection() {
2232 let flow_step = FlowStepInput {
2233 header: make_header(),
2234 step_id: "vision-step".into(),
2235 content: ContentInput::Blocks(vec![
2236 meerkat_core::types::ContentBlock::Text {
2237 text: "inspect this\nimage".into(),
2238 },
2239 meerkat_core::types::ContentBlock::Image {
2240 media_type: "image/png".into(),
2241 data: meerkat_core::types::ImageData::Inline {
2242 data: "abc123".into(),
2243 },
2244 },
2245 ]),
2246 directed_interaction_id: None,
2247 turn_metadata: None,
2248 };
2249 let input = Input::FlowStep(flow_step);
2250 let projected = runtime_input_projection(&input)
2251 .append
2252 .expect("multimodal flow step projects a run append")
2253 .content
2254 .render_text();
2255
2256 assert_eq!(
2257 runtime_input_run_started_content(&input)
2258 .expect("multimodal flow step starts a model-visible run"),
2259 ContentInput::Text(projected),
2260 );
2261 }
2262
2263 #[test]
2264 fn directed_peer_run_started_content_preserves_context_and_multimodal_projection() {
2265 let stable = uuid::Uuid::from_u128(0x00000000000040008000000000000123);
2266 let interaction_id = meerkat_core::interaction::InteractionId(stable);
2267 let input = Input::Peer(PeerInput {
2268 directed_interaction_id: Some(interaction_id),
2269 objective_id: Some(meerkat_core::interaction::ObjectiveId::new()),
2270 system_prompts: Vec::new(),
2271 injected_context: vec![ContentInput::Text("ambient context".to_string())],
2272 sender_taint: None,
2273 header: InputHeader {
2274 id: InputId::from_uuid(stable),
2275 timestamp: Utc::now(),
2276 source: InputOrigin::Peer {
2277 peer_id: uuid::Uuid::from_u128(7).to_string(),
2278 display_identity: Some("supervisor".to_string()),
2279 runtime_id: Some(LogicalRuntimeId::new("rt:session:placed")),
2280 },
2281 durability: InputDurability::Durable,
2282 visibility: InputVisibility::default(),
2283 idempotency_key: Some(IdempotencyKey::new(stable.to_string())),
2284 supersession_key: None,
2285 correlation_id: Some(CorrelationId::from_uuid(stable)),
2286 },
2287 convention: Some(PeerConvention::Message),
2288 content: ContentInput::Blocks(vec![
2289 meerkat_core::types::ContentBlock::Text {
2290 text: "inspect this image".to_string(),
2291 },
2292 meerkat_core::types::ContentBlock::Image {
2293 media_type: "image/png".to_string(),
2294 data: meerkat_core::types::ImageData::Inline {
2295 data: "abc123".to_string(),
2296 },
2297 },
2298 ]),
2299 payload: None,
2300 handling_mode: Some(meerkat_core::types::HandlingMode::Queue),
2301 });
2302
2303 let projection = runtime_input_projection(&input);
2304 let appends = projection
2305 .injected_context_appends
2306 .into_iter()
2307 .chain(projection.append)
2308 .chain(projection.additional_appends)
2309 .collect::<Vec<_>>();
2310 let expected = meerkat_core::lifecycle::run_primitive::model_projection_content_input_from_conversation_appends(
2311 &appends,
2312 );
2313 let actual = directed_input_run_started_content(&input)
2314 .expect("valid directed peer owns a turn-start projection");
2315
2316 assert_eq!(actual, expected);
2317 assert!(actual.text_content().contains("ambient context"));
2318 assert!(actual.text_content().contains("inspect this image"));
2319 assert!(
2320 matches!(actual, ContentInput::Blocks(ref blocks) if blocks.iter().any(|block| matches!(block, meerkat_core::types::ContentBlock::Image { .. })))
2321 );
2322 }
2323
2324 #[test]
2325 fn run_started_digest_is_invariant_to_inline_or_blob_image_representation() {
2326 let media_type = "image/png";
2327 let inline_data = "abc123";
2328 let inline = ContentInput::Blocks(vec![
2329 meerkat_core::types::ContentBlock::Text {
2330 text: "ambient context".to_string(),
2331 },
2332 meerkat_core::types::ContentBlock::Image {
2333 media_type: media_type.to_string(),
2334 data: meerkat_core::types::ImageData::Inline {
2335 data: inline_data.to_string(),
2336 },
2337 },
2338 ]);
2339 let blob_backed = ContentInput::Blocks(vec![
2340 meerkat_core::types::ContentBlock::Text {
2341 text: "ambient context".to_string(),
2342 },
2343 meerkat_core::types::ContentBlock::Image {
2344 media_type: media_type.to_string(),
2345 data: meerkat_core::types::ImageData::Blob {
2346 blob_id: meerkat_core::blob::content_blob_id(media_type, inline_data),
2347 },
2348 },
2349 ]);
2350
2351 assert_eq!(
2352 run_started_content_digest(&inline).expect("inline digest"),
2353 run_started_content_digest(&blob_backed).expect("blob-backed digest"),
2354 );
2355 }
2356
2357 #[test]
2358 fn external_event_input_serde() {
2359 let input = Input::ExternalEvent(ExternalEventInput {
2360 objective_id: None,
2361 header: make_header(),
2362 event_type: "webhook.received".into(),
2363 payload: serde_json::json!({"url": "https://example.com"}),
2364 blocks: Some(vec![
2365 meerkat_core::types::ContentBlock::Text {
2366 text: "look".into(),
2367 },
2368 meerkat_core::types::ContentBlock::Image {
2369 media_type: "image/png".into(),
2370 data: meerkat_core::types::ImageData::Inline {
2371 data: "abc123".into(),
2372 },
2373 },
2374 ]),
2375 handling_mode: HandlingMode::Queue,
2376 render_metadata: None,
2377 });
2378 let json = serde_json::to_value(&input).unwrap();
2379 assert_eq!(json["input_type"], "external_event");
2380 let parsed: Input = serde_json::from_value(json).unwrap();
2381 assert!(matches!(parsed, Input::ExternalEvent(_)));
2382 }
2383
2384 #[test]
2385 fn legacy_external_event_payload_blocks_are_rejected() {
2386 let event = ExternalEventInput {
2389 objective_id: None,
2390 header: make_header(),
2391 event_type: "webhook.received".into(),
2392 payload: serde_json::json!({
2393 "body": "see image",
2394 "blocks": [
2395 { "type": "text", "text": "caption text" },
2396 { "type": "image", "media_type": "image/png", "source": "inline", "data": "abc123" }
2397 ]
2398 }),
2399 blocks: None,
2400 handling_mode: HandlingMode::Queue,
2401 render_metadata: None,
2402 };
2403
2404 let err = reject_legacy_payload_blocks(&event)
2405 .expect_err("payload-level blocks must fail closed");
2406 assert!(matches!(err, BlobStoreError::Internal(_)));
2407 assert!(event.payload.get("blocks").is_some());
2409 assert!(event.blocks.is_none());
2410 }
2411
2412 #[test]
2413 fn external_event_payload_without_blocks_key_passes_rejection_gate() {
2414 let event = ExternalEventInput {
2415 objective_id: None,
2416 header: make_header(),
2417 event_type: "webhook.received".into(),
2418 payload: serde_json::json!({ "body": "plain payload" }),
2419 blocks: Some(vec![meerkat_core::types::ContentBlock::Text {
2420 text: "typed owner content".into(),
2421 }]),
2422 handling_mode: HandlingMode::Queue,
2423 render_metadata: None,
2424 };
2425
2426 reject_legacy_payload_blocks(&event)
2427 .expect("payload without a legacy blocks key must pass");
2428 }
2429
2430 #[test]
2431 fn continuation_input_serde() {
2432 let input = Input::Continuation(ContinuationInput::detached_background_op_completed());
2433 let json = serde_json::to_value(&input).unwrap();
2434 assert_eq!(json["input_type"], "continuation");
2435 let parsed: Input = serde_json::from_value(json).unwrap();
2436 match parsed {
2437 Input::Continuation(continuation) => {
2438 assert_eq!(continuation.handling_mode, HandlingMode::Steer);
2439 assert_eq!(continuation.reason, "detached_background_op_completed");
2440 }
2441 other => panic!("Expected Continuation, got {other:?}"),
2442 }
2443 }
2444
2445 #[test]
2446 fn continuation_input_rejects_legacy_system_generated_tag() {
2447 let input = Input::Continuation(ContinuationInput::detached_background_op_completed());
2450 let mut json = serde_json::to_value(&input).unwrap();
2451 json["input_type"] = serde_json::Value::String("system_generated".into());
2452 serde_json::from_value::<Input>(json)
2453 .expect_err("legacy system_generated input_type tag must be rejected");
2454 }
2455
2456 #[test]
2457 fn operation_input_serde() {
2458 let input = Input::Operation(OperationInput {
2459 header: InputHeader {
2460 durability: InputDurability::Derived,
2461 ..make_header()
2462 },
2463 operation_id: OperationId::new(),
2464 event: OpEvent::Cancelled {
2465 id: OperationId::new(),
2466 },
2467 });
2468 let json = serde_json::to_value(&input).unwrap();
2469 assert_eq!(json["input_type"], "operation");
2470 let parsed: Input = serde_json::from_value(json).unwrap();
2471 assert!(matches!(parsed, Input::Operation(_)));
2472 }
2473
2474 #[test]
2475 fn operation_input_rejects_legacy_projected_tag() {
2476 let input = Input::Operation(OperationInput {
2479 header: InputHeader {
2480 durability: InputDurability::Derived,
2481 ..make_header()
2482 },
2483 operation_id: OperationId::new(),
2484 event: OpEvent::Cancelled {
2485 id: OperationId::new(),
2486 },
2487 });
2488 let mut json = serde_json::to_value(&input).unwrap();
2489 json["input_type"] = serde_json::Value::String("projected".into());
2490 serde_json::from_value::<Input>(json)
2491 .expect_err("legacy projected input_type tag must be rejected");
2492 }
2493
2494 #[test]
2495 fn legacy_dual_carrier_input_shapes_are_rejected() {
2496 let header = serde_json::to_value(make_header()).unwrap();
2501
2502 let legacy_prompt = serde_json::json!({
2503 "input_type": "prompt",
2504 "header": header.clone(),
2505 "text": "hello",
2506 "blocks": null
2507 });
2508 serde_json::from_value::<Input>(legacy_prompt)
2509 .expect_err("legacy prompt text+blocks shape must be rejected");
2510
2511 let legacy_peer = serde_json::json!({
2512 "input_type": "peer",
2513 "header": header.clone(),
2514 "convention": { "convention_type": "message" },
2515 "body": "hi there"
2516 });
2517 serde_json::from_value::<Input>(legacy_peer)
2518 .expect_err("legacy peer body+blocks shape must be rejected");
2519
2520 let legacy_flow_step = serde_json::json!({
2521 "input_type": "flow_step",
2522 "header": header,
2523 "step_id": "step-1",
2524 "instructions": "analyze the data"
2525 });
2526 serde_json::from_value::<Input>(legacy_flow_step)
2527 .expect_err("legacy flow-step instructions+blocks shape must be rejected");
2528 }
2529
2530 #[test]
2531 fn input_kind_id() {
2532 let prompt = Input::Prompt(PromptInput {
2533 injected_context: Vec::new(),
2534 header: make_header(),
2535 content: "hi".into(),
2536 typed_turn_appends: Vec::new(),
2537 turn_metadata: None,
2538 });
2539 assert_eq!(prompt.kind(), InputKind::Prompt);
2540
2541 let peer_msg = Input::Peer(PeerInput {
2542 directed_interaction_id: None,
2543 objective_id: None,
2544 system_prompts: Vec::new(),
2545 injected_context: Vec::new(),
2546 sender_taint: None,
2547 header: make_header(),
2548 convention: Some(PeerConvention::Message),
2549 content: "hi".into(),
2550 payload: None,
2551 handling_mode: None,
2552 });
2553 assert_eq!(peer_msg.kind(), InputKind::PeerMessage);
2554
2555 let peer_req = Input::Peer(PeerInput {
2556 directed_interaction_id: None,
2557 objective_id: None,
2558 system_prompts: Vec::new(),
2559 injected_context: Vec::new(),
2560 sender_taint: None,
2561 header: make_header(),
2562 convention: Some(PeerConvention::Request {
2563 request_id: "r".into(),
2564 intent: "i".into(),
2565 }),
2566 content: "hi".into(),
2567 payload: Some(serde_json::json!({"subject": "x"})),
2568 handling_mode: None,
2569 });
2570 assert_eq!(peer_req.kind(), InputKind::PeerRequest);
2571
2572 let continuation = Input::Continuation(ContinuationInput {
2573 header: make_header(),
2574 reason: "continue".into(),
2575 continuation_kind: ContinuationKind::Ordinary,
2576 handling_mode: HandlingMode::Steer,
2577 request_id: None,
2578 turn_tool_overlay: None,
2579 turn_append: None,
2580 });
2581 assert_eq!(continuation.kind(), InputKind::Continuation);
2582
2583 let operation = Input::Operation(OperationInput {
2584 header: make_header(),
2585 operation_id: OperationId::new(),
2586 event: OpEvent::Cancelled {
2587 id: OperationId::new(),
2588 },
2589 });
2590 assert_eq!(operation.kind(), InputKind::Operation);
2591 }
2592
2593 #[test]
2594 fn input_source_variants() {
2595 let sources = vec![
2596 InputOrigin::Operator,
2597 InputOrigin::Peer {
2598 peer_id: "p1".into(),
2599 display_identity: None,
2600 runtime_id: None,
2601 },
2602 InputOrigin::Flow {
2603 flow_id: "f1".into(),
2604 step_index: 0,
2605 },
2606 InputOrigin::System,
2607 InputOrigin::External {
2608 source_name: "webhook".into(),
2609 },
2610 ];
2611 for source in sources {
2612 let json = serde_json::to_value(&source).unwrap();
2613 let parsed: InputOrigin = serde_json::from_value(json).unwrap();
2614 assert_eq!(source, parsed);
2615 }
2616 }
2617
2618 #[test]
2619 fn input_durability_serde() {
2620 for d in [
2621 InputDurability::Durable,
2622 InputDurability::Ephemeral,
2623 InputDurability::Derived,
2624 ] {
2625 let json = serde_json::to_value(d).unwrap();
2626 let parsed: InputDurability = serde_json::from_value(json).unwrap();
2627 assert_eq!(d, parsed);
2628 }
2629 }
2630
2631 #[test]
2632 fn peer_input_without_handling_mode_deserializes_as_none() {
2633 let json = serde_json::json!({
2635 "input_type": "peer",
2636 "header": serde_json::to_value(make_header()).unwrap(),
2637 "convention": { "convention_type": "message" },
2638 "content": "hello"
2639 });
2640 let parsed: Input = serde_json::from_value(json).unwrap();
2641 match parsed {
2642 Input::Peer(p) => assert!(p.handling_mode.is_none()),
2643 other => panic!("Expected Peer, got {other:?}"),
2644 }
2645 }
2646
2647 #[test]
2648 fn peer_input_with_queue_handling_mode_roundtrips() {
2649 let input = Input::Peer(PeerInput {
2650 directed_interaction_id: None,
2651 objective_id: None,
2652 system_prompts: Vec::new(),
2653 injected_context: Vec::new(),
2654 sender_taint: None,
2655 header: make_header(),
2656 convention: Some(PeerConvention::Message),
2657 content: "hi".into(),
2658 payload: None,
2659 handling_mode: Some(HandlingMode::Queue),
2660 });
2661 let json = serde_json::to_value(&input).unwrap();
2662 assert_eq!(json["handling_mode"], "queue");
2663 let parsed: Input = serde_json::from_value(json).unwrap();
2664 match parsed {
2665 Input::Peer(p) => assert_eq!(p.handling_mode, Some(HandlingMode::Queue)),
2666 other => panic!("Expected Peer, got {other:?}"),
2667 }
2668 }
2669
2670 #[test]
2671 fn peer_response_terminal_input_owns_wire_status_mapping() {
2672 let peer_id = meerkat_core::comms::PeerId::from_uuid(
2673 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000161").unwrap(),
2674 );
2675 let display_name = meerkat_core::comms::PeerName::new("analyst").unwrap();
2676 let request_id = meerkat_core::PeerCorrelationId::from_uuid(
2677 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap(),
2678 );
2679 let input = peer_response_terminal_input(
2680 peer_id,
2681 Some(display_name),
2682 request_id,
2683 meerkat_contracts::PeerResponseTerminalStatusWire::Completed,
2684 serde_json::json!({"ok": true}),
2685 );
2686
2687 match input {
2688 Input::Peer(PeerInput {
2689 header:
2690 InputHeader {
2691 source:
2692 InputOrigin::Peer {
2693 peer_id,
2694 display_identity,
2695 runtime_id,
2696 },
2697 durability: InputDurability::Durable,
2698 idempotency_key,
2699 correlation_id,
2700 ..
2701 },
2702 convention: Some(PeerConvention::ResponseTerminal { request_id, status }),
2703 payload: Some(payload),
2704 handling_mode: None,
2705 ..
2706 }) => {
2707 assert_eq!(peer_id, "00000000-0000-4000-8000-000000000161");
2708 assert_eq!(display_identity.as_deref(), Some("analyst"));
2709 assert_eq!(runtime_id, None);
2710 assert_eq!(request_id, "00000000-0000-4000-8000-000000000162");
2711 assert_eq!(
2712 correlation_id,
2713 Some(CorrelationId::from_uuid(
2714 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap()
2715 ))
2716 );
2717 assert_eq!(
2718 idempotency_key,
2719 Some(IdempotencyKey::new(
2720 "peer_response_terminal:00000000-0000-4000-8000-000000000161:\
2721 00000000-0000-4000-8000-000000000162"
2722 ))
2723 );
2724 assert_eq!(status, ResponseTerminalStatus::Completed);
2725 assert_eq!(payload["ok"], true);
2726 }
2727 other => panic!("expected terminal peer input, got {other:?}"),
2728 }
2729 }
2730
2731 #[test]
2732 fn absent_peer_directed_interaction_defaults_none_and_none_is_omitted() {
2733 let input = peer_response_terminal_input(
2734 meerkat_core::comms::PeerId::from_uuid(uuid::Uuid::new_v4()),
2735 None,
2736 meerkat_core::PeerCorrelationId::from_uuid(uuid::Uuid::new_v4()),
2737 meerkat_contracts::PeerResponseTerminalStatusWire::Completed,
2738 serde_json::json!({"ok": true}),
2739 );
2740 let encoded = serde_json::to_value(&input).expect("serialize ordinary peer input");
2741 assert!(
2742 encoded.get("directed_interaction_id").is_none(),
2743 "ordinary peer persistence must retain the pre-field wire shape"
2744 );
2745
2746 let decoded: Input = serde_json::from_value(encoded).expect("deserialize absent field");
2747 let Input::Peer(peer) = decoded else {
2748 panic!("peer input round-trips as peer input");
2749 };
2750 assert_eq!(peer.directed_interaction_id, None);
2751 }
2752
2753 #[test]
2754 fn peer_response_terminal_validation_is_structural_only() {
2755 let peer_id = meerkat_core::comms::PeerId::from_uuid(
2756 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000161").unwrap(),
2757 );
2758 let display_name = meerkat_core::comms::PeerName::new("analyst").unwrap();
2759 let request_id = meerkat_core::PeerCorrelationId::from_uuid(
2760 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap(),
2761 );
2762 let input = peer_response_terminal_input(
2763 peer_id,
2764 Some(display_name),
2765 request_id,
2766 meerkat_contracts::PeerResponseTerminalStatusWire::Cancelled,
2767 serde_json::json!({"ok": false}),
2768 );
2769
2770 validate_peer_response_terminal_fact(&input)
2771 .expect("status support is generated admission authority, structural fact validation should pass");
2772 }
2773
2774 #[test]
2775 fn peer_input_with_steer_handling_mode_roundtrips() {
2776 let input = Input::Peer(PeerInput {
2777 directed_interaction_id: None,
2778 objective_id: None,
2779 system_prompts: Vec::new(),
2780 injected_context: Vec::new(),
2781 sender_taint: None,
2782 header: make_header(),
2783 convention: Some(PeerConvention::Message),
2784 content: "hi".into(),
2785 payload: None,
2786 handling_mode: Some(HandlingMode::Steer),
2787 });
2788 let json = serde_json::to_value(&input).unwrap();
2789 assert_eq!(json["handling_mode"], "steer");
2790 let parsed: Input = serde_json::from_value(json).unwrap();
2791 match parsed {
2792 Input::Peer(p) => assert_eq!(p.handling_mode, Some(HandlingMode::Steer)),
2793 other => panic!("Expected Peer, got {other:?}"),
2794 }
2795 }
2796
2797 #[test]
2798 fn peer_input_handling_mode_not_serialized_when_none() {
2799 let input = Input::Peer(PeerInput {
2800 directed_interaction_id: None,
2801 objective_id: None,
2802 system_prompts: Vec::new(),
2803 injected_context: Vec::new(),
2804 sender_taint: None,
2805 header: make_header(),
2806 convention: Some(PeerConvention::Message),
2807 content: "hi".into(),
2808 payload: None,
2809 handling_mode: None,
2810 });
2811 let json = serde_json::to_value(&input).unwrap();
2812 assert!(json.get("handling_mode").is_none());
2813 }
2814}