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(skip_serializing_if = "Option::is_none")]
367 pub convention: Option<PeerConvention>,
368 pub content: ContentInput,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub payload: Option<serde_json::Value>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub handling_mode: Option<HandlingMode>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub sender_taint: Option<meerkat_core::comms::SenderContentTaint>,
396 #[serde(default, skip_serializing_if = "Vec::is_empty")]
406 pub injected_context: Vec<ContentInput>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
411#[serde(tag = "convention_type", rename_all = "snake_case")]
412#[non_exhaustive]
413pub enum PeerConvention {
414 Message,
416 Request { request_id: String, intent: String },
418 ResponseProgress {
420 request_id: String,
421 phase: ResponseProgressPhase,
422 },
423 ResponseTerminal {
425 request_id: String,
426 status: ResponseTerminalStatus,
427 },
428}
429
430pub type ResponseProgressPhase = PeerResponseProgressProjectionPhase;
433
434pub type ResponseTerminalStatus = PeerResponseTerminalProjectionStatus;
437
438pub fn response_terminal_status_from_wire(
439 status: meerkat_contracts::PeerResponseTerminalStatusWire,
440) -> ResponseTerminalStatus {
441 match status {
442 meerkat_contracts::PeerResponseTerminalStatusWire::Completed => {
443 PeerResponseTerminalProjectionStatus::Completed
444 }
445 meerkat_contracts::PeerResponseTerminalStatusWire::Failed => {
446 PeerResponseTerminalProjectionStatus::Failed
447 }
448 meerkat_contracts::PeerResponseTerminalStatusWire::Cancelled => {
449 PeerResponseTerminalProjectionStatus::Cancelled
450 }
451 }
452}
453
454pub fn peer_response_terminal_input(
455 peer_id: meerkat_core::comms::PeerId,
456 display_name: Option<meerkat_core::comms::PeerName>,
457 request_id: meerkat_core::PeerCorrelationId,
458 status: meerkat_contracts::PeerResponseTerminalStatusWire,
459 result: serde_json::Value,
460) -> Input {
461 let correlation_id = CorrelationId::from_uuid(request_id.as_uuid());
462 let request_id = request_id.to_string();
463 let peer_id = peer_id.to_string();
464 let display_identity = display_name.map_or_else(|| peer_id.clone(), |name| name.as_string());
465
466 Input::Peer(PeerInput {
467 injected_context: Vec::new(),
468 header: InputHeader {
469 id: InputId::new(),
470 timestamp: Utc::now(),
471 source: InputOrigin::Peer {
472 peer_id,
473 display_identity: Some(display_identity),
474 runtime_id: None,
475 },
476 durability: InputDurability::Durable,
477 visibility: InputVisibility::default(),
478 idempotency_key: None,
479 supersession_key: None,
480 correlation_id: Some(correlation_id),
481 },
482 convention: Some(PeerConvention::ResponseTerminal {
483 request_id,
484 status: response_terminal_status_from_wire(status),
485 }),
486 content: ContentInput::Text(String::new()),
487 payload: Some(result),
488 handling_mode: None,
489 sender_taint: None,
492 })
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct FlowStepInput {
498 pub header: InputHeader,
499 pub step_id: String,
501 pub content: ContentInput,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub turn_metadata: Option<RuntimeTurnMetadata>,
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct ExternalEventInput {
513 pub header: InputHeader,
514 pub event_type: String,
516 pub payload: serde_json::Value,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub blocks: Option<Vec<meerkat_core::types::ContentBlock>>,
524 #[serde(default)]
526 pub handling_mode: HandlingMode,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub render_metadata: Option<RenderMetadata>,
530}
531
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
542#[serde(rename_all = "snake_case")]
543pub enum ContinuationKind {
544 #[default]
546 Ordinary,
547 WorkgraphAttention,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct ContinuationInput {
556 pub header: InputHeader,
557 pub reason: String,
559 #[serde(default)]
563 pub continuation_kind: ContinuationKind,
564 #[serde(default)]
566 pub handling_mode: HandlingMode,
567 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub request_id: Option<String>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub turn_tool_overlay: Option<TurnToolOverlay>,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub context_append: Option<ConversationContextAppend>,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
578 pub turn_append: Option<ConversationAppend>,
579}
580
581impl ContinuationInput {
582 pub fn detached_background_op_completed() -> Self {
588 Self {
589 header: InputHeader {
590 id: meerkat_core::lifecycle::InputId::new(),
591 timestamp: chrono::Utc::now(),
592 source: InputOrigin::System,
593 durability: InputDurability::Derived,
594 visibility: InputVisibility {
595 transcript_eligible: false,
596 operator_eligible: false,
597 },
598 idempotency_key: None,
599 supersession_key: None,
600 correlation_id: None,
601 },
602 reason: "detached_background_op_completed".to_string(),
603 continuation_kind: ContinuationKind::Ordinary,
604 handling_mode: HandlingMode::Steer,
605 request_id: None,
606 turn_tool_overlay: None,
607 context_append: None,
608 turn_append: None,
609 }
610 }
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct OperationInput {
617 pub header: InputHeader,
618 pub operation_id: OperationId,
620 pub event: OpEvent,
622}
623
624pub(crate) fn peer_projection_from_peer_input(
631 peer: &PeerInput,
632) -> Option<PeerConversationProjection> {
633 peer_projection_from_peer_input_with_id(peer, peer_canonical_id(peer)?.as_str())
634}
635
636fn peer_projection_from_peer_input_with_id(
637 peer: &PeerInput,
638 peer_id: &str,
639) -> Option<PeerConversationProjection> {
640 let peer_id = peer_id.to_string();
641
642 match &peer.convention {
643 Some(PeerConvention::Message) => Some(PeerConversationProjection::Message { peer_id }),
644 Some(PeerConvention::Request { request_id, intent }) => {
645 let peer_id = match meerkat_core::comms::PeerId::parse(peer_id.as_str()) {
646 Ok(peer_id) => peer_id,
647 Err(error) => {
648 tracing::warn!(
649 peer_id,
650 error = %error,
651 "dropping peer request projection with non-canonical peer_id"
652 );
653 return None;
654 }
655 };
656 Some(PeerConversationProjection::Request {
657 peer_id,
658 display_name: peer_display_label(peer),
659 request_id: request_id.clone(),
660 intent: intent.clone(),
661 payload: peer.payload.clone(),
662 })
663 }
664 Some(PeerConvention::ResponseProgress { request_id, phase }) => {
665 Some(PeerConversationProjection::ResponseProgress {
666 peer_id,
667 request_id: request_id.clone(),
668 phase: *phase,
669 payload: peer.payload.clone(),
670 })
671 }
672 Some(PeerConvention::ResponseTerminal { .. }) => None,
673 None => None,
674 }
675}
676
677pub(crate) fn peer_response_terminal_fact(
678 peer: &PeerInput,
679) -> Result<Option<PeerResponseTerminalFact>, PeerResponseTerminalFactError> {
680 let InputOrigin::Peer {
681 peer_id,
682 display_identity,
683 runtime_id,
684 } = &peer.header.source
685 else {
686 return Ok(None);
687 };
688 let Some(PeerConvention::ResponseTerminal { request_id, status }) = &peer.convention else {
689 return Ok(None);
690 };
691
692 let transport_identity = runtime_id
693 .as_ref()
694 .map(ToString::to_string)
695 .map(PeerResponseTerminalTransportIdentity::parse)
696 .transpose()?;
697 let source = PeerResponseTerminalSource::new(
698 transport_identity,
699 PeerResponseTerminalRouteIdentity::parse(peer_id.clone())?,
700 PeerResponseTerminalDisplayIdentity::parse(
701 display_identity
702 .as_ref()
703 .ok_or(PeerResponseTerminalFactError::MissingDisplayIdentity)?
704 .clone(),
705 )?,
706 );
707 Ok(Some(PeerResponseTerminalFact::new(
708 source,
709 PeerResponseTerminalCorrelationId::parse(request_id)?,
710 *status,
711 PeerResponseTerminalRenderPayload::new(peer.payload.clone()),
712 )))
713}
714
715pub(crate) fn validate_peer_response_terminal_fact(
716 input: &Input,
717) -> Result<(), PeerResponseTerminalFactError> {
718 let Input::Peer(peer) = input else {
719 return Ok(());
720 };
721 peer_response_terminal_fact(peer).map(|_| ())
722}
723
724#[cfg(test)]
727pub(crate) fn peer_projection(input: &Input) -> Option<PeerConversationProjection> {
728 let Input::Peer(peer) = input else {
729 return None;
730 };
731 peer_projection_from_peer_input(peer)
732}
733
734fn peer_canonical_id(peer: &PeerInput) -> Option<String> {
735 let InputOrigin::Peer { peer_id, .. } = &peer.header.source else {
736 return None;
737 };
738 Some(peer_id.clone())
739}
740
741fn peer_display_label(peer: &PeerInput) -> Option<String> {
742 let InputOrigin::Peer {
743 display_identity, ..
744 } = &peer.header.source
745 else {
746 return None;
747 };
748
749 display_identity
750 .as_ref()
751 .map(|label| label.trim())
752 .filter(|label| !label.is_empty())
753 .map(ToOwned::to_owned)
754}
755
756pub(crate) fn peer_reply_capability(
768 input: &Input,
769) -> Option<meerkat_core::comms::PeerReplyCapability> {
770 if input.kind() != InputKind::PeerMessage {
771 return None;
772 }
773 let Input::Peer(peer) = input else {
774 return None;
775 };
776 let InputOrigin::Peer { peer_id, .. } = &peer.header.source else {
777 return None;
778 };
779 let peer_id = match meerkat_core::comms::PeerId::parse(peer_id) {
780 Ok(peer_id) => peer_id,
781 Err(error) => {
782 tracing::error!(
783 peer_id,
784 error = %error,
785 "dropping peer reply capability with non-canonical peer_id"
786 );
787 return None;
788 }
789 };
790 let correlation_id = peer.header.correlation_id.as_ref()?;
791 Some(meerkat_core::comms::PeerReplyCapability {
792 in_reply_to: meerkat_core::InteractionId(correlation_id.0),
793 peer_id,
794 display_name: peer_display_label(peer),
795 kind: meerkat_core::comms::PeerReplyDeliveryKind::Message,
796 })
797}
798
799pub(crate) fn peer_prompt_text(peer: &PeerInput) -> String {
801 peer_projection_from_peer_input(peer)
802 .map(|projection| {
803 let prompt = projection.prompt_text();
804 if prompt.is_empty() {
805 peer.content.text_content()
806 } else {
807 prompt
808 }
809 })
810 .unwrap_or_else(|| peer.content.text_content())
811}
812
813pub(crate) fn input_prompt_text(input: &Input) -> String {
814 match input {
815 Input::Prompt(p) => p.content.text_content(),
816 Input::Peer(p) => peer_prompt_text(p),
817 Input::FlowStep(f) => f.content.text_content(),
818 Input::ExternalEvent(e) => external_event_projection_text(e),
819 Input::Continuation(continuation) => format!("[Continuation] {}", continuation.reason),
820 Input::Operation(operation) => {
821 format!(
822 "[Operation {}] {:?}",
823 operation.operation_id, operation.event
824 )
825 }
826 }
827}
828
829fn external_event_projection_text(event: &ExternalEventInput) -> String {
830 let source_name = match &event.header.source {
831 InputOrigin::External { source_name } if !source_name.trim().is_empty() => {
832 source_name.as_str()
833 }
834 _ => event.event_type.as_str(),
835 };
836 let body = event
837 .payload
838 .get("body")
839 .and_then(serde_json::Value::as_str)
840 .map(str::trim);
841
842 meerkat_core::interaction::format_external_event_projection(source_name, body)
843}
844
845fn peer_notice_renderable(peer: &PeerInput) -> Option<CoreRenderable> {
846 let (peer_id, display_name) = match &peer.header.source {
847 InputOrigin::Peer {
848 peer_id,
849 display_identity,
850 ..
851 } => (peer_id.clone(), display_identity.clone()),
852 _ => return None,
853 };
854 use meerkat_core::types::CommsNoticeKind;
855 let (kind, request_id, intent, status) = match &peer.convention {
856 Some(PeerConvention::Message) | None => (CommsNoticeKind::Message, None, None, None),
857 Some(PeerConvention::Request { request_id, intent }) => (
858 CommsNoticeKind::Request,
859 Some(request_id.clone()),
860 Some(intent.clone()),
861 None,
862 ),
863 Some(PeerConvention::ResponseProgress { request_id, phase }) => (
864 CommsNoticeKind::ResponseProgress,
865 Some(request_id.clone()),
866 None,
867 Some(format!("{phase:?}")),
868 ),
869 Some(PeerConvention::ResponseTerminal { request_id, status }) => (
870 CommsNoticeKind::ResponseTerminal,
871 Some(request_id.clone()),
872 None,
873 Some(format!("{status:?}")),
874 ),
875 };
876 let summary = match kind {
877 CommsNoticeKind::Request => intent.as_ref().map_or_else(
878 || "Peer request".to_string(),
879 |intent| format!("Peer request: {intent}"),
880 ),
881 CommsNoticeKind::ResponseProgress => "Peer response progress".to_string(),
882 CommsNoticeKind::ResponseTerminal => "Peer response terminal".to_string(),
883 CommsNoticeKind::Message | CommsNoticeKind::Other(_) => "Peer message".to_string(),
884 };
885 let content = match &peer.content {
886 ContentInput::Text(body) if body.is_empty() => Vec::new(),
887 ContentInput::Text(body) => {
888 vec![meerkat_core::types::ContentBlock::Text { text: body.clone() }]
889 }
890 ContentInput::Blocks(blocks) => blocks.clone(),
891 };
892 let notice_peer = meerkat_core::comms::PeerId::parse(&peer_id)
899 .ok()
900 .map(|id| SystemNoticePeer { id, display_name });
901 Some(CoreRenderable::SystemNotice {
902 kind: SystemNoticeKind::Comms,
903 body: Some(summary.clone()),
904 blocks: vec![SystemNoticeBlock::Comms {
905 kind,
906 direction: SystemNoticeDirection::Incoming,
907 peer: notice_peer,
908 sender_taint: peer.sender_taint,
912 request_id,
913 intent,
914 status,
915 summary: Some(summary),
916 payload: peer.payload.clone(),
917 content,
918 }],
919 })
920}
921
922fn external_event_notice_renderable(event: &ExternalEventInput) -> CoreRenderable {
923 let source = match &event.header.source {
924 InputOrigin::External { source_name } if !source_name.trim().is_empty() => {
925 source_name.clone()
926 }
927 _ => event.event_type.clone(),
928 };
929 let body = event
930 .payload
931 .get("body")
932 .and_then(serde_json::Value::as_str)
933 .map(str::trim)
934 .filter(|body| !body.is_empty())
935 .map(ToOwned::to_owned);
936 let summary = body.as_ref().map_or_else(
937 || format!("External event via {source}"),
938 std::clone::Clone::clone,
939 );
940 CoreRenderable::SystemNotice {
941 kind: SystemNoticeKind::ExternalEvent,
942 body: Some(summary.clone()),
943 blocks: vec![SystemNoticeBlock::ExternalEvent {
944 source,
945 event_type: event.event_type.clone(),
946 summary: Some(summary),
947 body,
948 payload: Some(event.payload.clone()),
949 content: event.blocks.clone().unwrap_or_default(),
950 }],
951 }
952}
953
954fn input_to_append(input: &Input) -> Option<ConversationAppend> {
955 let (role, content) = match input {
963 Input::Prompt(p)
964 if !p.typed_turn_appends.is_empty()
965 && match &p.content {
966 ContentInput::Text(text) => text.trim().is_empty(),
967 ContentInput::Blocks(blocks) => blocks.is_empty(),
968 } =>
969 {
970 return None;
971 }
972 Input::Prompt(p) => match &p.content {
973 ContentInput::Blocks(blocks) => (
974 ConversationAppendRole::User,
975 CoreRenderable::Blocks {
976 blocks: blocks.clone(),
977 },
978 ),
979 ContentInput::Text(_) => (
980 ConversationAppendRole::User,
981 CoreRenderable::Text {
982 text: input_prompt_text(input),
983 },
984 ),
985 },
986 Input::Peer(p) => peer_notice_renderable(p)
987 .map(|content| (ConversationAppendRole::SystemNotice, content))?,
988 Input::FlowStep(f) => (
989 ConversationAppendRole::SystemNotice,
990 CoreRenderable::SystemNotice {
991 kind: SystemNoticeKind::Generic,
992 body: Some(format!("Flow step {}", f.step_id)),
993 blocks: vec![SystemNoticeBlock::RuntimeNotice {
994 category: "flow_step".to_string(),
995 detail: Some(f.content.text_content()),
996 payload: None,
997 }],
998 },
999 ),
1000 Input::ExternalEvent(e) => (
1001 ConversationAppendRole::SystemNotice,
1002 external_event_notice_renderable(e),
1003 ),
1004 Input::Continuation(continuation) => return continuation.turn_append.clone(),
1005 Input::Operation(_) => return None,
1006 };
1007
1008 Some(ConversationAppend { role, content })
1009}
1010
1011fn input_to_context_append(input: &Input) -> Option<ConversationContextAppend> {
1012 let (projection, content) = match input {
1013 Input::Continuation(continuation) => {
1014 return continuation.context_append.clone();
1015 }
1016 Input::Peer(peer) => {
1017 let projection = peer_projection_from_peer_input(peer)?;
1018 let content = peer_notice_renderable(peer)?;
1019 (projection, content)
1020 }
1021 _ => return None,
1022 };
1023
1024 Some(ConversationContextAppend {
1025 key: projection.context_key()?,
1026 content,
1027 })
1028}
1029
1030fn peer_response_terminal_context_append(
1031 peer: &PeerInput,
1032) -> Result<Option<ConversationContextAppend>, PeerResponseTerminalFactError> {
1033 let Some(fact) = peer_response_terminal_fact(peer)? else {
1034 return Ok(None);
1035 };
1036
1037 Ok(Some(ConversationContextAppend {
1038 key: fact.context_key(),
1039 content: CoreRenderable::SystemNotice {
1040 kind: SystemNoticeKind::Comms,
1041 body: Some("Peer terminal response context".to_string()),
1042 blocks: vec![SystemNoticeBlock::Comms {
1043 kind: meerkat_core::types::CommsNoticeKind::ResponseTerminal,
1044 direction: SystemNoticeDirection::Incoming,
1045 peer: Some(SystemNoticePeer {
1046 id: fact.source.route_identity.peer_id(),
1047 display_name: Some(fact.source.display_identity.to_string()),
1048 }),
1049 sender_taint: None,
1053 request_id: Some(fact.correlation_id.to_string()),
1054 intent: None,
1055 status: Some(fact.status.label().to_string()),
1056 summary: Some("Peer terminal response".to_string()),
1057 payload: fact.render_payload.as_ref().cloned(),
1058 content: Vec::new(),
1059 }],
1060 },
1061 }))
1062}
1063
1064fn injected_context_appends(entries: &[ContentInput]) -> Vec<ConversationAppend> {
1068 entries
1069 .iter()
1070 .map(|entry| ConversationAppend {
1071 role: ConversationAppendRole::InjectedContext,
1072 content: match entry {
1073 ContentInput::Blocks(blocks) => CoreRenderable::Blocks {
1074 blocks: blocks.clone(),
1075 },
1076 ContentInput::Text(text) => CoreRenderable::Text { text: text.clone() },
1077 },
1078 })
1079 .collect()
1080}
1081
1082pub(crate) fn runtime_input_projection(
1083 input: &Input,
1084) -> crate::ingress_types::RuntimeInputProjection {
1085 crate::ingress_types::RuntimeInputProjection {
1086 injected_context_appends: match input {
1087 Input::Prompt(prompt) => injected_context_appends(&prompt.injected_context),
1088 Input::Peer(peer) => injected_context_appends(&peer.injected_context),
1089 _ => Vec::new(),
1090 },
1091 append: input_to_append(input),
1092 additional_appends: match input {
1093 Input::Prompt(prompt) => prompt.typed_turn_appends.clone(),
1094 _ => Vec::new(),
1095 },
1096 context_append: input_to_context_append(input),
1097 peer_response_terminal: None,
1098 }
1099}
1100
1101pub(crate) fn runtime_input_projection_for_machine_batch(
1102 input: &Input,
1103) -> crate::ingress_types::RuntimeInputProjection {
1104 let mut projection = runtime_input_projection(input);
1105 if let Input::Peer(peer) = input
1106 && let Ok(Some(context_append)) = peer_response_terminal_context_append(peer)
1107 {
1108 projection.context_append = Some(context_append);
1109 if let Ok(fact) = peer_response_terminal_fact(peer) {
1113 projection.peer_response_terminal = fact;
1114 }
1115 }
1116 projection
1117}
1118
1119pub(crate) fn context_append_to_pending_system_context_append(
1120 append: &ConversationContextAppend,
1121 peer_response_terminal: Option<&meerkat_core::PeerResponseTerminalFact>,
1122) -> meerkat_core::PendingSystemContextAppend {
1123 meerkat_core::PendingSystemContextAppend {
1124 content: append.content.clone(),
1125 source: Some(append.key.clone()),
1126 idempotency_key: Some(append.key.clone()),
1127 source_kind: meerkat_core::session::SystemContextSource::Normal,
1129 peer_response_terminal: peer_response_terminal.cloned(),
1134 accepted_at: meerkat_core::time_compat::SystemTime::now(),
1135 }
1136}
1137
1138pub(crate) fn projection_to_pending_system_context_appends(
1139 input_id: &InputId,
1140 projection: &crate::ingress_types::RuntimeInputProjection,
1141) -> Vec<meerkat_core::PendingSystemContextAppend> {
1142 if let Some(append) = projection.context_append.as_ref() {
1143 return std::iter::once(context_append_to_pending_system_context_append(
1144 append,
1145 projection.peer_response_terminal.as_ref(),
1146 ))
1147 .filter(|append| !append.content.render_text().trim().is_empty())
1148 .collect();
1149 }
1150
1151 projection
1152 .append
1153 .as_ref()
1154 .map(|append| {
1155 let key = format!("runtime:steer:{input_id}");
1161 meerkat_core::PendingSystemContextAppend {
1162 content: append.content.clone(),
1163 source: Some(key.clone()),
1164 idempotency_key: Some(key),
1165 source_kind: meerkat_core::session::SystemContextSource::RuntimeSteer,
1166 peer_response_terminal: None,
1168 accepted_at: meerkat_core::time_compat::SystemTime::now(),
1169 }
1170 })
1171 .into_iter()
1172 .filter(|append| !append.content.render_text().trim().is_empty())
1173 .collect()
1174}
1175
1176#[cfg(test)]
1177#[allow(clippy::unwrap_used, clippy::panic)]
1178mod tests {
1179 use super::*;
1180 use chrono::Utc;
1181
1182 fn make_header() -> InputHeader {
1183 InputHeader {
1184 id: InputId::new(),
1185 timestamp: Utc::now(),
1186 source: InputOrigin::Operator,
1187 durability: InputDurability::Durable,
1188 visibility: InputVisibility::default(),
1189 idempotency_key: None,
1190 supersession_key: None,
1191 correlation_id: None,
1192 }
1193 }
1194
1195 fn typed_runtime_notice_append(detail: &str) -> ConversationAppend {
1196 ConversationAppend {
1197 role: ConversationAppendRole::SystemNotice,
1198 content: CoreRenderable::SystemNotice {
1199 kind: meerkat_core::types::SystemNoticeKind::Generic,
1200 body: Some(detail.to_string()),
1201 blocks: vec![meerkat_core::types::SystemNoticeBlock::RuntimeNotice {
1202 category: "test".to_string(),
1203 detail: Some(detail.to_string()),
1204 payload: None,
1205 }],
1206 },
1207 }
1208 }
1209
1210 #[test]
1211 fn prompt_input_serde() {
1212 let input = Input::Prompt(PromptInput {
1213 injected_context: Vec::new(),
1214 header: make_header(),
1215 content: "hello".into(),
1216 typed_turn_appends: Vec::new(),
1217 turn_metadata: None,
1218 });
1219 let json = serde_json::to_value(&input).unwrap();
1220 assert_eq!(json["input_type"], "prompt");
1221 let parsed: Input = serde_json::from_value(json).unwrap();
1222 assert!(matches!(parsed, Input::Prompt(_)));
1223 }
1224
1225 #[test]
1226 fn prompt_input_typed_turn_appends_project_without_user_text() {
1227 let append = typed_runtime_notice_append("peer delivery");
1228 let input = Input::Prompt(PromptInput {
1229 injected_context: Vec::new(),
1230 header: make_header(),
1231 content: ContentInput::Text(String::new()),
1232 typed_turn_appends: vec![append.clone()],
1233 turn_metadata: None,
1234 });
1235
1236 let projection = runtime_input_projection(&input);
1237 assert!(
1238 projection.append.is_none(),
1239 "empty runtime-authored prompt carrier must not synthesize a user append"
1240 );
1241 assert_eq!(projection.additional_appends, vec![append]);
1242 }
1243
1244 #[test]
1249 fn prompt_input_injected_context_projects_before_user_append() {
1250 let input = Input::Prompt(PromptInput {
1251 injected_context: vec![
1252 ContentInput::Text("ambient alpha".to_string()),
1253 ContentInput::Text("ambient beta".to_string()),
1254 ],
1255 header: make_header(),
1256 content: "the prompt".into(),
1257 typed_turn_appends: Vec::new(),
1258 turn_metadata: None,
1259 });
1260
1261 let projection = runtime_input_projection(&input);
1262 assert_eq!(projection.injected_context_appends.len(), 2);
1263 assert!(
1264 projection
1265 .injected_context_appends
1266 .iter()
1267 .all(|append| { append.role == ConversationAppendRole::InjectedContext })
1268 );
1269 assert_eq!(
1270 projection.injected_context_appends[0].content,
1271 CoreRenderable::Text {
1272 text: "ambient alpha".to_string()
1273 }
1274 );
1275 assert_eq!(
1276 projection.injected_context_appends[1].content,
1277 CoreRenderable::Text {
1278 text: "ambient beta".to_string()
1279 }
1280 );
1281 assert!(
1282 projection.additional_appends.is_empty(),
1283 "injected context must not ride the generic typed_turn_appends carrier"
1284 );
1285 assert!(projection.append.is_some(), "user append must survive");
1286 }
1287
1288 #[test]
1291 fn peer_input_injected_context_projects_before_peer_append() {
1292 let mut header = make_header();
1293 header.source = InputOrigin::Peer {
1294 peer_id: "peer-1".into(),
1295 display_identity: Some("Peer One".into()),
1296 runtime_id: None,
1297 };
1298 let input = Input::Peer(PeerInput {
1299 injected_context: vec![ContentInput::Text("supervisor ambient".to_string())],
1300 sender_taint: None,
1301 header,
1302 convention: Some(PeerConvention::Message),
1303 content: "work content".into(),
1304 payload: None,
1305 handling_mode: None,
1306 });
1307
1308 let projection = runtime_input_projection(&input);
1309 assert_eq!(projection.injected_context_appends.len(), 1);
1310 assert_eq!(
1311 projection.injected_context_appends[0].role,
1312 ConversationAppendRole::InjectedContext
1313 );
1314 assert!(
1315 projection.append.is_some(),
1316 "peer work append must survive alongside injected context"
1317 );
1318 }
1319
1320 #[test]
1323 fn prompt_input_injected_context_serde_default_and_omission() {
1324 let input = Input::Prompt(PromptInput {
1325 injected_context: vec![ContentInput::Text("ambient".to_string())],
1326 header: make_header(),
1327 content: "hello".into(),
1328 typed_turn_appends: Vec::new(),
1329 turn_metadata: None,
1330 });
1331 let json = serde_json::to_value(&input).unwrap();
1332 assert!(json.get("injected_context").is_some());
1333 let parsed: Input = serde_json::from_value(json).unwrap();
1334 let Input::Prompt(prompt) = parsed else {
1335 panic!("expected prompt input");
1336 };
1337 assert_eq!(prompt.injected_context.len(), 1);
1338
1339 let empty = Input::Prompt(PromptInput {
1340 injected_context: Vec::new(),
1341 header: make_header(),
1342 content: "hello".into(),
1343 typed_turn_appends: Vec::new(),
1344 turn_metadata: None,
1345 });
1346 let mut json = serde_json::to_value(&empty).unwrap();
1347 assert!(
1348 json.get("injected_context").is_none(),
1349 "empty injected context must be omitted on the wire"
1350 );
1351 json.as_object_mut().unwrap().remove("injected_context");
1353 let parsed: Input = serde_json::from_value(json).unwrap();
1354 let Input::Prompt(prompt) = parsed else {
1355 panic!("expected prompt input");
1356 };
1357 assert!(prompt.injected_context.is_empty());
1358 }
1359
1360 #[test]
1361 fn prompt_input_typed_turn_appends_serde_roundtrip() {
1362 let append = typed_runtime_notice_append("typed appends persist");
1363 let input = Input::Prompt(PromptInput {
1364 injected_context: Vec::new(),
1365 header: make_header(),
1366 content: ContentInput::Text(String::new()),
1367 typed_turn_appends: vec![append.clone()],
1368 turn_metadata: None,
1369 });
1370
1371 let json = serde_json::to_value(&input).unwrap();
1372 let parsed: Input = serde_json::from_value(json).unwrap();
1373 let Input::Prompt(prompt) = parsed else {
1374 panic!("expected prompt input");
1375 };
1376 assert_eq!(prompt.content.text_content(), "");
1377 assert_eq!(prompt.typed_turn_appends, vec![append]);
1378 }
1379
1380 #[test]
1381 fn peer_input_message_serde() {
1382 let input = Input::Peer(PeerInput {
1383 injected_context: Vec::new(),
1384 sender_taint: None,
1385 header: make_header(),
1386 convention: Some(PeerConvention::Message),
1387 content: "hi there".into(),
1388 payload: None,
1389 handling_mode: None,
1390 });
1391 let json = serde_json::to_value(&input).unwrap();
1392 assert_eq!(json["input_type"], "peer");
1393 let parsed: Input = serde_json::from_value(json).unwrap();
1394 assert!(matches!(parsed, Input::Peer(_)));
1395 }
1396
1397 fn peer_input_with(
1398 peer_id: &str,
1399 convention: Option<PeerConvention>,
1400 correlation_id: Option<CorrelationId>,
1401 ) -> Input {
1402 let mut header = make_header();
1403 header.source = InputOrigin::Peer {
1404 peer_id: peer_id.into(),
1405 display_identity: Some(" display-agent ".into()),
1406 runtime_id: None,
1407 };
1408 header.correlation_id = correlation_id;
1409 Input::Peer(PeerInput {
1410 injected_context: Vec::new(),
1411 sender_taint: None,
1412 header,
1413 convention,
1414 content: "hi there".into(),
1415 payload: None,
1416 handling_mode: None,
1417 })
1418 }
1419
1420 #[test]
1424 fn non_message_conventions_mint_no_reply_capability() {
1425 let peer_id = "018f6f79-7a82-7c4e-a552-a3b86f963005";
1426 let correlation = CorrelationId::from_uuid(uuid::Uuid::from_u128(9));
1427
1428 let message = peer_input_with(
1429 peer_id,
1430 Some(PeerConvention::Message),
1431 Some(correlation.clone()),
1432 );
1433 let capability = peer_reply_capability(&message)
1434 .expect("message convention with correlation must mint a capability");
1435 assert_eq!(
1436 capability.peer_id,
1437 meerkat_core::comms::PeerId::parse(peer_id).expect("canonical id")
1438 );
1439 assert_eq!(
1440 capability.in_reply_to,
1441 meerkat_core::InteractionId(uuid::Uuid::from_u128(9))
1442 );
1443 assert_eq!(
1444 capability.display_name.as_deref(),
1445 Some("display-agent"),
1446 "display identity must be trimmed"
1447 );
1448 assert_eq!(
1449 capability.kind,
1450 meerkat_core::comms::PeerReplyDeliveryKind::Message
1451 );
1452
1453 let bare = peer_input_with(peer_id, None, Some(correlation.clone()));
1454 assert!(
1455 peer_reply_capability(&bare).is_some(),
1456 "bare peer input groups as PeerMessage and must mint"
1457 );
1458
1459 let request = peer_input_with(
1460 peer_id,
1461 Some(PeerConvention::Request {
1462 request_id: "req-1".into(),
1463 intent: "review".into(),
1464 }),
1465 Some(correlation.clone()),
1466 );
1467 assert!(peer_reply_capability(&request).is_none());
1468
1469 let progress = peer_input_with(
1470 peer_id,
1471 Some(PeerConvention::ResponseProgress {
1472 request_id: "req-1".into(),
1473 phase: ResponseProgressPhase::Accepted,
1474 }),
1475 Some(correlation.clone()),
1476 );
1477 assert!(peer_reply_capability(&progress).is_none());
1478
1479 let terminal = peer_input_with(
1480 peer_id,
1481 Some(PeerConvention::ResponseTerminal {
1482 request_id: "req-1".into(),
1483 status: ResponseTerminalStatus::Completed,
1484 }),
1485 Some(correlation),
1486 );
1487 assert!(peer_reply_capability(&terminal).is_none());
1488
1489 let no_correlation = peer_input_with(peer_id, Some(PeerConvention::Message), None);
1490 assert!(
1491 peer_reply_capability(&no_correlation).is_none(),
1492 "a delivery without a correlation id has no reply selector"
1493 );
1494
1495 let prompt = Input::Prompt(PromptInput::new("hello", None));
1496 assert!(peer_reply_capability(&prompt).is_none());
1497 }
1498
1499 #[test]
1500 fn non_canonical_peer_id_mints_no_reply_capability() {
1501 let input = peer_input_with(
1502 "peer-1",
1503 Some(PeerConvention::Message),
1504 Some(CorrelationId::from_uuid(uuid::Uuid::from_u128(9))),
1505 );
1506 assert!(
1507 peer_reply_capability(&input).is_none(),
1508 "a non-canonical peer id must fail the mint, never smuggle a raw string"
1509 );
1510 }
1511
1512 #[test]
1513 fn peer_message_blocks_preserve_typed_comms_content_without_prefix_injection() {
1514 let peer_id = "018f6f79-7a82-7c4e-a552-a3b86f963005";
1515 let mut header = make_header();
1516 header.source = InputOrigin::Peer {
1517 peer_id: peer_id.into(),
1518 display_identity: Some("display-agent".into()),
1519 runtime_id: None,
1520 };
1521 let input = Input::Peer(PeerInput {
1522 injected_context: Vec::new(),
1523 sender_taint: None,
1524 header,
1525 convention: Some(PeerConvention::Message),
1526 content: ContentInput::Blocks(vec![
1527 meerkat_core::types::ContentBlock::Text {
1528 text: "caption".into(),
1529 },
1530 meerkat_core::types::ContentBlock::Image {
1531 media_type: "image/png".into(),
1532 data: "abc".into(),
1533 },
1534 ]),
1535 payload: None,
1536 handling_mode: None,
1537 });
1538
1539 let Input::Peer(peer) = &input else {
1540 panic!("expected peer input");
1541 };
1542 assert_eq!(
1543 peer_projection_from_peer_input(peer)
1544 .and_then(|projection| projection.block_prefix_text())
1545 .as_deref(),
1546 Some(format!("Peer message from {peer_id}").as_str())
1547 );
1548
1549 let projection = runtime_input_projection(&input);
1550 let append = projection.append.expect("conversation append");
1551 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1552 panic!("expected typed system notice");
1553 };
1554 let Some(meerkat_core::types::SystemNoticeBlock::Comms { content, peer, .. }) =
1555 blocks.first()
1556 else {
1557 panic!("expected comms block");
1558 };
1559 assert_eq!(
1560 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1561 Some("display-agent")
1562 );
1563 assert_eq!(
1564 content.first(),
1565 Some(&meerkat_core::types::ContentBlock::Text {
1566 text: "caption".into()
1567 })
1568 );
1569 }
1570
1571 #[test]
1578 fn peer_message_sender_taint_reaches_typed_comms_notice_and_model_projection() {
1579 use meerkat_core::comms::SenderContentTaint;
1580
1581 let notice_block = |declared: Option<SenderContentTaint>| {
1582 let mut header = make_header();
1583 header.source = InputOrigin::Peer {
1584 peer_id: "018f6f79-7a82-7c4e-a552-a3b86f963005".into(),
1585 display_identity: Some("display-agent".into()),
1586 runtime_id: None,
1587 };
1588 let input = Input::Peer(PeerInput {
1589 injected_context: Vec::new(),
1590 sender_taint: declared,
1591 header,
1592 convention: Some(PeerConvention::Message),
1593 content: "hello from peer".into(),
1594 payload: None,
1595 handling_mode: None,
1596 });
1597 let projection = runtime_input_projection(&input);
1598 let append = projection.append.expect("conversation append");
1599 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1600 panic!("expected typed system notice");
1601 };
1602 blocks.first().cloned().expect("comms block")
1603 };
1604
1605 let tainted_block = notice_block(Some(SenderContentTaint::Tainted));
1606 let clean_block = notice_block(Some(SenderContentTaint::Clean));
1607 let undeclared_block = notice_block(None);
1608
1609 let taint_of = |block: &meerkat_core::types::SystemNoticeBlock| {
1610 let meerkat_core::types::SystemNoticeBlock::Comms { sender_taint, .. } = block else {
1611 panic!("expected comms block");
1612 };
1613 *sender_taint
1614 };
1615 assert_eq!(taint_of(&tainted_block), Some(SenderContentTaint::Tainted));
1616 assert_eq!(taint_of(&clean_block), Some(SenderContentTaint::Clean));
1617 assert_eq!(
1618 taint_of(&undeclared_block),
1619 None,
1620 "no declaration must stay None in the transcript, never coalesced into Clean"
1621 );
1622
1623 let tainted_text = tainted_block.model_projection_text();
1624 let clean_text = clean_block.model_projection_text();
1625 let undeclared_text = undeclared_block.model_projection_text();
1626 assert!(
1627 tainted_text.contains("[sender declared this content tainted]"),
1628 "declared taint must be model-visible: {tainted_text}"
1629 );
1630 assert_eq!(
1631 clean_text, undeclared_text,
1632 "Clean and no-declaration deliberately render identically; the typed field is the carrier"
1633 );
1634 assert!(!clean_text.contains("tainted"));
1635 }
1636
1637 #[test]
1638 fn peer_response_terminal_context_is_deferred_to_machine_batch_projection() {
1639 let route_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f2";
1640 let request_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f1";
1641 let mut header = make_header();
1642 header.source = InputOrigin::Peer {
1643 peer_id: route_id.into(),
1644 display_identity: Some("display-agent".into()),
1645 runtime_id: None,
1646 };
1647 let input = Input::Peer(PeerInput {
1648 injected_context: Vec::new(),
1649 sender_taint: None,
1650 header,
1651 convention: Some(PeerConvention::ResponseTerminal {
1652 request_id: request_id.into(),
1653 status: ResponseTerminalStatus::Completed,
1654 }),
1655 content: "response body".into(),
1656 payload: Some(serde_json::json!({"answer":"ok"})),
1657 handling_mode: None,
1658 });
1659
1660 let Input::Peer(peer) = &input else {
1661 panic!("expected peer input");
1662 };
1663 let expected_canonical_key = format!("peer_response_terminal:{route_id}:{request_id}");
1664 assert!(
1665 peer_projection_from_peer_input(peer).is_none(),
1666 "terminal peer response projection must not be built before machine batch selection"
1667 );
1668
1669 let projection = runtime_input_projection(&input);
1670 assert!(
1671 projection.context_append.is_none(),
1672 "admission projection must not store terminal peer response context"
1673 );
1674 let projection = runtime_input_projection_for_machine_batch(&input);
1675 let context = projection.context_append.expect("context append");
1676 assert_eq!(context.key, expected_canonical_key);
1677 let CoreRenderable::SystemNotice { blocks, .. } = context.content else {
1678 panic!("expected typed context");
1679 };
1680 let Some(meerkat_core::types::SystemNoticeBlock::Comms { peer, .. }) = blocks.first()
1681 else {
1682 panic!("expected comms block");
1683 };
1684 assert_eq!(
1685 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1686 Some("display-agent")
1687 );
1688 assert_eq!(
1689 peer.as_ref().map(|peer| peer.id),
1690 Some(meerkat_core::comms::PeerId::parse(route_id).expect("valid route id"))
1691 );
1692 }
1693
1694 #[test]
1695 fn steer_projection_uses_context_append_as_pending_system_context() {
1696 let input_id = InputId::new();
1697 let projection = crate::ingress_types::RuntimeInputProjection {
1698 injected_context_appends: Vec::new(),
1699 append: Some(ConversationAppend {
1700 role: ConversationAppendRole::SystemNotice,
1701 content: CoreRenderable::Text {
1702 text: "ordinary append must lose to context append".into(),
1703 },
1704 }),
1705 additional_appends: Vec::new(),
1706 context_append: Some(ConversationContextAppend {
1707 key: "peer_response_terminal:peer:req".into(),
1708 content: CoreRenderable::Text {
1709 text: "terminal response is ready".into(),
1710 },
1711 }),
1712 peer_response_terminal: None,
1713 };
1714
1715 let appends = projection_to_pending_system_context_appends(&input_id, &projection);
1716
1717 assert_eq!(appends.len(), 1);
1718 assert_eq!(
1719 appends[0].content.render_text(),
1720 "terminal response is ready"
1721 );
1722 assert_eq!(
1723 appends[0].source.as_deref(),
1724 Some("peer_response_terminal:peer:req")
1725 );
1726 assert_eq!(
1727 appends[0].idempotency_key.as_deref(),
1728 Some("peer_response_terminal:peer:req")
1729 );
1730 }
1731
1732 #[test]
1733 fn continuation_projection_can_carry_runtime_context_append() {
1734 let input = Input::Continuation(ContinuationInput {
1735 header: make_header(),
1736 reason: "workgraph_attention".into(),
1737 continuation_kind: ContinuationKind::WorkgraphAttention,
1738 handling_mode: HandlingMode::Steer,
1739 request_id: Some("binding-1".into()),
1740 turn_tool_overlay: Some(TurnToolOverlay {
1741 allowed_tools: Some(vec!["workgraph_add_evidence".into()]),
1742 blocked_tools: None,
1743 dispatch_context: Default::default(),
1744 }),
1745 context_append: Some(ConversationContextAppend {
1746 key: "workgraph_attention:binding-1:2:5".into(),
1747 content: CoreRenderable::Text {
1748 text: "WorkGraph attention projection".into(),
1749 },
1750 }),
1751 turn_append: None,
1752 });
1753 let projection = runtime_input_projection_for_machine_batch(&input);
1754 let appends = projection_to_pending_system_context_appends(input.id(), &projection);
1755
1756 assert_eq!(appends.len(), 1);
1757 assert_eq!(
1758 appends[0].content.render_text(),
1759 "WorkGraph attention projection"
1760 );
1761 assert_eq!(
1762 appends[0].source.as_deref(),
1763 Some("workgraph_attention:binding-1:2:5")
1764 );
1765 let metadata = crate::runtime_loop::for_input(
1766 &input,
1767 crate::ingress_types::RuntimeInputSemantics {
1768 boundary: meerkat_core::lifecycle::run_primitive::RunApplyBoundary::RunStart,
1769 execution_kind: meerkat_core::lifecycle::RuntimeExecutionKind::ContentTurn,
1770 execution_handling_mode: None,
1771 peer_response_terminal_apply_intent: None,
1772 live_interrupt_required: false,
1773 },
1774 );
1775 assert_eq!(
1776 metadata
1777 .turn_tool_overlay
1778 .and_then(|overlay| overlay.allowed_tools),
1779 Some(vec!["workgraph_add_evidence".into()])
1780 );
1781 }
1782
1783 #[test]
1784 fn steer_projection_falls_back_to_ordinary_peer_append() {
1785 let mut header = make_header();
1786 header.source = InputOrigin::Peer {
1787 peer_id: "peer-a".into(),
1788 display_identity: Some("Peer A".into()),
1789 runtime_id: None,
1790 };
1791 let input = Input::Peer(PeerInput {
1792 injected_context: Vec::new(),
1793 sender_taint: None,
1794 header,
1795 convention: Some(PeerConvention::Message),
1796 content: "please look at this while you work".into(),
1797 payload: None,
1798 handling_mode: Some(HandlingMode::Steer),
1799 });
1800 let input_id = input.id().clone();
1801 let projection = runtime_input_projection(&input);
1802
1803 let appends = projection_to_pending_system_context_appends(&input_id, &projection);
1804
1805 assert_eq!(appends.len(), 1);
1806 let rendered = appends[0].content.render_text();
1807 assert!(
1808 rendered.contains("please look at this while you work"),
1809 "peer message append should be renderable as live system context: {rendered:?}"
1810 );
1811 assert_eq!(
1812 appends[0].source.as_deref(),
1813 Some(format!("runtime:steer:{input_id}").as_str())
1814 );
1815 assert_eq!(
1816 appends[0].idempotency_key.as_deref(),
1817 Some(format!("runtime:steer:{input_id}").as_str())
1818 );
1819 }
1820
1821 #[test]
1822 fn steer_projection_filters_empty_context_and_empty_append() {
1823 let input_id = InputId::new();
1824 let context_projection = crate::ingress_types::RuntimeInputProjection {
1825 injected_context_appends: Vec::new(),
1826 append: None,
1827 additional_appends: Vec::new(),
1828 context_append: Some(ConversationContextAppend {
1829 key: "empty-context".into(),
1830 content: CoreRenderable::Text { text: " ".into() },
1831 }),
1832 peer_response_terminal: None,
1833 };
1834 assert!(
1835 projection_to_pending_system_context_appends(&input_id, &context_projection).is_empty()
1836 );
1837
1838 let append_projection = crate::ingress_types::RuntimeInputProjection {
1839 injected_context_appends: Vec::new(),
1840 append: Some(ConversationAppend {
1841 role: ConversationAppendRole::SystemNotice,
1842 content: CoreRenderable::Text { text: "\n".into() },
1843 }),
1844 additional_appends: Vec::new(),
1845 context_append: None,
1846 peer_response_terminal: None,
1847 };
1848 assert!(
1849 projection_to_pending_system_context_appends(&input_id, &append_projection).is_empty()
1850 );
1851 }
1852
1853 #[test]
1854 fn peer_response_terminal_with_blocks_projects_append_and_context() {
1855 let route_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f2";
1856 let request_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f1";
1857 let mut header = make_header();
1858 header.source = InputOrigin::Peer {
1859 peer_id: route_id.into(),
1860 display_identity: Some("display-agent".into()),
1861 runtime_id: None,
1862 };
1863 let input = Input::Peer(PeerInput {
1864 injected_context: Vec::new(),
1865 sender_taint: None,
1866 header,
1867 convention: Some(PeerConvention::ResponseTerminal {
1868 request_id: request_id.into(),
1869 status: ResponseTerminalStatus::Completed,
1870 }),
1871 content: ContentInput::Blocks(vec![meerkat_core::types::ContentBlock::Image {
1872 media_type: "image/jpeg".into(),
1873 data: "abc".into(),
1874 }]),
1875 payload: Some(serde_json::json!({"answer":"ok"})),
1876 handling_mode: None,
1877 });
1878
1879 let projection = runtime_input_projection_for_machine_batch(&input);
1880 let append = projection.append.expect("conversation append");
1881 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1882 panic!("expected typed append");
1883 };
1884 let Some(meerkat_core::types::SystemNoticeBlock::Comms { content, peer, .. }) =
1885 blocks.first()
1886 else {
1887 panic!("expected comms block");
1888 };
1889 assert_eq!(
1890 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1891 Some("display-agent")
1892 );
1893 assert!(matches!(
1894 content.first(),
1895 Some(meerkat_core::types::ContentBlock::Image { media_type, .. })
1896 if media_type == "image/jpeg"
1897 ));
1898 assert!(
1899 projection.context_append.is_some(),
1900 "terminal response must still apply runtime-owned context"
1901 );
1902 }
1903
1904 #[test]
1905 fn peer_input_request_serde() {
1906 let input = Input::Peer(PeerInput {
1907 injected_context: Vec::new(),
1908 sender_taint: None,
1909 header: make_header(),
1910 convention: Some(PeerConvention::Request {
1911 request_id: "req-1".into(),
1912 intent: "mob.peer_added".into(),
1913 }),
1914 content: "Agent joined".into(),
1915 payload: Some(serde_json::json!({"name": "agent-1"})),
1916 handling_mode: None,
1917 });
1918 let json = serde_json::to_value(&input).unwrap();
1919 let parsed: Input = serde_json::from_value(json).unwrap();
1920 if let Input::Peer(p) = parsed {
1921 assert!(matches!(p.convention, Some(PeerConvention::Request { .. })));
1922 } else {
1923 panic!("Expected PeerInput");
1924 }
1925 }
1926
1927 #[test]
1928 fn peer_input_response_terminal_serde() {
1929 let input = Input::Peer(PeerInput {
1930 injected_context: Vec::new(),
1931 sender_taint: None,
1932 header: make_header(),
1933 convention: Some(PeerConvention::ResponseTerminal {
1934 request_id: "req-1".into(),
1935 status: ResponseTerminalStatus::Completed,
1936 }),
1937 content: "Done".into(),
1938 payload: Some(serde_json::json!({"ok": true})),
1939 handling_mode: None,
1940 });
1941 let json = serde_json::to_value(&input).unwrap();
1942 let parsed: Input = serde_json::from_value(json).unwrap();
1943 assert!(matches!(parsed, Input::Peer(_)));
1944 }
1945
1946 #[test]
1947 fn peer_input_response_progress_serde() {
1948 let input = Input::Peer(PeerInput {
1949 injected_context: Vec::new(),
1950 sender_taint: None,
1951 header: make_header(),
1952 convention: Some(PeerConvention::ResponseProgress {
1953 request_id: "req-1".into(),
1954 phase: ResponseProgressPhase::InProgress,
1955 }),
1956 content: "Working...".into(),
1957 payload: Some(serde_json::json!({"progress": "working"})),
1958 handling_mode: None,
1959 });
1960 let json = serde_json::to_value(&input).unwrap();
1961 let parsed: Input = serde_json::from_value(json).unwrap();
1962 assert!(matches!(parsed, Input::Peer(_)));
1963 }
1964
1965 #[test]
1966 fn flow_step_input_serde() {
1967 let input = Input::FlowStep(FlowStepInput {
1968 header: make_header(),
1969 step_id: "step-1".into(),
1970 content: ContentInput::Blocks(vec![
1971 meerkat_core::types::ContentBlock::Text {
1972 text: "analyze the data".into(),
1973 },
1974 meerkat_core::types::ContentBlock::Image {
1975 media_type: "image/png".into(),
1976 data: meerkat_core::types::ImageData::Inline {
1977 data: "abc123".into(),
1978 },
1979 },
1980 ]),
1981 turn_metadata: None,
1982 });
1983 let json = serde_json::to_value(&input).unwrap();
1984 assert_eq!(json["input_type"], "flow_step");
1985 let parsed: Input = serde_json::from_value(json).unwrap();
1986 assert!(matches!(parsed, Input::FlowStep(_)));
1987 }
1988
1989 #[test]
1990 fn external_event_input_serde() {
1991 let input = Input::ExternalEvent(ExternalEventInput {
1992 header: make_header(),
1993 event_type: "webhook.received".into(),
1994 payload: serde_json::json!({"url": "https://example.com"}),
1995 blocks: Some(vec![
1996 meerkat_core::types::ContentBlock::Text {
1997 text: "look".into(),
1998 },
1999 meerkat_core::types::ContentBlock::Image {
2000 media_type: "image/png".into(),
2001 data: meerkat_core::types::ImageData::Inline {
2002 data: "abc123".into(),
2003 },
2004 },
2005 ]),
2006 handling_mode: HandlingMode::Queue,
2007 render_metadata: None,
2008 });
2009 let json = serde_json::to_value(&input).unwrap();
2010 assert_eq!(json["input_type"], "external_event");
2011 let parsed: Input = serde_json::from_value(json).unwrap();
2012 assert!(matches!(parsed, Input::ExternalEvent(_)));
2013 }
2014
2015 #[test]
2016 fn legacy_external_event_payload_blocks_are_rejected() {
2017 let event = ExternalEventInput {
2020 header: make_header(),
2021 event_type: "webhook.received".into(),
2022 payload: serde_json::json!({
2023 "body": "see image",
2024 "blocks": [
2025 { "type": "text", "text": "caption text" },
2026 { "type": "image", "media_type": "image/png", "source": "inline", "data": "abc123" }
2027 ]
2028 }),
2029 blocks: None,
2030 handling_mode: HandlingMode::Queue,
2031 render_metadata: None,
2032 };
2033
2034 let err = reject_legacy_payload_blocks(&event)
2035 .expect_err("payload-level blocks must fail closed");
2036 assert!(matches!(err, BlobStoreError::Internal(_)));
2037 assert!(event.payload.get("blocks").is_some());
2039 assert!(event.blocks.is_none());
2040 }
2041
2042 #[test]
2043 fn external_event_payload_without_blocks_key_passes_rejection_gate() {
2044 let event = ExternalEventInput {
2045 header: make_header(),
2046 event_type: "webhook.received".into(),
2047 payload: serde_json::json!({ "body": "plain payload" }),
2048 blocks: Some(vec![meerkat_core::types::ContentBlock::Text {
2049 text: "typed owner content".into(),
2050 }]),
2051 handling_mode: HandlingMode::Queue,
2052 render_metadata: None,
2053 };
2054
2055 reject_legacy_payload_blocks(&event)
2056 .expect("payload without a legacy blocks key must pass");
2057 }
2058
2059 #[test]
2060 fn continuation_input_serde() {
2061 let input = Input::Continuation(ContinuationInput::detached_background_op_completed());
2062 let json = serde_json::to_value(&input).unwrap();
2063 assert_eq!(json["input_type"], "continuation");
2064 let parsed: Input = serde_json::from_value(json).unwrap();
2065 match parsed {
2066 Input::Continuation(continuation) => {
2067 assert_eq!(continuation.handling_mode, HandlingMode::Steer);
2068 assert_eq!(continuation.reason, "detached_background_op_completed");
2069 }
2070 other => panic!("Expected Continuation, got {other:?}"),
2071 }
2072 }
2073
2074 #[test]
2075 fn continuation_input_rejects_legacy_system_generated_tag() {
2076 let input = Input::Continuation(ContinuationInput::detached_background_op_completed());
2079 let mut json = serde_json::to_value(&input).unwrap();
2080 json["input_type"] = serde_json::Value::String("system_generated".into());
2081 serde_json::from_value::<Input>(json)
2082 .expect_err("legacy system_generated input_type tag must be rejected");
2083 }
2084
2085 #[test]
2086 fn operation_input_serde() {
2087 let input = Input::Operation(OperationInput {
2088 header: InputHeader {
2089 durability: InputDurability::Derived,
2090 ..make_header()
2091 },
2092 operation_id: OperationId::new(),
2093 event: OpEvent::Cancelled {
2094 id: OperationId::new(),
2095 },
2096 });
2097 let json = serde_json::to_value(&input).unwrap();
2098 assert_eq!(json["input_type"], "operation");
2099 let parsed: Input = serde_json::from_value(json).unwrap();
2100 assert!(matches!(parsed, Input::Operation(_)));
2101 }
2102
2103 #[test]
2104 fn operation_input_rejects_legacy_projected_tag() {
2105 let input = Input::Operation(OperationInput {
2108 header: InputHeader {
2109 durability: InputDurability::Derived,
2110 ..make_header()
2111 },
2112 operation_id: OperationId::new(),
2113 event: OpEvent::Cancelled {
2114 id: OperationId::new(),
2115 },
2116 });
2117 let mut json = serde_json::to_value(&input).unwrap();
2118 json["input_type"] = serde_json::Value::String("projected".into());
2119 serde_json::from_value::<Input>(json)
2120 .expect_err("legacy projected input_type tag must be rejected");
2121 }
2122
2123 #[test]
2124 fn legacy_dual_carrier_input_shapes_are_rejected() {
2125 let header = serde_json::to_value(make_header()).unwrap();
2130
2131 let legacy_prompt = serde_json::json!({
2132 "input_type": "prompt",
2133 "header": header.clone(),
2134 "text": "hello",
2135 "blocks": null
2136 });
2137 serde_json::from_value::<Input>(legacy_prompt)
2138 .expect_err("legacy prompt text+blocks shape must be rejected");
2139
2140 let legacy_peer = serde_json::json!({
2141 "input_type": "peer",
2142 "header": header.clone(),
2143 "convention": { "convention_type": "message" },
2144 "body": "hi there"
2145 });
2146 serde_json::from_value::<Input>(legacy_peer)
2147 .expect_err("legacy peer body+blocks shape must be rejected");
2148
2149 let legacy_flow_step = serde_json::json!({
2150 "input_type": "flow_step",
2151 "header": header,
2152 "step_id": "step-1",
2153 "instructions": "analyze the data"
2154 });
2155 serde_json::from_value::<Input>(legacy_flow_step)
2156 .expect_err("legacy flow-step instructions+blocks shape must be rejected");
2157 }
2158
2159 #[test]
2160 fn input_kind_id() {
2161 let prompt = Input::Prompt(PromptInput {
2162 injected_context: Vec::new(),
2163 header: make_header(),
2164 content: "hi".into(),
2165 typed_turn_appends: Vec::new(),
2166 turn_metadata: None,
2167 });
2168 assert_eq!(prompt.kind(), InputKind::Prompt);
2169
2170 let peer_msg = Input::Peer(PeerInput {
2171 injected_context: Vec::new(),
2172 sender_taint: None,
2173 header: make_header(),
2174 convention: Some(PeerConvention::Message),
2175 content: "hi".into(),
2176 payload: None,
2177 handling_mode: None,
2178 });
2179 assert_eq!(peer_msg.kind(), InputKind::PeerMessage);
2180
2181 let peer_req = Input::Peer(PeerInput {
2182 injected_context: Vec::new(),
2183 sender_taint: None,
2184 header: make_header(),
2185 convention: Some(PeerConvention::Request {
2186 request_id: "r".into(),
2187 intent: "i".into(),
2188 }),
2189 content: "hi".into(),
2190 payload: Some(serde_json::json!({"subject": "x"})),
2191 handling_mode: None,
2192 });
2193 assert_eq!(peer_req.kind(), InputKind::PeerRequest);
2194
2195 let continuation = Input::Continuation(ContinuationInput {
2196 header: make_header(),
2197 reason: "continue".into(),
2198 continuation_kind: ContinuationKind::Ordinary,
2199 handling_mode: HandlingMode::Steer,
2200 request_id: None,
2201 turn_tool_overlay: None,
2202 context_append: None,
2203 turn_append: None,
2204 });
2205 assert_eq!(continuation.kind(), InputKind::Continuation);
2206
2207 let operation = Input::Operation(OperationInput {
2208 header: make_header(),
2209 operation_id: OperationId::new(),
2210 event: OpEvent::Cancelled {
2211 id: OperationId::new(),
2212 },
2213 });
2214 assert_eq!(operation.kind(), InputKind::Operation);
2215 }
2216
2217 #[test]
2218 fn input_source_variants() {
2219 let sources = vec![
2220 InputOrigin::Operator,
2221 InputOrigin::Peer {
2222 peer_id: "p1".into(),
2223 display_identity: None,
2224 runtime_id: None,
2225 },
2226 InputOrigin::Flow {
2227 flow_id: "f1".into(),
2228 step_index: 0,
2229 },
2230 InputOrigin::System,
2231 InputOrigin::External {
2232 source_name: "webhook".into(),
2233 },
2234 ];
2235 for source in sources {
2236 let json = serde_json::to_value(&source).unwrap();
2237 let parsed: InputOrigin = serde_json::from_value(json).unwrap();
2238 assert_eq!(source, parsed);
2239 }
2240 }
2241
2242 #[test]
2243 fn input_durability_serde() {
2244 for d in [
2245 InputDurability::Durable,
2246 InputDurability::Ephemeral,
2247 InputDurability::Derived,
2248 ] {
2249 let json = serde_json::to_value(d).unwrap();
2250 let parsed: InputDurability = serde_json::from_value(json).unwrap();
2251 assert_eq!(d, parsed);
2252 }
2253 }
2254
2255 #[test]
2256 fn peer_input_without_handling_mode_deserializes_as_none() {
2257 let json = serde_json::json!({
2259 "input_type": "peer",
2260 "header": serde_json::to_value(make_header()).unwrap(),
2261 "convention": { "convention_type": "message" },
2262 "content": "hello"
2263 });
2264 let parsed: Input = serde_json::from_value(json).unwrap();
2265 match parsed {
2266 Input::Peer(p) => assert!(p.handling_mode.is_none()),
2267 other => panic!("Expected Peer, got {other:?}"),
2268 }
2269 }
2270
2271 #[test]
2272 fn peer_input_with_queue_handling_mode_roundtrips() {
2273 let input = Input::Peer(PeerInput {
2274 injected_context: Vec::new(),
2275 sender_taint: None,
2276 header: make_header(),
2277 convention: Some(PeerConvention::Message),
2278 content: "hi".into(),
2279 payload: None,
2280 handling_mode: Some(HandlingMode::Queue),
2281 });
2282 let json = serde_json::to_value(&input).unwrap();
2283 assert_eq!(json["handling_mode"], "queue");
2284 let parsed: Input = serde_json::from_value(json).unwrap();
2285 match parsed {
2286 Input::Peer(p) => assert_eq!(p.handling_mode, Some(HandlingMode::Queue)),
2287 other => panic!("Expected Peer, got {other:?}"),
2288 }
2289 }
2290
2291 #[test]
2292 fn peer_response_terminal_input_owns_wire_status_mapping() {
2293 let peer_id = meerkat_core::comms::PeerId::from_uuid(
2294 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000161").unwrap(),
2295 );
2296 let display_name = meerkat_core::comms::PeerName::new("analyst").unwrap();
2297 let request_id = meerkat_core::PeerCorrelationId::from_uuid(
2298 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap(),
2299 );
2300 let input = peer_response_terminal_input(
2301 peer_id,
2302 Some(display_name),
2303 request_id,
2304 meerkat_contracts::PeerResponseTerminalStatusWire::Completed,
2305 serde_json::json!({"ok": true}),
2306 );
2307
2308 match input {
2309 Input::Peer(PeerInput {
2310 header:
2311 InputHeader {
2312 source:
2313 InputOrigin::Peer {
2314 peer_id,
2315 display_identity,
2316 runtime_id,
2317 },
2318 durability: InputDurability::Durable,
2319 correlation_id,
2320 ..
2321 },
2322 convention: Some(PeerConvention::ResponseTerminal { request_id, status }),
2323 payload: Some(payload),
2324 handling_mode: None,
2325 ..
2326 }) => {
2327 assert_eq!(peer_id, "00000000-0000-4000-8000-000000000161");
2328 assert_eq!(display_identity.as_deref(), Some("analyst"));
2329 assert_eq!(runtime_id, None);
2330 assert_eq!(request_id, "00000000-0000-4000-8000-000000000162");
2331 assert_eq!(
2332 correlation_id,
2333 Some(CorrelationId::from_uuid(
2334 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap()
2335 ))
2336 );
2337 assert_eq!(status, ResponseTerminalStatus::Completed);
2338 assert_eq!(payload["ok"], true);
2339 }
2340 other => panic!("expected terminal peer input, got {other:?}"),
2341 }
2342 }
2343
2344 #[test]
2345 fn peer_response_terminal_validation_is_structural_only() {
2346 let peer_id = meerkat_core::comms::PeerId::from_uuid(
2347 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000161").unwrap(),
2348 );
2349 let display_name = meerkat_core::comms::PeerName::new("analyst").unwrap();
2350 let request_id = meerkat_core::PeerCorrelationId::from_uuid(
2351 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap(),
2352 );
2353 let input = peer_response_terminal_input(
2354 peer_id,
2355 Some(display_name),
2356 request_id,
2357 meerkat_contracts::PeerResponseTerminalStatusWire::Cancelled,
2358 serde_json::json!({"ok": false}),
2359 );
2360
2361 validate_peer_response_terminal_fact(&input)
2362 .expect("status support is generated admission authority, structural fact validation should pass");
2363 }
2364
2365 #[test]
2366 fn peer_input_with_steer_handling_mode_roundtrips() {
2367 let input = Input::Peer(PeerInput {
2368 injected_context: Vec::new(),
2369 sender_taint: None,
2370 header: make_header(),
2371 convention: Some(PeerConvention::Message),
2372 content: "hi".into(),
2373 payload: None,
2374 handling_mode: Some(HandlingMode::Steer),
2375 });
2376 let json = serde_json::to_value(&input).unwrap();
2377 assert_eq!(json["handling_mode"], "steer");
2378 let parsed: Input = serde_json::from_value(json).unwrap();
2379 match parsed {
2380 Input::Peer(p) => assert_eq!(p.handling_mode, Some(HandlingMode::Steer)),
2381 other => panic!("Expected Peer, got {other:?}"),
2382 }
2383 }
2384
2385 #[test]
2386 fn peer_input_handling_mode_not_serialized_when_none() {
2387 let input = Input::Peer(PeerInput {
2388 injected_context: Vec::new(),
2389 sender_taint: None,
2390 header: make_header(),
2391 convention: Some(PeerConvention::Message),
2392 content: "hi".into(),
2393 payload: None,
2394 handling_mode: None,
2395 });
2396 let json = serde_json::to_value(&input).unwrap();
2397 assert!(json.get("handling_mode").is_none());
2398 }
2399}