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