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 flow_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 flow_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_prompt_text(peer: &PeerInput) -> String {
758 peer_projection_from_peer_input(peer)
759 .map(|projection| {
760 let prompt = projection.prompt_text();
761 if prompt.is_empty() {
762 peer.content.text_content()
763 } else {
764 prompt
765 }
766 })
767 .unwrap_or_else(|| peer.content.text_content())
768}
769
770pub(crate) fn input_prompt_text(input: &Input) -> String {
771 match input {
772 Input::Prompt(p) => p.content.text_content(),
773 Input::Peer(p) => peer_prompt_text(p),
774 Input::FlowStep(f) => f.content.text_content(),
775 Input::ExternalEvent(e) => external_event_projection_text(e),
776 Input::Continuation(continuation) => format!("[Continuation] {}", continuation.reason),
777 Input::Operation(operation) => {
778 format!(
779 "[Operation {}] {:?}",
780 operation.operation_id, operation.event
781 )
782 }
783 }
784}
785
786fn external_event_projection_text(event: &ExternalEventInput) -> String {
787 let source_name = match &event.header.source {
788 InputOrigin::External { source_name } if !source_name.trim().is_empty() => {
789 source_name.as_str()
790 }
791 _ => event.event_type.as_str(),
792 };
793 let body = event
794 .payload
795 .get("body")
796 .and_then(serde_json::Value::as_str)
797 .map(str::trim);
798
799 meerkat_core::interaction::format_external_event_projection(source_name, body)
800}
801
802fn peer_notice_renderable(peer: &PeerInput) -> Option<CoreRenderable> {
803 let (peer_id, display_name) = match &peer.header.source {
804 InputOrigin::Peer {
805 peer_id,
806 display_identity,
807 ..
808 } => (peer_id.clone(), display_identity.clone()),
809 _ => return None,
810 };
811 use meerkat_core::types::CommsNoticeKind;
812 let (kind, request_id, intent, status) = match &peer.convention {
813 Some(PeerConvention::Message) | None => (CommsNoticeKind::Message, None, None, None),
814 Some(PeerConvention::Request { request_id, intent }) => (
815 CommsNoticeKind::Request,
816 Some(request_id.clone()),
817 Some(intent.clone()),
818 None,
819 ),
820 Some(PeerConvention::ResponseProgress { request_id, phase }) => (
821 CommsNoticeKind::ResponseProgress,
822 Some(request_id.clone()),
823 None,
824 Some(format!("{phase:?}")),
825 ),
826 Some(PeerConvention::ResponseTerminal { request_id, status }) => (
827 CommsNoticeKind::ResponseTerminal,
828 Some(request_id.clone()),
829 None,
830 Some(format!("{status:?}")),
831 ),
832 };
833 let summary = match kind {
834 CommsNoticeKind::Request => intent.as_ref().map_or_else(
835 || "Peer request".to_string(),
836 |intent| format!("Peer request: {intent}"),
837 ),
838 CommsNoticeKind::ResponseProgress => "Peer response progress".to_string(),
839 CommsNoticeKind::ResponseTerminal => "Peer response terminal".to_string(),
840 CommsNoticeKind::Message | CommsNoticeKind::Other(_) => "Peer message".to_string(),
841 };
842 let content = match &peer.content {
843 ContentInput::Text(body) if body.is_empty() => Vec::new(),
844 ContentInput::Text(body) => {
845 vec![meerkat_core::types::ContentBlock::Text { text: body.clone() }]
846 }
847 ContentInput::Blocks(blocks) => blocks.clone(),
848 };
849 let notice_peer = meerkat_core::comms::PeerId::parse(&peer_id)
856 .ok()
857 .map(|id| SystemNoticePeer { id, display_name });
858 Some(CoreRenderable::SystemNotice {
859 kind: SystemNoticeKind::Comms,
860 body: Some(summary.clone()),
861 blocks: vec![SystemNoticeBlock::Comms {
862 kind,
863 direction: SystemNoticeDirection::Incoming,
864 peer: notice_peer,
865 sender_taint: peer.sender_taint,
869 request_id,
870 intent,
871 status,
872 summary: Some(summary),
873 payload: peer.payload.clone(),
874 content,
875 }],
876 })
877}
878
879fn external_event_notice_renderable(event: &ExternalEventInput) -> CoreRenderable {
880 let source = match &event.header.source {
881 InputOrigin::External { source_name } if !source_name.trim().is_empty() => {
882 source_name.clone()
883 }
884 _ => event.event_type.clone(),
885 };
886 let body = event
887 .payload
888 .get("body")
889 .and_then(serde_json::Value::as_str)
890 .map(str::trim)
891 .filter(|body| !body.is_empty())
892 .map(ToOwned::to_owned);
893 let summary = body.as_ref().map_or_else(
894 || format!("External event via {source}"),
895 std::clone::Clone::clone,
896 );
897 CoreRenderable::SystemNotice {
898 kind: SystemNoticeKind::ExternalEvent,
899 body: Some(summary.clone()),
900 blocks: vec![SystemNoticeBlock::ExternalEvent {
901 source,
902 event_type: event.event_type.clone(),
903 summary: Some(summary),
904 body,
905 payload: Some(event.payload.clone()),
906 content: event.blocks.clone().unwrap_or_default(),
907 }],
908 }
909}
910
911fn input_to_append(input: &Input) -> Option<ConversationAppend> {
912 let (role, content) = match input {
920 Input::Prompt(p)
921 if !p.typed_turn_appends.is_empty()
922 && match &p.content {
923 ContentInput::Text(text) => text.trim().is_empty(),
924 ContentInput::Blocks(blocks) => blocks.is_empty(),
925 } =>
926 {
927 return None;
928 }
929 Input::Prompt(p) => match &p.content {
930 ContentInput::Blocks(blocks) => (
931 ConversationAppendRole::User,
932 CoreRenderable::Blocks {
933 blocks: blocks.clone(),
934 },
935 ),
936 ContentInput::Text(_) => (
937 ConversationAppendRole::User,
938 CoreRenderable::Text {
939 text: input_prompt_text(input),
940 },
941 ),
942 },
943 Input::Peer(p) => peer_notice_renderable(p)
944 .map(|content| (ConversationAppendRole::SystemNotice, content))?,
945 Input::FlowStep(f) => (
946 ConversationAppendRole::SystemNotice,
947 CoreRenderable::SystemNotice {
948 kind: SystemNoticeKind::Generic,
949 body: Some(format!("Flow step {}", f.step_id)),
950 blocks: vec![SystemNoticeBlock::RuntimeNotice {
951 category: "flow_step".to_string(),
952 detail: Some(f.content.text_content()),
953 payload: None,
954 }],
955 },
956 ),
957 Input::ExternalEvent(e) => (
958 ConversationAppendRole::SystemNotice,
959 external_event_notice_renderable(e),
960 ),
961 Input::Continuation(continuation) => return continuation.turn_append.clone(),
962 Input::Operation(_) => return None,
963 };
964
965 Some(ConversationAppend { role, content })
966}
967
968fn input_to_context_append(input: &Input) -> Option<ConversationContextAppend> {
969 let (projection, content) = match input {
970 Input::Continuation(continuation) => {
971 return continuation.context_append.clone();
972 }
973 Input::Peer(peer) => {
974 let projection = peer_projection_from_peer_input(peer)?;
975 let content = peer_notice_renderable(peer)?;
976 (projection, content)
977 }
978 _ => return None,
979 };
980
981 Some(ConversationContextAppend {
982 key: projection.context_key()?,
983 content,
984 })
985}
986
987fn peer_response_terminal_context_append(
988 peer: &PeerInput,
989) -> Result<Option<ConversationContextAppend>, PeerResponseTerminalFactError> {
990 let Some(fact) = peer_response_terminal_fact(peer)? else {
991 return Ok(None);
992 };
993
994 Ok(Some(ConversationContextAppend {
995 key: fact.context_key(),
996 content: CoreRenderable::SystemNotice {
997 kind: SystemNoticeKind::Comms,
998 body: Some("Peer terminal response context".to_string()),
999 blocks: vec![SystemNoticeBlock::Comms {
1000 kind: meerkat_core::types::CommsNoticeKind::ResponseTerminal,
1001 direction: SystemNoticeDirection::Incoming,
1002 peer: Some(SystemNoticePeer {
1003 id: fact.source.route_identity.peer_id(),
1004 display_name: Some(fact.source.display_identity.to_string()),
1005 }),
1006 sender_taint: None,
1010 request_id: Some(fact.correlation_id.to_string()),
1011 intent: None,
1012 status: Some(fact.status.label().to_string()),
1013 summary: Some("Peer terminal response".to_string()),
1014 payload: fact.render_payload.as_ref().cloned(),
1015 content: Vec::new(),
1016 }],
1017 },
1018 }))
1019}
1020
1021fn injected_context_appends(entries: &[ContentInput]) -> Vec<ConversationAppend> {
1025 entries
1026 .iter()
1027 .map(|entry| ConversationAppend {
1028 role: ConversationAppendRole::InjectedContext,
1029 content: match entry {
1030 ContentInput::Blocks(blocks) => CoreRenderable::Blocks {
1031 blocks: blocks.clone(),
1032 },
1033 ContentInput::Text(text) => CoreRenderable::Text { text: text.clone() },
1034 },
1035 })
1036 .collect()
1037}
1038
1039pub(crate) fn runtime_input_projection(
1040 input: &Input,
1041) -> crate::ingress_types::RuntimeInputProjection {
1042 crate::ingress_types::RuntimeInputProjection {
1043 injected_context_appends: match input {
1044 Input::Prompt(prompt) => injected_context_appends(&prompt.injected_context),
1045 Input::Peer(peer) => injected_context_appends(&peer.injected_context),
1046 _ => Vec::new(),
1047 },
1048 append: input_to_append(input),
1049 additional_appends: match input {
1050 Input::Prompt(prompt) => prompt.typed_turn_appends.clone(),
1051 _ => Vec::new(),
1052 },
1053 context_append: input_to_context_append(input),
1054 peer_response_terminal: None,
1055 }
1056}
1057
1058pub(crate) fn runtime_input_projection_for_machine_batch(
1059 input: &Input,
1060) -> crate::ingress_types::RuntimeInputProjection {
1061 let mut projection = runtime_input_projection(input);
1062 if let Input::Peer(peer) = input
1063 && let Ok(Some(context_append)) = peer_response_terminal_context_append(peer)
1064 {
1065 projection.context_append = Some(context_append);
1066 if let Ok(fact) = peer_response_terminal_fact(peer) {
1070 projection.peer_response_terminal = fact;
1071 }
1072 }
1073 projection
1074}
1075
1076pub(crate) fn context_append_to_pending_system_context_append(
1077 append: &ConversationContextAppend,
1078 peer_response_terminal: Option<&meerkat_core::PeerResponseTerminalFact>,
1079) -> meerkat_core::PendingSystemContextAppend {
1080 meerkat_core::PendingSystemContextAppend {
1081 content: append.content.clone(),
1082 source: Some(append.key.clone()),
1083 idempotency_key: Some(append.key.clone()),
1084 source_kind: meerkat_core::session::SystemContextSource::Normal,
1086 peer_response_terminal: peer_response_terminal.cloned(),
1091 accepted_at: meerkat_core::time_compat::SystemTime::now(),
1092 }
1093}
1094
1095pub(crate) fn projection_to_pending_system_context_appends(
1096 input_id: &InputId,
1097 projection: &crate::ingress_types::RuntimeInputProjection,
1098) -> Vec<meerkat_core::PendingSystemContextAppend> {
1099 if let Some(append) = projection.context_append.as_ref() {
1100 return std::iter::once(context_append_to_pending_system_context_append(
1101 append,
1102 projection.peer_response_terminal.as_ref(),
1103 ))
1104 .filter(|append| !append.content.render_text().trim().is_empty())
1105 .collect();
1106 }
1107
1108 projection
1109 .append
1110 .as_ref()
1111 .map(|append| {
1112 let key = format!("runtime:steer:{input_id}");
1118 meerkat_core::PendingSystemContextAppend {
1119 content: append.content.clone(),
1120 source: Some(key.clone()),
1121 idempotency_key: Some(key),
1122 source_kind: meerkat_core::session::SystemContextSource::RuntimeSteer,
1123 peer_response_terminal: None,
1125 accepted_at: meerkat_core::time_compat::SystemTime::now(),
1126 }
1127 })
1128 .into_iter()
1129 .filter(|append| !append.content.render_text().trim().is_empty())
1130 .collect()
1131}
1132
1133#[cfg(test)]
1134#[allow(clippy::unwrap_used, clippy::panic)]
1135mod tests {
1136 use super::*;
1137 use chrono::Utc;
1138
1139 fn make_header() -> InputHeader {
1140 InputHeader {
1141 id: InputId::new(),
1142 timestamp: Utc::now(),
1143 source: InputOrigin::Operator,
1144 durability: InputDurability::Durable,
1145 visibility: InputVisibility::default(),
1146 idempotency_key: None,
1147 supersession_key: None,
1148 correlation_id: None,
1149 }
1150 }
1151
1152 fn typed_runtime_notice_append(detail: &str) -> ConversationAppend {
1153 ConversationAppend {
1154 role: ConversationAppendRole::SystemNotice,
1155 content: CoreRenderable::SystemNotice {
1156 kind: meerkat_core::types::SystemNoticeKind::Generic,
1157 body: Some(detail.to_string()),
1158 blocks: vec![meerkat_core::types::SystemNoticeBlock::RuntimeNotice {
1159 category: "test".to_string(),
1160 detail: Some(detail.to_string()),
1161 payload: None,
1162 }],
1163 },
1164 }
1165 }
1166
1167 #[test]
1168 fn prompt_input_serde() {
1169 let input = Input::Prompt(PromptInput {
1170 injected_context: Vec::new(),
1171 header: make_header(),
1172 content: "hello".into(),
1173 typed_turn_appends: Vec::new(),
1174 turn_metadata: None,
1175 });
1176 let json = serde_json::to_value(&input).unwrap();
1177 assert_eq!(json["input_type"], "prompt");
1178 let parsed: Input = serde_json::from_value(json).unwrap();
1179 assert!(matches!(parsed, Input::Prompt(_)));
1180 }
1181
1182 #[test]
1183 fn prompt_input_typed_turn_appends_project_without_user_text() {
1184 let append = typed_runtime_notice_append("peer delivery");
1185 let input = Input::Prompt(PromptInput {
1186 injected_context: Vec::new(),
1187 header: make_header(),
1188 content: ContentInput::Text(String::new()),
1189 typed_turn_appends: vec![append.clone()],
1190 turn_metadata: None,
1191 });
1192
1193 let projection = runtime_input_projection(&input);
1194 assert!(
1195 projection.append.is_none(),
1196 "empty runtime-authored prompt carrier must not synthesize a user append"
1197 );
1198 assert_eq!(projection.additional_appends, vec![append]);
1199 }
1200
1201 #[test]
1206 fn prompt_input_injected_context_projects_before_user_append() {
1207 let input = Input::Prompt(PromptInput {
1208 injected_context: vec![
1209 ContentInput::Text("ambient alpha".to_string()),
1210 ContentInput::Text("ambient beta".to_string()),
1211 ],
1212 header: make_header(),
1213 content: "the prompt".into(),
1214 typed_turn_appends: Vec::new(),
1215 turn_metadata: None,
1216 });
1217
1218 let projection = runtime_input_projection(&input);
1219 assert_eq!(projection.injected_context_appends.len(), 2);
1220 assert!(
1221 projection
1222 .injected_context_appends
1223 .iter()
1224 .all(|append| { append.role == ConversationAppendRole::InjectedContext })
1225 );
1226 assert_eq!(
1227 projection.injected_context_appends[0].content,
1228 CoreRenderable::Text {
1229 text: "ambient alpha".to_string()
1230 }
1231 );
1232 assert_eq!(
1233 projection.injected_context_appends[1].content,
1234 CoreRenderable::Text {
1235 text: "ambient beta".to_string()
1236 }
1237 );
1238 assert!(
1239 projection.additional_appends.is_empty(),
1240 "injected context must not ride the generic typed_turn_appends carrier"
1241 );
1242 assert!(projection.append.is_some(), "user append must survive");
1243 }
1244
1245 #[test]
1248 fn peer_input_injected_context_projects_before_peer_append() {
1249 let mut header = make_header();
1250 header.source = InputOrigin::Peer {
1251 peer_id: "peer-1".into(),
1252 display_identity: Some("Peer One".into()),
1253 runtime_id: None,
1254 };
1255 let input = Input::Peer(PeerInput {
1256 injected_context: vec![ContentInput::Text("supervisor ambient".to_string())],
1257 sender_taint: None,
1258 header,
1259 convention: Some(PeerConvention::Message),
1260 content: "work content".into(),
1261 payload: None,
1262 handling_mode: None,
1263 });
1264
1265 let projection = runtime_input_projection(&input);
1266 assert_eq!(projection.injected_context_appends.len(), 1);
1267 assert_eq!(
1268 projection.injected_context_appends[0].role,
1269 ConversationAppendRole::InjectedContext
1270 );
1271 assert!(
1272 projection.append.is_some(),
1273 "peer work append must survive alongside injected context"
1274 );
1275 }
1276
1277 #[test]
1280 fn prompt_input_injected_context_serde_default_and_omission() {
1281 let input = Input::Prompt(PromptInput {
1282 injected_context: vec![ContentInput::Text("ambient".to_string())],
1283 header: make_header(),
1284 content: "hello".into(),
1285 typed_turn_appends: Vec::new(),
1286 turn_metadata: None,
1287 });
1288 let json = serde_json::to_value(&input).unwrap();
1289 assert!(json.get("injected_context").is_some());
1290 let parsed: Input = serde_json::from_value(json).unwrap();
1291 let Input::Prompt(prompt) = parsed else {
1292 panic!("expected prompt input");
1293 };
1294 assert_eq!(prompt.injected_context.len(), 1);
1295
1296 let empty = Input::Prompt(PromptInput {
1297 injected_context: Vec::new(),
1298 header: make_header(),
1299 content: "hello".into(),
1300 typed_turn_appends: Vec::new(),
1301 turn_metadata: None,
1302 });
1303 let mut json = serde_json::to_value(&empty).unwrap();
1304 assert!(
1305 json.get("injected_context").is_none(),
1306 "empty injected context must be omitted on the wire"
1307 );
1308 json.as_object_mut().unwrap().remove("injected_context");
1310 let parsed: Input = serde_json::from_value(json).unwrap();
1311 let Input::Prompt(prompt) = parsed else {
1312 panic!("expected prompt input");
1313 };
1314 assert!(prompt.injected_context.is_empty());
1315 }
1316
1317 #[test]
1318 fn prompt_input_typed_turn_appends_serde_roundtrip() {
1319 let append = typed_runtime_notice_append("typed appends persist");
1320 let input = Input::Prompt(PromptInput {
1321 injected_context: Vec::new(),
1322 header: make_header(),
1323 content: ContentInput::Text(String::new()),
1324 typed_turn_appends: vec![append.clone()],
1325 turn_metadata: None,
1326 });
1327
1328 let json = serde_json::to_value(&input).unwrap();
1329 let parsed: Input = serde_json::from_value(json).unwrap();
1330 let Input::Prompt(prompt) = parsed else {
1331 panic!("expected prompt input");
1332 };
1333 assert_eq!(prompt.content.text_content(), "");
1334 assert_eq!(prompt.typed_turn_appends, vec![append]);
1335 }
1336
1337 #[test]
1338 fn peer_input_message_serde() {
1339 let input = Input::Peer(PeerInput {
1340 injected_context: Vec::new(),
1341 sender_taint: None,
1342 header: make_header(),
1343 convention: Some(PeerConvention::Message),
1344 content: "hi there".into(),
1345 payload: None,
1346 handling_mode: None,
1347 });
1348 let json = serde_json::to_value(&input).unwrap();
1349 assert_eq!(json["input_type"], "peer");
1350 let parsed: Input = serde_json::from_value(json).unwrap();
1351 assert!(matches!(parsed, Input::Peer(_)));
1352 }
1353
1354 #[test]
1355 fn peer_message_blocks_preserve_typed_comms_content_without_prefix_injection() {
1356 let peer_id = "018f6f79-7a82-7c4e-a552-a3b86f963005";
1357 let mut header = make_header();
1358 header.source = InputOrigin::Peer {
1359 peer_id: peer_id.into(),
1360 display_identity: Some("display-agent".into()),
1361 runtime_id: None,
1362 };
1363 let input = Input::Peer(PeerInput {
1364 injected_context: Vec::new(),
1365 sender_taint: None,
1366 header,
1367 convention: Some(PeerConvention::Message),
1368 content: ContentInput::Blocks(vec![
1369 meerkat_core::types::ContentBlock::Text {
1370 text: "caption".into(),
1371 },
1372 meerkat_core::types::ContentBlock::Image {
1373 media_type: "image/png".into(),
1374 data: "abc".into(),
1375 },
1376 ]),
1377 payload: None,
1378 handling_mode: None,
1379 });
1380
1381 let Input::Peer(peer) = &input else {
1382 panic!("expected peer input");
1383 };
1384 assert_eq!(
1385 peer_projection_from_peer_input(peer)
1386 .and_then(|projection| projection.block_prefix_text())
1387 .as_deref(),
1388 Some(format!("Peer message from {peer_id}").as_str())
1389 );
1390
1391 let projection = runtime_input_projection(&input);
1392 let append = projection.append.expect("conversation append");
1393 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1394 panic!("expected typed system notice");
1395 };
1396 let Some(meerkat_core::types::SystemNoticeBlock::Comms { content, peer, .. }) =
1397 blocks.first()
1398 else {
1399 panic!("expected comms block");
1400 };
1401 assert_eq!(
1402 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1403 Some("display-agent")
1404 );
1405 assert_eq!(
1406 content.first(),
1407 Some(&meerkat_core::types::ContentBlock::Text {
1408 text: "caption".into()
1409 })
1410 );
1411 }
1412
1413 #[test]
1420 fn peer_message_sender_taint_reaches_typed_comms_notice_and_model_projection() {
1421 use meerkat_core::comms::SenderContentTaint;
1422
1423 let notice_block = |declared: Option<SenderContentTaint>| {
1424 let mut header = make_header();
1425 header.source = InputOrigin::Peer {
1426 peer_id: "018f6f79-7a82-7c4e-a552-a3b86f963005".into(),
1427 display_identity: Some("display-agent".into()),
1428 runtime_id: None,
1429 };
1430 let input = Input::Peer(PeerInput {
1431 injected_context: Vec::new(),
1432 sender_taint: declared,
1433 header,
1434 convention: Some(PeerConvention::Message),
1435 content: "hello from peer".into(),
1436 payload: None,
1437 handling_mode: None,
1438 });
1439 let projection = runtime_input_projection(&input);
1440 let append = projection.append.expect("conversation append");
1441 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1442 panic!("expected typed system notice");
1443 };
1444 blocks.first().cloned().expect("comms block")
1445 };
1446
1447 let tainted_block = notice_block(Some(SenderContentTaint::Tainted));
1448 let clean_block = notice_block(Some(SenderContentTaint::Clean));
1449 let undeclared_block = notice_block(None);
1450
1451 let taint_of = |block: &meerkat_core::types::SystemNoticeBlock| {
1452 let meerkat_core::types::SystemNoticeBlock::Comms { sender_taint, .. } = block else {
1453 panic!("expected comms block");
1454 };
1455 *sender_taint
1456 };
1457 assert_eq!(taint_of(&tainted_block), Some(SenderContentTaint::Tainted));
1458 assert_eq!(taint_of(&clean_block), Some(SenderContentTaint::Clean));
1459 assert_eq!(
1460 taint_of(&undeclared_block),
1461 None,
1462 "no declaration must stay None in the transcript, never coalesced into Clean"
1463 );
1464
1465 let tainted_text = tainted_block.model_projection_text();
1466 let clean_text = clean_block.model_projection_text();
1467 let undeclared_text = undeclared_block.model_projection_text();
1468 assert!(
1469 tainted_text.contains("[sender declared this content tainted]"),
1470 "declared taint must be model-visible: {tainted_text}"
1471 );
1472 assert_eq!(
1473 clean_text, undeclared_text,
1474 "Clean and no-declaration deliberately render identically; the typed field is the carrier"
1475 );
1476 assert!(!clean_text.contains("tainted"));
1477 }
1478
1479 #[test]
1480 fn peer_response_terminal_context_is_deferred_to_machine_batch_projection() {
1481 let route_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f2";
1482 let request_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f1";
1483 let mut header = make_header();
1484 header.source = InputOrigin::Peer {
1485 peer_id: route_id.into(),
1486 display_identity: Some("display-agent".into()),
1487 runtime_id: None,
1488 };
1489 let input = Input::Peer(PeerInput {
1490 injected_context: Vec::new(),
1491 sender_taint: None,
1492 header,
1493 convention: Some(PeerConvention::ResponseTerminal {
1494 request_id: request_id.into(),
1495 status: ResponseTerminalStatus::Completed,
1496 }),
1497 content: "response body".into(),
1498 payload: Some(serde_json::json!({"answer":"ok"})),
1499 handling_mode: None,
1500 });
1501
1502 let Input::Peer(peer) = &input else {
1503 panic!("expected peer input");
1504 };
1505 let expected_canonical_key = format!("peer_response_terminal:{route_id}:{request_id}");
1506 assert!(
1507 peer_projection_from_peer_input(peer).is_none(),
1508 "terminal peer response projection must not be built before machine batch selection"
1509 );
1510
1511 let projection = runtime_input_projection(&input);
1512 assert!(
1513 projection.context_append.is_none(),
1514 "admission projection must not store terminal peer response context"
1515 );
1516 let projection = runtime_input_projection_for_machine_batch(&input);
1517 let context = projection.context_append.expect("context append");
1518 assert_eq!(context.key, expected_canonical_key);
1519 let CoreRenderable::SystemNotice { blocks, .. } = context.content else {
1520 panic!("expected typed context");
1521 };
1522 let Some(meerkat_core::types::SystemNoticeBlock::Comms { peer, .. }) = blocks.first()
1523 else {
1524 panic!("expected comms block");
1525 };
1526 assert_eq!(
1527 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1528 Some("display-agent")
1529 );
1530 assert_eq!(
1531 peer.as_ref().map(|peer| peer.id),
1532 Some(meerkat_core::comms::PeerId::parse(route_id).expect("valid route id"))
1533 );
1534 }
1535
1536 #[test]
1537 fn steer_projection_uses_context_append_as_pending_system_context() {
1538 let input_id = InputId::new();
1539 let projection = crate::ingress_types::RuntimeInputProjection {
1540 injected_context_appends: Vec::new(),
1541 append: Some(ConversationAppend {
1542 role: ConversationAppendRole::SystemNotice,
1543 content: CoreRenderable::Text {
1544 text: "ordinary append must lose to context append".into(),
1545 },
1546 }),
1547 additional_appends: Vec::new(),
1548 context_append: Some(ConversationContextAppend {
1549 key: "peer_response_terminal:peer:req".into(),
1550 content: CoreRenderable::Text {
1551 text: "terminal response is ready".into(),
1552 },
1553 }),
1554 peer_response_terminal: None,
1555 };
1556
1557 let appends = projection_to_pending_system_context_appends(&input_id, &projection);
1558
1559 assert_eq!(appends.len(), 1);
1560 assert_eq!(
1561 appends[0].content.render_text(),
1562 "terminal response is ready"
1563 );
1564 assert_eq!(
1565 appends[0].source.as_deref(),
1566 Some("peer_response_terminal:peer:req")
1567 );
1568 assert_eq!(
1569 appends[0].idempotency_key.as_deref(),
1570 Some("peer_response_terminal:peer:req")
1571 );
1572 }
1573
1574 #[test]
1575 fn continuation_projection_can_carry_runtime_context_append() {
1576 let input = Input::Continuation(ContinuationInput {
1577 header: make_header(),
1578 reason: "workgraph_attention".into(),
1579 continuation_kind: ContinuationKind::WorkgraphAttention,
1580 handling_mode: HandlingMode::Steer,
1581 request_id: Some("binding-1".into()),
1582 flow_tool_overlay: Some(TurnToolOverlay {
1583 allowed_tools: Some(vec!["workgraph_add_evidence".into()]),
1584 blocked_tools: None,
1585 dispatch_context: Default::default(),
1586 }),
1587 context_append: Some(ConversationContextAppend {
1588 key: "workgraph_attention:binding-1:2:5".into(),
1589 content: CoreRenderable::Text {
1590 text: "WorkGraph attention projection".into(),
1591 },
1592 }),
1593 turn_append: None,
1594 });
1595 let projection = runtime_input_projection_for_machine_batch(&input);
1596 let appends = projection_to_pending_system_context_appends(input.id(), &projection);
1597
1598 assert_eq!(appends.len(), 1);
1599 assert_eq!(
1600 appends[0].content.render_text(),
1601 "WorkGraph attention projection"
1602 );
1603 assert_eq!(
1604 appends[0].source.as_deref(),
1605 Some("workgraph_attention:binding-1:2:5")
1606 );
1607 let metadata = crate::runtime_loop::for_input(
1608 &input,
1609 crate::ingress_types::RuntimeInputSemantics {
1610 boundary: meerkat_core::lifecycle::run_primitive::RunApplyBoundary::RunStart,
1611 execution_kind: meerkat_core::lifecycle::RuntimeExecutionKind::ContentTurn,
1612 execution_handling_mode: None,
1613 peer_response_terminal_apply_intent: None,
1614 live_interrupt_required: false,
1615 },
1616 );
1617 assert_eq!(
1618 metadata
1619 .flow_tool_overlay
1620 .and_then(|overlay| overlay.allowed_tools),
1621 Some(vec!["workgraph_add_evidence".into()])
1622 );
1623 }
1624
1625 #[test]
1626 fn steer_projection_falls_back_to_ordinary_peer_append() {
1627 let mut header = make_header();
1628 header.source = InputOrigin::Peer {
1629 peer_id: "peer-a".into(),
1630 display_identity: Some("Peer A".into()),
1631 runtime_id: None,
1632 };
1633 let input = Input::Peer(PeerInput {
1634 injected_context: Vec::new(),
1635 sender_taint: None,
1636 header,
1637 convention: Some(PeerConvention::Message),
1638 content: "please look at this while you work".into(),
1639 payload: None,
1640 handling_mode: Some(HandlingMode::Steer),
1641 });
1642 let input_id = input.id().clone();
1643 let projection = runtime_input_projection(&input);
1644
1645 let appends = projection_to_pending_system_context_appends(&input_id, &projection);
1646
1647 assert_eq!(appends.len(), 1);
1648 let rendered = appends[0].content.render_text();
1649 assert!(
1650 rendered.contains("please look at this while you work"),
1651 "peer message append should be renderable as live system context: {rendered:?}"
1652 );
1653 assert_eq!(
1654 appends[0].source.as_deref(),
1655 Some(format!("runtime:steer:{input_id}").as_str())
1656 );
1657 assert_eq!(
1658 appends[0].idempotency_key.as_deref(),
1659 Some(format!("runtime:steer:{input_id}").as_str())
1660 );
1661 }
1662
1663 #[test]
1664 fn steer_projection_filters_empty_context_and_empty_append() {
1665 let input_id = InputId::new();
1666 let context_projection = crate::ingress_types::RuntimeInputProjection {
1667 injected_context_appends: Vec::new(),
1668 append: None,
1669 additional_appends: Vec::new(),
1670 context_append: Some(ConversationContextAppend {
1671 key: "empty-context".into(),
1672 content: CoreRenderable::Text { text: " ".into() },
1673 }),
1674 peer_response_terminal: None,
1675 };
1676 assert!(
1677 projection_to_pending_system_context_appends(&input_id, &context_projection).is_empty()
1678 );
1679
1680 let append_projection = crate::ingress_types::RuntimeInputProjection {
1681 injected_context_appends: Vec::new(),
1682 append: Some(ConversationAppend {
1683 role: ConversationAppendRole::SystemNotice,
1684 content: CoreRenderable::Text { text: "\n".into() },
1685 }),
1686 additional_appends: Vec::new(),
1687 context_append: None,
1688 peer_response_terminal: None,
1689 };
1690 assert!(
1691 projection_to_pending_system_context_appends(&input_id, &append_projection).is_empty()
1692 );
1693 }
1694
1695 #[test]
1696 fn peer_response_terminal_with_blocks_projects_append_and_context() {
1697 let route_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f2";
1698 let request_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f1";
1699 let mut header = make_header();
1700 header.source = InputOrigin::Peer {
1701 peer_id: route_id.into(),
1702 display_identity: Some("display-agent".into()),
1703 runtime_id: None,
1704 };
1705 let input = Input::Peer(PeerInput {
1706 injected_context: Vec::new(),
1707 sender_taint: None,
1708 header,
1709 convention: Some(PeerConvention::ResponseTerminal {
1710 request_id: request_id.into(),
1711 status: ResponseTerminalStatus::Completed,
1712 }),
1713 content: ContentInput::Blocks(vec![meerkat_core::types::ContentBlock::Image {
1714 media_type: "image/jpeg".into(),
1715 data: "abc".into(),
1716 }]),
1717 payload: Some(serde_json::json!({"answer":"ok"})),
1718 handling_mode: None,
1719 });
1720
1721 let projection = runtime_input_projection_for_machine_batch(&input);
1722 let append = projection.append.expect("conversation append");
1723 let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1724 panic!("expected typed append");
1725 };
1726 let Some(meerkat_core::types::SystemNoticeBlock::Comms { content, peer, .. }) =
1727 blocks.first()
1728 else {
1729 panic!("expected comms block");
1730 };
1731 assert_eq!(
1732 peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1733 Some("display-agent")
1734 );
1735 assert!(matches!(
1736 content.first(),
1737 Some(meerkat_core::types::ContentBlock::Image { media_type, .. })
1738 if media_type == "image/jpeg"
1739 ));
1740 assert!(
1741 projection.context_append.is_some(),
1742 "terminal response must still apply runtime-owned context"
1743 );
1744 }
1745
1746 #[test]
1747 fn peer_input_request_serde() {
1748 let input = Input::Peer(PeerInput {
1749 injected_context: Vec::new(),
1750 sender_taint: None,
1751 header: make_header(),
1752 convention: Some(PeerConvention::Request {
1753 request_id: "req-1".into(),
1754 intent: "mob.peer_added".into(),
1755 }),
1756 content: "Agent joined".into(),
1757 payload: Some(serde_json::json!({"name": "agent-1"})),
1758 handling_mode: None,
1759 });
1760 let json = serde_json::to_value(&input).unwrap();
1761 let parsed: Input = serde_json::from_value(json).unwrap();
1762 if let Input::Peer(p) = parsed {
1763 assert!(matches!(p.convention, Some(PeerConvention::Request { .. })));
1764 } else {
1765 panic!("Expected PeerInput");
1766 }
1767 }
1768
1769 #[test]
1770 fn peer_input_response_terminal_serde() {
1771 let input = Input::Peer(PeerInput {
1772 injected_context: Vec::new(),
1773 sender_taint: None,
1774 header: make_header(),
1775 convention: Some(PeerConvention::ResponseTerminal {
1776 request_id: "req-1".into(),
1777 status: ResponseTerminalStatus::Completed,
1778 }),
1779 content: "Done".into(),
1780 payload: Some(serde_json::json!({"ok": true})),
1781 handling_mode: None,
1782 });
1783 let json = serde_json::to_value(&input).unwrap();
1784 let parsed: Input = serde_json::from_value(json).unwrap();
1785 assert!(matches!(parsed, Input::Peer(_)));
1786 }
1787
1788 #[test]
1789 fn peer_input_response_progress_serde() {
1790 let input = Input::Peer(PeerInput {
1791 injected_context: Vec::new(),
1792 sender_taint: None,
1793 header: make_header(),
1794 convention: Some(PeerConvention::ResponseProgress {
1795 request_id: "req-1".into(),
1796 phase: ResponseProgressPhase::InProgress,
1797 }),
1798 content: "Working...".into(),
1799 payload: Some(serde_json::json!({"progress": "working"})),
1800 handling_mode: None,
1801 });
1802 let json = serde_json::to_value(&input).unwrap();
1803 let parsed: Input = serde_json::from_value(json).unwrap();
1804 assert!(matches!(parsed, Input::Peer(_)));
1805 }
1806
1807 #[test]
1808 fn flow_step_input_serde() {
1809 let input = Input::FlowStep(FlowStepInput {
1810 header: make_header(),
1811 step_id: "step-1".into(),
1812 content: ContentInput::Blocks(vec![
1813 meerkat_core::types::ContentBlock::Text {
1814 text: "analyze the data".into(),
1815 },
1816 meerkat_core::types::ContentBlock::Image {
1817 media_type: "image/png".into(),
1818 data: meerkat_core::types::ImageData::Inline {
1819 data: "abc123".into(),
1820 },
1821 },
1822 ]),
1823 turn_metadata: None,
1824 });
1825 let json = serde_json::to_value(&input).unwrap();
1826 assert_eq!(json["input_type"], "flow_step");
1827 let parsed: Input = serde_json::from_value(json).unwrap();
1828 assert!(matches!(parsed, Input::FlowStep(_)));
1829 }
1830
1831 #[test]
1832 fn external_event_input_serde() {
1833 let input = Input::ExternalEvent(ExternalEventInput {
1834 header: make_header(),
1835 event_type: "webhook.received".into(),
1836 payload: serde_json::json!({"url": "https://example.com"}),
1837 blocks: Some(vec![
1838 meerkat_core::types::ContentBlock::Text {
1839 text: "look".into(),
1840 },
1841 meerkat_core::types::ContentBlock::Image {
1842 media_type: "image/png".into(),
1843 data: meerkat_core::types::ImageData::Inline {
1844 data: "abc123".into(),
1845 },
1846 },
1847 ]),
1848 handling_mode: HandlingMode::Queue,
1849 render_metadata: None,
1850 });
1851 let json = serde_json::to_value(&input).unwrap();
1852 assert_eq!(json["input_type"], "external_event");
1853 let parsed: Input = serde_json::from_value(json).unwrap();
1854 assert!(matches!(parsed, Input::ExternalEvent(_)));
1855 }
1856
1857 #[test]
1858 fn legacy_external_event_payload_blocks_are_rejected() {
1859 let event = ExternalEventInput {
1862 header: make_header(),
1863 event_type: "webhook.received".into(),
1864 payload: serde_json::json!({
1865 "body": "see image",
1866 "blocks": [
1867 { "type": "text", "text": "caption text" },
1868 { "type": "image", "media_type": "image/png", "source": "inline", "data": "abc123" }
1869 ]
1870 }),
1871 blocks: None,
1872 handling_mode: HandlingMode::Queue,
1873 render_metadata: None,
1874 };
1875
1876 let err = reject_legacy_payload_blocks(&event)
1877 .expect_err("payload-level blocks must fail closed");
1878 assert!(matches!(err, BlobStoreError::Internal(_)));
1879 assert!(event.payload.get("blocks").is_some());
1881 assert!(event.blocks.is_none());
1882 }
1883
1884 #[test]
1885 fn external_event_payload_without_blocks_key_passes_rejection_gate() {
1886 let event = ExternalEventInput {
1887 header: make_header(),
1888 event_type: "webhook.received".into(),
1889 payload: serde_json::json!({ "body": "plain payload" }),
1890 blocks: Some(vec![meerkat_core::types::ContentBlock::Text {
1891 text: "typed owner content".into(),
1892 }]),
1893 handling_mode: HandlingMode::Queue,
1894 render_metadata: None,
1895 };
1896
1897 reject_legacy_payload_blocks(&event)
1898 .expect("payload without a legacy blocks key must pass");
1899 }
1900
1901 #[test]
1902 fn continuation_input_serde() {
1903 let input = Input::Continuation(ContinuationInput::detached_background_op_completed());
1904 let json = serde_json::to_value(&input).unwrap();
1905 assert_eq!(json["input_type"], "continuation");
1906 let parsed: Input = serde_json::from_value(json).unwrap();
1907 match parsed {
1908 Input::Continuation(continuation) => {
1909 assert_eq!(continuation.handling_mode, HandlingMode::Steer);
1910 assert_eq!(continuation.reason, "detached_background_op_completed");
1911 }
1912 other => panic!("Expected Continuation, got {other:?}"),
1913 }
1914 }
1915
1916 #[test]
1917 fn continuation_input_rejects_legacy_system_generated_tag() {
1918 let input = Input::Continuation(ContinuationInput::detached_background_op_completed());
1921 let mut json = serde_json::to_value(&input).unwrap();
1922 json["input_type"] = serde_json::Value::String("system_generated".into());
1923 serde_json::from_value::<Input>(json)
1924 .expect_err("legacy system_generated input_type tag must be rejected");
1925 }
1926
1927 #[test]
1928 fn operation_input_serde() {
1929 let input = Input::Operation(OperationInput {
1930 header: InputHeader {
1931 durability: InputDurability::Derived,
1932 ..make_header()
1933 },
1934 operation_id: OperationId::new(),
1935 event: OpEvent::Cancelled {
1936 id: OperationId::new(),
1937 },
1938 });
1939 let json = serde_json::to_value(&input).unwrap();
1940 assert_eq!(json["input_type"], "operation");
1941 let parsed: Input = serde_json::from_value(json).unwrap();
1942 assert!(matches!(parsed, Input::Operation(_)));
1943 }
1944
1945 #[test]
1946 fn operation_input_rejects_legacy_projected_tag() {
1947 let input = Input::Operation(OperationInput {
1950 header: InputHeader {
1951 durability: InputDurability::Derived,
1952 ..make_header()
1953 },
1954 operation_id: OperationId::new(),
1955 event: OpEvent::Cancelled {
1956 id: OperationId::new(),
1957 },
1958 });
1959 let mut json = serde_json::to_value(&input).unwrap();
1960 json["input_type"] = serde_json::Value::String("projected".into());
1961 serde_json::from_value::<Input>(json)
1962 .expect_err("legacy projected input_type tag must be rejected");
1963 }
1964
1965 #[test]
1966 fn legacy_dual_carrier_input_shapes_are_rejected() {
1967 let header = serde_json::to_value(make_header()).unwrap();
1972
1973 let legacy_prompt = serde_json::json!({
1974 "input_type": "prompt",
1975 "header": header.clone(),
1976 "text": "hello",
1977 "blocks": null
1978 });
1979 serde_json::from_value::<Input>(legacy_prompt)
1980 .expect_err("legacy prompt text+blocks shape must be rejected");
1981
1982 let legacy_peer = serde_json::json!({
1983 "input_type": "peer",
1984 "header": header.clone(),
1985 "convention": { "convention_type": "message" },
1986 "body": "hi there"
1987 });
1988 serde_json::from_value::<Input>(legacy_peer)
1989 .expect_err("legacy peer body+blocks shape must be rejected");
1990
1991 let legacy_flow_step = serde_json::json!({
1992 "input_type": "flow_step",
1993 "header": header,
1994 "step_id": "step-1",
1995 "instructions": "analyze the data"
1996 });
1997 serde_json::from_value::<Input>(legacy_flow_step)
1998 .expect_err("legacy flow-step instructions+blocks shape must be rejected");
1999 }
2000
2001 #[test]
2002 fn input_kind_id() {
2003 let prompt = Input::Prompt(PromptInput {
2004 injected_context: Vec::new(),
2005 header: make_header(),
2006 content: "hi".into(),
2007 typed_turn_appends: Vec::new(),
2008 turn_metadata: None,
2009 });
2010 assert_eq!(prompt.kind(), InputKind::Prompt);
2011
2012 let peer_msg = Input::Peer(PeerInput {
2013 injected_context: Vec::new(),
2014 sender_taint: None,
2015 header: make_header(),
2016 convention: Some(PeerConvention::Message),
2017 content: "hi".into(),
2018 payload: None,
2019 handling_mode: None,
2020 });
2021 assert_eq!(peer_msg.kind(), InputKind::PeerMessage);
2022
2023 let peer_req = Input::Peer(PeerInput {
2024 injected_context: Vec::new(),
2025 sender_taint: None,
2026 header: make_header(),
2027 convention: Some(PeerConvention::Request {
2028 request_id: "r".into(),
2029 intent: "i".into(),
2030 }),
2031 content: "hi".into(),
2032 payload: Some(serde_json::json!({"subject": "x"})),
2033 handling_mode: None,
2034 });
2035 assert_eq!(peer_req.kind(), InputKind::PeerRequest);
2036
2037 let continuation = Input::Continuation(ContinuationInput {
2038 header: make_header(),
2039 reason: "continue".into(),
2040 continuation_kind: ContinuationKind::Ordinary,
2041 handling_mode: HandlingMode::Steer,
2042 request_id: None,
2043 flow_tool_overlay: None,
2044 context_append: None,
2045 turn_append: None,
2046 });
2047 assert_eq!(continuation.kind(), InputKind::Continuation);
2048
2049 let operation = Input::Operation(OperationInput {
2050 header: make_header(),
2051 operation_id: OperationId::new(),
2052 event: OpEvent::Cancelled {
2053 id: OperationId::new(),
2054 },
2055 });
2056 assert_eq!(operation.kind(), InputKind::Operation);
2057 }
2058
2059 #[test]
2060 fn input_source_variants() {
2061 let sources = vec![
2062 InputOrigin::Operator,
2063 InputOrigin::Peer {
2064 peer_id: "p1".into(),
2065 display_identity: None,
2066 runtime_id: None,
2067 },
2068 InputOrigin::Flow {
2069 flow_id: "f1".into(),
2070 step_index: 0,
2071 },
2072 InputOrigin::System,
2073 InputOrigin::External {
2074 source_name: "webhook".into(),
2075 },
2076 ];
2077 for source in sources {
2078 let json = serde_json::to_value(&source).unwrap();
2079 let parsed: InputOrigin = serde_json::from_value(json).unwrap();
2080 assert_eq!(source, parsed);
2081 }
2082 }
2083
2084 #[test]
2085 fn input_durability_serde() {
2086 for d in [
2087 InputDurability::Durable,
2088 InputDurability::Ephemeral,
2089 InputDurability::Derived,
2090 ] {
2091 let json = serde_json::to_value(d).unwrap();
2092 let parsed: InputDurability = serde_json::from_value(json).unwrap();
2093 assert_eq!(d, parsed);
2094 }
2095 }
2096
2097 #[test]
2098 fn peer_input_without_handling_mode_deserializes_as_none() {
2099 let json = serde_json::json!({
2101 "input_type": "peer",
2102 "header": serde_json::to_value(make_header()).unwrap(),
2103 "convention": { "convention_type": "message" },
2104 "content": "hello"
2105 });
2106 let parsed: Input = serde_json::from_value(json).unwrap();
2107 match parsed {
2108 Input::Peer(p) => assert!(p.handling_mode.is_none()),
2109 other => panic!("Expected Peer, got {other:?}"),
2110 }
2111 }
2112
2113 #[test]
2114 fn peer_input_with_queue_handling_mode_roundtrips() {
2115 let input = Input::Peer(PeerInput {
2116 injected_context: Vec::new(),
2117 sender_taint: None,
2118 header: make_header(),
2119 convention: Some(PeerConvention::Message),
2120 content: "hi".into(),
2121 payload: None,
2122 handling_mode: Some(HandlingMode::Queue),
2123 });
2124 let json = serde_json::to_value(&input).unwrap();
2125 assert_eq!(json["handling_mode"], "queue");
2126 let parsed: Input = serde_json::from_value(json).unwrap();
2127 match parsed {
2128 Input::Peer(p) => assert_eq!(p.handling_mode, Some(HandlingMode::Queue)),
2129 other => panic!("Expected Peer, got {other:?}"),
2130 }
2131 }
2132
2133 #[test]
2134 fn peer_response_terminal_input_owns_wire_status_mapping() {
2135 let peer_id = meerkat_core::comms::PeerId::from_uuid(
2136 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000161").unwrap(),
2137 );
2138 let display_name = meerkat_core::comms::PeerName::new("analyst").unwrap();
2139 let request_id = meerkat_core::PeerCorrelationId::from_uuid(
2140 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap(),
2141 );
2142 let input = peer_response_terminal_input(
2143 peer_id,
2144 Some(display_name),
2145 request_id,
2146 meerkat_contracts::PeerResponseTerminalStatusWire::Completed,
2147 serde_json::json!({"ok": true}),
2148 );
2149
2150 match input {
2151 Input::Peer(PeerInput {
2152 header:
2153 InputHeader {
2154 source:
2155 InputOrigin::Peer {
2156 peer_id,
2157 display_identity,
2158 runtime_id,
2159 },
2160 durability: InputDurability::Durable,
2161 correlation_id,
2162 ..
2163 },
2164 convention: Some(PeerConvention::ResponseTerminal { request_id, status }),
2165 payload: Some(payload),
2166 handling_mode: None,
2167 ..
2168 }) => {
2169 assert_eq!(peer_id, "00000000-0000-4000-8000-000000000161");
2170 assert_eq!(display_identity.as_deref(), Some("analyst"));
2171 assert_eq!(runtime_id, None);
2172 assert_eq!(request_id, "00000000-0000-4000-8000-000000000162");
2173 assert_eq!(
2174 correlation_id,
2175 Some(CorrelationId::from_uuid(
2176 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap()
2177 ))
2178 );
2179 assert_eq!(status, ResponseTerminalStatus::Completed);
2180 assert_eq!(payload["ok"], true);
2181 }
2182 other => panic!("expected terminal peer input, got {other:?}"),
2183 }
2184 }
2185
2186 #[test]
2187 fn peer_response_terminal_validation_is_structural_only() {
2188 let peer_id = meerkat_core::comms::PeerId::from_uuid(
2189 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000161").unwrap(),
2190 );
2191 let display_name = meerkat_core::comms::PeerName::new("analyst").unwrap();
2192 let request_id = meerkat_core::PeerCorrelationId::from_uuid(
2193 uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap(),
2194 );
2195 let input = peer_response_terminal_input(
2196 peer_id,
2197 Some(display_name),
2198 request_id,
2199 meerkat_contracts::PeerResponseTerminalStatusWire::Cancelled,
2200 serde_json::json!({"ok": false}),
2201 );
2202
2203 validate_peer_response_terminal_fact(&input)
2204 .expect("status support is generated admission authority, structural fact validation should pass");
2205 }
2206
2207 #[test]
2208 fn peer_input_with_steer_handling_mode_roundtrips() {
2209 let input = Input::Peer(PeerInput {
2210 injected_context: Vec::new(),
2211 sender_taint: None,
2212 header: make_header(),
2213 convention: Some(PeerConvention::Message),
2214 content: "hi".into(),
2215 payload: None,
2216 handling_mode: Some(HandlingMode::Steer),
2217 });
2218 let json = serde_json::to_value(&input).unwrap();
2219 assert_eq!(json["handling_mode"], "steer");
2220 let parsed: Input = serde_json::from_value(json).unwrap();
2221 match parsed {
2222 Input::Peer(p) => assert_eq!(p.handling_mode, Some(HandlingMode::Steer)),
2223 other => panic!("Expected Peer, got {other:?}"),
2224 }
2225 }
2226
2227 #[test]
2228 fn peer_input_handling_mode_not_serialized_when_none() {
2229 let input = Input::Peer(PeerInput {
2230 injected_context: Vec::new(),
2231 sender_taint: None,
2232 header: make_header(),
2233 convention: Some(PeerConvention::Message),
2234 content: "hi".into(),
2235 payload: None,
2236 handling_mode: None,
2237 });
2238 let json = serde_json::to_value(&input).unwrap();
2239 assert!(json.get("handling_mode").is_none());
2240 }
2241}