Skip to main content

meerkat_runtime/
input.rs

1//! §8 Input types — the 6 input variants accepted by the runtime layer.
2//!
3//! Core never sees these. Generated admission authority resolves each accepted
4//! Input to a PolicyDecision, then the runtime translates accepted Inputs into
5//! RunPrimitive for core consumption.
6
7use 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/// Common header for all input variants.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct InputHeader {
37    /// Unique ID for this input.
38    pub id: InputId,
39    /// When the input was created.
40    pub timestamp: DateTime<Utc>,
41    /// Source of the input.
42    pub source: InputOrigin,
43    /// Durability requirement.
44    pub durability: InputDurability,
45    /// Visibility controls.
46    pub visibility: InputVisibility,
47    /// Optional idempotency key for dedup.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub idempotency_key: Option<IdempotencyKey>,
50    /// Optional supersession key.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub supersession_key: Option<SupersessionKey>,
53    /// Optional correlation ID.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub correlation_id: Option<CorrelationId>,
56}
57
58/// Where the input originated.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(tag = "type", rename_all = "snake_case")]
61#[non_exhaustive]
62pub enum InputOrigin {
63    /// Human operator / external API caller.
64    Operator,
65    /// Peer agent (comms).
66    Peer {
67        /// Canonical comms peer id used by machine/runtime policy and schema
68        /// projection.
69        peer_id: String,
70        /// Optional display/source label admitted at peer ingress. This is
71        /// presentation metadata only; routing and trust must use typed ingress
72        /// facts before the runtime input seam or the canonical `peer_id`.
73        #[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 engine (mob orchestration).
79    Flow { flow_id: String, step_index: usize },
80    /// System-generated (compaction, projection, etc.).
81    System,
82    /// External event source.
83    External { source_name: String },
84}
85
86/// Durability requirement for an input.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89#[non_exhaustive]
90pub enum InputDurability {
91    /// Must be persisted before acknowledgment.
92    Durable,
93    /// In-memory only, may be lost on crash.
94    Ephemeral,
95    /// Derived from other inputs (can be reconstructed).
96    Derived,
97}
98
99/// Visibility controls for an input.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101pub struct InputVisibility {
102    /// Whether this input appears in the conversation transcript.
103    pub transcript_eligible: bool,
104    /// Whether this input is visible to operator surfaces.
105    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/// The 6 input variants accepted by the runtime layer.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(tag = "input_type", rename_all = "snake_case")]
120#[non_exhaustive]
121pub enum Input {
122    /// User/operator prompt.
123    Prompt(PromptInput),
124    /// Peer-originated input (comms).
125    Peer(PeerInput),
126    /// Flow step input (mob orchestration).
127    FlowStep(FlowStepInput),
128    /// External event input.
129    ExternalEvent(ExternalEventInput),
130    /// Explicit runtime continuation work.
131    Continuation(ContinuationInput),
132    /// Explicit non-content operation/lifecycle input.
133    Operation(OperationInput),
134}
135
136impl Input {
137    /// Get the input header.
138    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    /// Get the input ID.
150    pub fn id(&self) -> &InputId {
151        &self.header().id
152    }
153
154    /// Typed kind for policy dispatch.
155    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    /// Wrapped kind identifier (for newtype-discipline call sites).
172    pub fn kind_id(&self) -> KindId {
173        KindId::new(self.kind())
174    }
175
176    /// Handling-mode hint for ordinary work admitted through the runtime.
177    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    /// Typed continuation discriminant threaded into admission. Only
189    /// continuations carry a non-ordinary kind; every other input family is
190    /// [`ContinuationKind::Ordinary`]. This is a typed pass-through of the
191    /// producer-declared fact — admission never re-classifies continuation
192    /// routing from reason strings or overlay dispatch-context keys.
193    pub fn continuation_kind(&self) -> ContinuationKind {
194        match self {
195            Input::Continuation(continuation) => continuation.continuation_kind,
196            _ => ContinuationKind::Ordinary,
197        }
198    }
199}
200
201/// Fail closed on the retired external-event shape that smuggled multimodal
202/// blocks inside the payload JSON object. The typed [`ExternalEventInput::blocks`]
203/// field is the only content owner; a payload-level `blocks` key is rejected
204/// with a typed error instead of being migrated or silently passed through.
205fn 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/// User/operator prompt input.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct PromptInput {
286    pub header: InputHeader,
287    /// The prompt content — the single typed owner of this input's content
288    /// fact (plain text or multimodal blocks). The text projection is derived
289    /// at read time via [`ContentInput::text_content`]; it is never stored
290    /// separately.
291    pub content: ContentInput,
292    /// Runtime-authored typed transcript appends that travel with this turn.
293    ///
294    /// These are not operator-authored prompt content. The runtime projects
295    /// them into model-facing text only when building the provider request.
296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
297    pub typed_turn_appends: Vec<ConversationAppend>,
298    /// Host-attached injected context delivered alongside (not inside) this
299    /// turn's prompt. Each entry lowers into a separate
300    /// [`ConversationAppendRole::InjectedContext`] transcript append placed
301    /// immediately BEFORE the turn's user append, in order — the typed slot
302    /// the content arrived in mints the transcript role. Additive on durable
303    /// input persistence (absent on older persisted inputs).
304    #[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    /// Create a new operator prompt with default header.
312    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    /// Create a prompt from `ContentInput` (text or multimodal blocks).
332    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    /// Attach host-attached injected context to this prompt input.
355    pub fn with_injected_context(mut self, injected_context: Vec<ContentInput>) -> Self {
356        self.injected_context = injected_context;
357        self
358    }
359}
360
361/// Peer-originated input from comms.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct PeerInput {
364    pub header: InputHeader,
365    /// Exact per-input interaction identity for a host-residency-fenced
366    /// supervisor-bridge delivery. Ordinary peer traffic and peer-only legacy
367    /// bridge deliveries leave this absent.
368    ///
369    /// This persisted field is capability-adjacent data, not authority. The
370    /// runtime validates its independently carried input, correlation,
371    /// idempotency, durability, and peer-shape facts before admission and
372    /// mints terminal-publication metadata from the validated value.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub directed_interaction_id: Option<meerkat_core::interaction::InteractionId>,
375    /// The peer convention (message, request, response).
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub convention: Option<PeerConvention>,
378    /// The peer content — the single typed owner of this input's content fact
379    /// (plain text or multimodal blocks). Message-style peer traffic uses this
380    /// directly. Request/response prompt projection is runtime-owned and must
381    /// be reconstructed from `convention + payload + source` rather than
382    /// helper-rendered prose. The text projection is derived at read time via
383    /// [`ContentInput::text_content`]; it is never stored separately.
384    pub content: ContentInput,
385    /// Structured peer payload, when one exists.
386    ///
387    /// For `Request`, this is the request params. For `Response*`, this is the
388    /// response result payload. Message traffic leaves this unset.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub payload: Option<serde_json::Value>,
391    /// Optional handling-mode override for actionable peer inputs.
392    /// When present on Message/Request/no-convention, overrides kind-based
393    /// policy defaults. Forbidden on ResponseProgress; ResponseTerminal may
394    /// carry a typed override for requester reaction urgency (enforced by
395    /// [`validate_peer_handling_mode`]).
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub handling_mode: Option<HandlingMode>,
398    /// Sender-declared content taint carried inside the signed comms
399    /// envelope, when the sender made a declaration. `None` means "no
400    /// declaration" — a real third state that must never be coalesced into
401    /// [`meerkat_core::comms::SenderContentTaint::Clean`]. Content-adjacent
402    /// payload only: it makes no admission or routing decision. Additive on
403    /// durable input persistence (absent on older persisted inputs).
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub sender_taint: Option<meerkat_core::comms::SenderContentTaint>,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub objective_id: Option<meerkat_core::interaction::ObjectiveId>,
408    /// Host-attached injected context carried by supervisor-authored work
409    /// deliveries (remote mob members over the supervisor bridge). Each entry
410    /// lowers into a separate
411    /// [`ConversationAppendRole::InjectedContext`] transcript append placed
412    /// immediately BEFORE this input's peer append, in order. Forbidden on
413    /// steer-mode deliveries (the steer realization path carries no
414    /// transcript appends — enforced by
415    /// [`crate::peer_handling_mode::validate_peer_handling_mode`]). Additive
416    /// on durable input persistence (absent on older persisted inputs).
417    #[serde(default, skip_serializing_if = "Vec::is_empty")]
418    pub injected_context: Vec<ContentInput>,
419}
420
421/// Peer communication conventions.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423#[serde(tag = "convention_type", rename_all = "snake_case")]
424#[non_exhaustive]
425pub enum PeerConvention {
426    /// Simple peer-to-peer message.
427    Message,
428    /// Request expecting a response.
429    Request { request_id: String, intent: String },
430    /// Progress update for an ongoing response.
431    ResponseProgress {
432        request_id: String,
433        phase: ResponseProgressPhase,
434    },
435    /// Terminal response (completed or failed).
436    ResponseTerminal {
437        request_id: String,
438        status: ResponseTerminalStatus,
439    },
440}
441
442/// Phase of a response progress update. This is the core projection enum, not
443/// a runtime-local duplicate.
444pub type ResponseProgressPhase = PeerResponseProgressProjectionPhase;
445
446/// Terminal status of a response. This is the core projection enum, not a
447/// runtime-local duplicate.
448pub type ResponseTerminalStatus = PeerResponseTerminalProjectionStatus;
449
450pub fn response_terminal_status_from_wire(
451    status: meerkat_contracts::PeerResponseTerminalStatusWire,
452) -> ResponseTerminalStatus {
453    match status {
454        meerkat_contracts::PeerResponseTerminalStatusWire::Completed => {
455            PeerResponseTerminalProjectionStatus::Completed
456        }
457        meerkat_contracts::PeerResponseTerminalStatusWire::Failed => {
458            PeerResponseTerminalProjectionStatus::Failed
459        }
460        meerkat_contracts::PeerResponseTerminalStatusWire::Cancelled => {
461            PeerResponseTerminalProjectionStatus::Cancelled
462        }
463    }
464}
465
466pub fn peer_response_terminal_input(
467    peer_id: meerkat_core::comms::PeerId,
468    display_name: Option<meerkat_core::comms::PeerName>,
469    request_id: meerkat_core::PeerCorrelationId,
470    status: meerkat_contracts::PeerResponseTerminalStatusWire,
471    result: serde_json::Value,
472) -> Input {
473    let correlation_id = CorrelationId::from_uuid(request_id.as_uuid());
474    let request_id = request_id.to_string();
475    let peer_id = peer_id.to_string();
476    let display_identity = display_name.map_or_else(|| peer_id.clone(), |name| name.as_string());
477
478    Input::Peer(PeerInput {
479        directed_interaction_id: None,
480        objective_id: None,
481        injected_context: Vec::new(),
482        header: InputHeader {
483            id: InputId::new(),
484            timestamp: Utc::now(),
485            source: InputOrigin::Peer {
486                peer_id,
487                display_identity: Some(display_identity),
488                runtime_id: None,
489            },
490            durability: InputDurability::Durable,
491            visibility: InputVisibility::default(),
492            idempotency_key: None,
493            supersession_key: None,
494            correlation_id: Some(correlation_id),
495        },
496        convention: Some(PeerConvention::ResponseTerminal {
497            request_id,
498            status: response_terminal_status_from_wire(status),
499        }),
500        content: ContentInput::Text(String::new()),
501        payload: Some(result),
502        handling_mode: None,
503        // Bridge-projected terminal responses carry no comms envelope, so no
504        // sender declaration exists.
505        sender_taint: None,
506    })
507}
508
509/// Flow step input from mob orchestration.
510#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct FlowStepInput {
512    pub header: InputHeader,
513    /// Flow step identifier.
514    pub step_id: String,
515    /// Step instructions — the single typed owner of this input's content
516    /// fact (plain text or multimodal blocks). The text projection is derived
517    /// at read time via [`ContentInput::text_content`]; it is never stored
518    /// separately.
519    pub content: ContentInput,
520    /// Exact per-input interaction identity for a directed cross-host flow
521    /// delivery. Ordinary/local flow steps leave this absent.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub directed_interaction_id: Option<meerkat_core::interaction::InteractionId>,
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub turn_metadata: Option<RuntimeTurnMetadata>,
526}
527
528fn validate_directed_interaction_header(
529    header: &InputHeader,
530    interaction_id: meerkat_core::interaction::InteractionId,
531    input_kind: &str,
532) -> Result<(), String> {
533    if header.id.0 != interaction_id.0 {
534        return Err(format!(
535            "directed {input_kind} interaction id does not match input id"
536        ));
537    }
538    if header.correlation_id.as_ref().map(|id| id.0) != Some(interaction_id.0) {
539        return Err(format!(
540            "directed {input_kind} correlation id does not match interaction id"
541        ));
542    }
543    let canonical_id = interaction_id.to_string();
544    if header
545        .idempotency_key
546        .as_ref()
547        .map(ToString::to_string)
548        .as_deref()
549        != Some(canonical_id.as_str())
550    {
551        return Err(format!(
552            "directed {input_kind} idempotency key does not match interaction id"
553        ));
554    }
555    if header.durability != InputDurability::Durable {
556        return Err(format!("directed {input_kind} input must be durable"));
557    }
558    Ok(())
559}
560
561/// Validate the capability-bearing correlation on a directed flow-step.
562///
563/// The optional field is persisted as data, not trusted as authority. A
564/// directed correlation is admitted only when every independently-carried
565/// identity fact agrees with the runtime input ID and the input has the
566/// durable flow-origin shape minted by [`crate::mob_adapter::create_tracked_flow_step_input`].
567pub(crate) fn validate_directed_flow_step_correlation(input: &Input) -> Result<(), String> {
568    let Input::FlowStep(flow_step) = input else {
569        return Ok(());
570    };
571    let Some(interaction_id) = flow_step.directed_interaction_id else {
572        return Ok(());
573    };
574    let header = &flow_step.header;
575    validate_directed_interaction_header(header, interaction_id, "flow-step")?;
576    match &header.source {
577        InputOrigin::Flow {
578            flow_id,
579            step_index: 0,
580        } if !flow_id.trim().is_empty() => Ok(()),
581        _ => Err(
582            "directed flow-step input must carry a non-empty flow origin with remote step index 0"
583                .to_string(),
584        ),
585    }
586}
587
588/// Validate and return the runtime-tracked interaction identity carried by an
589/// input. Both tracked flow steps and placed supervisor-bridge peer deliveries
590/// use the same terminal-publication machinery; every other input returns
591/// `None`.
592pub(crate) fn validated_directed_interaction_id(
593    input: &Input,
594) -> Result<Option<meerkat_core::interaction::InteractionId>, String> {
595    match input {
596        Input::FlowStep(flow_step) => {
597            validate_directed_flow_step_correlation(input)?;
598            Ok(flow_step.directed_interaction_id)
599        }
600        Input::Peer(peer) => {
601            let Some(interaction_id) = peer.directed_interaction_id else {
602                return Ok(None);
603            };
604            validate_directed_interaction_header(&peer.header, interaction_id, "peer input")?;
605            match &peer.header.source {
606                InputOrigin::Peer {
607                    peer_id,
608                    runtime_id: Some(runtime_id),
609                    ..
610                } if !peer_id.trim().is_empty() && !runtime_id.0.trim().is_empty() => {}
611                _ => {
612                    return Err(
613                        "directed peer input must carry a non-empty peer origin and runtime id"
614                            .to_string(),
615                    );
616                }
617            }
618            if !matches!(peer.convention, Some(PeerConvention::Message)) {
619                return Err("directed peer input must use the message convention".to_string());
620            }
621            if peer.payload.is_some() {
622                return Err("directed peer input must not carry a structured peer payload".into());
623            }
624            if peer.sender_taint.is_some() {
625                return Err("directed peer input must not carry sender-declared taint".into());
626            }
627            if peer.header.supersession_key.is_some() {
628                return Err("directed peer input must not carry a supersession key".into());
629            }
630            Ok(Some(interaction_id))
631        }
632        _ => Ok(None),
633    }
634}
635
636/// External event input.
637#[derive(Debug, Clone, Serialize, Deserialize)]
638pub struct ExternalEventInput {
639    pub header: InputHeader,
640    /// Event type/name.
641    pub event_type: String,
642    /// Event payload. Uses `Value` because the runtime layer may inspect/merge
643    /// payloads during coalescing and projection — not a pure pass-through.
644    /// Multimodal content does NOT live here canonically; use `blocks`.
645    pub payload: serde_json::Value,
646    /// Optional multimodal blocks carried by the external event. This is the
647    /// canonical owner for multimodal external-event content.
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub blocks: Option<Vec<meerkat_core::types::ContentBlock>>,
650    /// Runtime-owned handling hint for this external event.
651    #[serde(default)]
652    pub handling_mode: HandlingMode,
653    /// Optional normalized render metadata carried with the event.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub render_metadata: Option<RenderMetadata>,
656    #[serde(default, skip_serializing_if = "Option::is_none")]
657    pub objective_id: Option<meerkat_core::interaction::ObjectiveId>,
658}
659
660/// Typed continuation discriminant carried on a [`ContinuationInput`].
661///
662/// The producer of a continuation declares how the runtime must re-enter the
663/// session: an ordinary continuation resumes the pending run, while a WorkGraph
664/// attention continuation re-enters as a fresh queued content turn. This is a
665/// typed fact owned by the producer; admission threads it into
666/// `MeerkatMachine::ResolveAdmissionPlan`, which owns the lane and run-apply
667/// semantics derived from it. No downstream consumer re-classifies continuation
668/// routing from continuation reason strings or overlay dispatch-context keys.
669#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
670#[serde(rename_all = "snake_case")]
671pub enum ContinuationKind {
672    /// Ordinary continuation: resume the pending run at the run boundary.
673    #[default]
674    Ordinary,
675    /// WorkGraph attention continuation: re-enter as a fresh queued content turn.
676    WorkgraphAttention,
677}
678
679/// Explicit continuation request that asks the runtime to keep draining
680/// ordinary work after a boundary-local event (for example, terminal peer
681/// responses injected into session state).
682#[derive(Debug, Clone, Serialize, Deserialize)]
683pub struct ContinuationInput {
684    pub header: InputHeader,
685    /// Stable reason for the continuation request.
686    pub reason: String,
687    /// Typed continuation discriminant owned by the producer. Admission threads
688    /// it into `MeerkatMachine::ResolveAdmissionPlan` so the machine owns the
689    /// lane and run-apply semantics for WorkGraph attention re-entry.
690    #[serde(default)]
691    pub continuation_kind: ContinuationKind,
692    /// Ordinary-work handling mode for the continuation.
693    #[serde(default)]
694    pub handling_mode: HandlingMode,
695    /// Optional request/correlation handle tied to the continuation.
696    #[serde(default, skip_serializing_if = "Option::is_none")]
697    pub request_id: Option<String>,
698    /// Optional per-turn tool visibility overlay for scoped continuations.
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub turn_tool_overlay: Option<TurnToolOverlay>,
701    /// Optional runtime-owned context projected into the next turn boundary.
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub context_append: Option<ConversationContextAppend>,
704    /// Optional runtime-owned turn append used to force a continuation turn.
705    #[serde(default, skip_serializing_if = "Option::is_none")]
706    pub turn_append: Option<ConversationAppend>,
707}
708
709impl ContinuationInput {
710    /// Build a continuation for waking an idle session after a detached
711    /// background operation reaches terminal state.
712    ///
713    /// Properties: `Derived` durability, invisible to transcript and operator,
714    /// `System` origin, `Steer` handling mode.
715    pub fn detached_background_op_completed() -> Self {
716        Self {
717            header: InputHeader {
718                id: meerkat_core::lifecycle::InputId::new(),
719                timestamp: chrono::Utc::now(),
720                source: InputOrigin::System,
721                durability: InputDurability::Derived,
722                visibility: InputVisibility {
723                    transcript_eligible: false,
724                    operator_eligible: false,
725                },
726                idempotency_key: None,
727                supersession_key: None,
728                correlation_id: None,
729            },
730            reason: "detached_background_op_completed".to_string(),
731            continuation_kind: ContinuationKind::Ordinary,
732            handling_mode: HandlingMode::Steer,
733            request_id: None,
734            turn_tool_overlay: None,
735            context_append: None,
736            turn_append: None,
737        }
738    }
739}
740
741/// Explicit operation/lifecycle input admitted through runtime instead of
742/// being smuggled through transcript projections or peer-only paths.
743#[derive(Debug, Clone, Serialize, Deserialize)]
744pub struct OperationInput {
745    pub header: InputHeader,
746    /// Stable operation identifier.
747    pub operation_id: OperationId,
748    /// Typed lifecycle event for the operation.
749    pub event: OpEvent,
750}
751
752/// Build the core-owned peer conversation projection for a runtime peer input.
753///
754/// Peer-response terminal context projection is deliberately excluded here:
755/// admission must not store it as pre-machine truth. Runtime-loop batch
756/// construction uses [`runtime_input_projection_for_machine_batch`] after the
757/// machine-selected input is dequeued.
758pub(crate) fn peer_projection_from_peer_input(
759    peer: &PeerInput,
760) -> Option<PeerConversationProjection> {
761    peer_projection_from_peer_input_with_id(peer, peer_canonical_id(peer)?.as_str())
762}
763
764fn peer_projection_from_peer_input_with_id(
765    peer: &PeerInput,
766    peer_id: &str,
767) -> Option<PeerConversationProjection> {
768    let peer_id = peer_id.to_string();
769
770    match &peer.convention {
771        Some(PeerConvention::Message) => Some(PeerConversationProjection::Message { peer_id }),
772        Some(PeerConvention::Request { request_id, intent }) => {
773            let peer_id = match meerkat_core::comms::PeerId::parse(peer_id.as_str()) {
774                Ok(peer_id) => peer_id,
775                Err(error) => {
776                    tracing::warn!(
777                        peer_id,
778                        error = %error,
779                        "dropping peer request projection with non-canonical peer_id"
780                    );
781                    return None;
782                }
783            };
784            Some(PeerConversationProjection::Request {
785                peer_id,
786                display_name: peer_display_label(peer),
787                request_id: request_id.clone(),
788                intent: intent.clone(),
789                payload: peer.payload.clone(),
790            })
791        }
792        Some(PeerConvention::ResponseProgress { request_id, phase }) => {
793            Some(PeerConversationProjection::ResponseProgress {
794                peer_id,
795                request_id: request_id.clone(),
796                phase: *phase,
797                payload: peer.payload.clone(),
798            })
799        }
800        Some(PeerConvention::ResponseTerminal { .. }) => None,
801        None => None,
802    }
803}
804
805pub(crate) fn peer_response_terminal_fact(
806    peer: &PeerInput,
807) -> Result<Option<PeerResponseTerminalFact>, PeerResponseTerminalFactError> {
808    let InputOrigin::Peer {
809        peer_id,
810        display_identity,
811        runtime_id,
812    } = &peer.header.source
813    else {
814        return Ok(None);
815    };
816    let Some(PeerConvention::ResponseTerminal { request_id, status }) = &peer.convention else {
817        return Ok(None);
818    };
819
820    let transport_identity = runtime_id
821        .as_ref()
822        .map(ToString::to_string)
823        .map(PeerResponseTerminalTransportIdentity::parse)
824        .transpose()?;
825    let source = PeerResponseTerminalSource::new(
826        transport_identity,
827        PeerResponseTerminalRouteIdentity::parse(peer_id.clone())?,
828        PeerResponseTerminalDisplayIdentity::parse(
829            display_identity
830                .as_ref()
831                .ok_or(PeerResponseTerminalFactError::MissingDisplayIdentity)?
832                .clone(),
833        )?,
834    );
835    Ok(Some(PeerResponseTerminalFact::new(
836        source,
837        PeerResponseTerminalCorrelationId::parse(request_id)?,
838        *status,
839        PeerResponseTerminalRenderPayload::new(peer.payload.clone()),
840    )))
841}
842
843pub(crate) fn validate_peer_response_terminal_fact(
844    input: &Input,
845) -> Result<(), PeerResponseTerminalFactError> {
846    let Input::Peer(peer) = input else {
847        return Ok(());
848    };
849    peer_response_terminal_fact(peer).map(|_| ())
850}
851
852/// Lift an [`Input`] to its core peer projection when it is a peer input with a
853/// peer-origin header.
854#[cfg(test)]
855pub(crate) fn peer_projection(input: &Input) -> Option<PeerConversationProjection> {
856    let Input::Peer(peer) = input else {
857        return None;
858    };
859    peer_projection_from_peer_input(peer)
860}
861
862fn peer_canonical_id(peer: &PeerInput) -> Option<String> {
863    let InputOrigin::Peer { peer_id, .. } = &peer.header.source else {
864        return None;
865    };
866    Some(peer_id.clone())
867}
868
869fn peer_display_label(peer: &PeerInput) -> Option<String> {
870    let InputOrigin::Peer {
871        display_identity, ..
872    } = &peer.header.source
873    else {
874        return None;
875    };
876
877    display_identity
878        .as_ref()
879        .map(|label| label.trim())
880        .filter(|label| !label.is_empty())
881        .map(ToOwned::to_owned)
882}
883
884/// Mint the typed peer-reply capability for a runtime input, when one exists.
885///
886/// Only peer *message* deliveries — the [`InputKind::PeerMessage`] grouping
887/// (`PeerConvention::Message` or a bare peer input) — mint a reply capability.
888/// Request/response conventions have their own correlated reply channel
889/// (`send_response`) and mint none. A peer input whose origin `peer_id` is not
890/// a canonical [`meerkat_core::comms::PeerId`] cannot mint a routable
891/// capability: that is a producer bug, logged loudly and dropped rather than
892/// smuggled into the tool seam. A delivery without a stamped correlation id
893/// (the comms bridge stamps one on every classified peer ingress) carries no
894/// reply selector and mints none.
895pub(crate) fn peer_reply_capability(
896    input: &Input,
897) -> Option<meerkat_core::comms::PeerReplyCapability> {
898    if input.kind() != InputKind::PeerMessage {
899        return None;
900    }
901    let Input::Peer(peer) = input else {
902        return None;
903    };
904    let InputOrigin::Peer { peer_id, .. } = &peer.header.source else {
905        return None;
906    };
907    let peer_id = match meerkat_core::comms::PeerId::parse(peer_id) {
908        Ok(peer_id) => peer_id,
909        Err(error) => {
910            tracing::error!(
911                peer_id,
912                error = %error,
913                "dropping peer reply capability with non-canonical peer_id"
914            );
915            return None;
916        }
917    };
918    let correlation_id = peer.header.correlation_id.as_ref()?;
919    Some(meerkat_core::comms::PeerReplyCapability {
920        in_reply_to: meerkat_core::InteractionId(correlation_id.0),
921        peer_id,
922        display_name: peer_display_label(peer),
923        kind: meerkat_core::comms::PeerReplyDeliveryKind::Message,
924    })
925}
926
927/// Rendered prompt-text projection for a peer input.
928pub(crate) fn peer_prompt_text(peer: &PeerInput) -> String {
929    peer_projection_from_peer_input(peer)
930        .map(|projection| {
931            let prompt = projection.prompt_text();
932            if prompt.is_empty() {
933                peer.content.text_content()
934            } else {
935                prompt
936            }
937        })
938        .unwrap_or_else(|| peer.content.text_content())
939}
940
941pub(crate) fn input_prompt_text(input: &Input) -> String {
942    match input {
943        Input::Prompt(p) => p.content.text_content(),
944        Input::Peer(p) => peer_prompt_text(p),
945        Input::FlowStep(f) => f.content.text_content(),
946        Input::ExternalEvent(e) => external_event_projection_text(e),
947        Input::Continuation(continuation) => format!("[Continuation] {}", continuation.reason),
948        Input::Operation(operation) => {
949            format!(
950                "[Operation {}] {:?}",
951                operation.operation_id, operation.event
952            )
953        }
954    }
955}
956
957fn external_event_projection_text(event: &ExternalEventInput) -> String {
958    let source_name = match &event.header.source {
959        InputOrigin::External { source_name } if !source_name.trim().is_empty() => {
960            source_name.as_str()
961        }
962        _ => event.event_type.as_str(),
963    };
964    let body = event
965        .payload
966        .get("body")
967        .and_then(serde_json::Value::as_str)
968        .map(str::trim);
969
970    meerkat_core::interaction::format_external_event_projection(source_name, body)
971}
972
973fn peer_notice_renderable(peer: &PeerInput) -> Option<CoreRenderable> {
974    let (peer_id, display_name) = match &peer.header.source {
975        InputOrigin::Peer {
976            peer_id,
977            display_identity,
978            ..
979        } => (peer_id.clone(), display_identity.clone()),
980        _ => return None,
981    };
982    use meerkat_core::types::CommsNoticeKind;
983    let (kind, request_id, intent, status) = match &peer.convention {
984        Some(PeerConvention::Message) | None => (CommsNoticeKind::Message, None, None, None),
985        Some(PeerConvention::Request { request_id, intent }) => (
986            CommsNoticeKind::Request,
987            Some(request_id.clone()),
988            Some(intent.clone()),
989            None,
990        ),
991        Some(PeerConvention::ResponseProgress { request_id, phase }) => (
992            CommsNoticeKind::ResponseProgress,
993            Some(request_id.clone()),
994            None,
995            Some(format!("{phase:?}")),
996        ),
997        Some(PeerConvention::ResponseTerminal { request_id, status }) => (
998            CommsNoticeKind::ResponseTerminal,
999            Some(request_id.clone()),
1000            None,
1001            Some(format!("{status:?}")),
1002        ),
1003    };
1004    let summary = match kind {
1005        CommsNoticeKind::Request => intent.as_ref().map_or_else(
1006            || "Peer request".to_string(),
1007            |intent| format!("Peer request: {intent}"),
1008        ),
1009        CommsNoticeKind::ResponseProgress => "Peer response progress".to_string(),
1010        CommsNoticeKind::ResponseTerminal => "Peer response terminal".to_string(),
1011        CommsNoticeKind::Message | CommsNoticeKind::Other(_) => "Peer message".to_string(),
1012    };
1013    let content = match &peer.content {
1014        ContentInput::Text(body) if body.is_empty() => Vec::new(),
1015        ContentInput::Text(body) => {
1016            vec![meerkat_core::types::ContentBlock::Text { text: body.clone() }]
1017        }
1018        ContentInput::Blocks(blocks) => blocks.clone(),
1019    };
1020    // The peer routing identity is the canonical typed `PeerId`. Production
1021    // peer inputs always carry a hyphenated UUID here (the comms bridge stamps
1022    // `canonical_peer_id_string()`); parse once at this producer boundary. A
1023    // value that is not a valid `PeerId` cannot populate the typed identity, so
1024    // the notice renders without a peer identity (degraded projection) rather
1025    // than smuggling an unparseable string through the transcript.
1026    let notice_peer = meerkat_core::comms::PeerId::parse(&peer_id)
1027        .ok()
1028        .map(|id| SystemNoticePeer { id, display_name });
1029    Some(CoreRenderable::SystemNotice {
1030        kind: SystemNoticeKind::Comms,
1031        body: Some(summary.clone()),
1032        blocks: vec![SystemNoticeBlock::Comms {
1033            kind,
1034            direction: SystemNoticeDirection::Incoming,
1035            peer: notice_peer,
1036            // The envelope's sender-declared taint rides into the typed
1037            // transcript notice; `None` (no declaration) stays distinct from
1038            // an affirmative `Clean` declaration.
1039            sender_taint: peer.sender_taint,
1040            request_id,
1041            intent,
1042            status,
1043            summary: Some(summary),
1044            payload: peer.payload.clone(),
1045            content,
1046        }],
1047    })
1048}
1049
1050fn external_event_notice_renderable(event: &ExternalEventInput) -> CoreRenderable {
1051    let source = match &event.header.source {
1052        InputOrigin::External { source_name } if !source_name.trim().is_empty() => {
1053            source_name.clone()
1054        }
1055        _ => event.event_type.clone(),
1056    };
1057    let body = event
1058        .payload
1059        .get("body")
1060        .and_then(serde_json::Value::as_str)
1061        .map(str::trim)
1062        .filter(|body| !body.is_empty())
1063        .map(ToOwned::to_owned);
1064    let summary = body.as_ref().map_or_else(
1065        || format!("External event via {source}"),
1066        std::clone::Clone::clone,
1067    );
1068    CoreRenderable::SystemNotice {
1069        kind: SystemNoticeKind::ExternalEvent,
1070        body: Some(summary.clone()),
1071        blocks: vec![SystemNoticeBlock::ExternalEvent {
1072            source,
1073            event_type: event.event_type.clone(),
1074            summary: Some(summary),
1075            body,
1076            payload: Some(event.payload.clone()),
1077            content: event.blocks.clone().unwrap_or_default(),
1078        }],
1079    }
1080}
1081
1082fn input_to_append(input: &Input) -> Option<ConversationAppend> {
1083    // Terminal peer responses always carry their typed comms notice as the
1084    // turn's conversation append — with or without multimodal blocks. The
1085    // former `blocks: None => no append` special case left the mandatory
1086    // `AppendContextAndRun` reaction turn with a fabricated empty prompt,
1087    // which providers reject (Anthropic: "user messages must have non-empty
1088    // content") and which violates the typed-run-input doctrine that no
1089    // empty-string prompt is ever synthesized.
1090    let (role, content) = match input {
1091        Input::Prompt(p)
1092            if !p.typed_turn_appends.is_empty()
1093                && match &p.content {
1094                    ContentInput::Text(text) => text.trim().is_empty(),
1095                    ContentInput::Blocks(blocks) => blocks.is_empty(),
1096                } =>
1097        {
1098            return None;
1099        }
1100        Input::Prompt(p) => match &p.content {
1101            ContentInput::Blocks(blocks) => (
1102                ConversationAppendRole::User,
1103                CoreRenderable::Blocks {
1104                    blocks: blocks.clone(),
1105                },
1106            ),
1107            ContentInput::Text(_) => (
1108                ConversationAppendRole::User,
1109                CoreRenderable::Text {
1110                    text: input_prompt_text(input),
1111                },
1112            ),
1113        },
1114        Input::Peer(p) => peer_notice_renderable(p)
1115            .map(|content| (ConversationAppendRole::SystemNotice, content))?,
1116        Input::FlowStep(f) => (
1117            ConversationAppendRole::SystemNotice,
1118            flow_step_run_renderable(f),
1119        ),
1120        Input::ExternalEvent(e) => (
1121            ConversationAppendRole::SystemNotice,
1122            external_event_notice_renderable(e),
1123        ),
1124        Input::Continuation(continuation) => return continuation.turn_append.clone(),
1125        Input::Operation(_) => return None,
1126    };
1127
1128    Some(ConversationAppend { role, content })
1129}
1130
1131fn flow_step_run_renderable(flow_step: &FlowStepInput) -> CoreRenderable {
1132    CoreRenderable::SystemNotice {
1133        kind: SystemNoticeKind::Generic,
1134        body: Some(format!("Flow step {}", flow_step.step_id)),
1135        blocks: vec![SystemNoticeBlock::RuntimeNotice {
1136            category: "flow_step".to_string(),
1137            detail: Some(flow_step.content.text_content()),
1138            payload: None,
1139        }],
1140    }
1141}
1142
1143/// Canonical content published in `AgentEvent::RunStarted` for one runtime
1144/// input when it starts a turn by itself.
1145///
1146/// Runtime-authored typed appends are lowered by the session service into the
1147/// same provider/model projection before the agent emits `RunStarted`. Keep
1148/// host-side terminal attribution on this helper so peer notice rendering,
1149/// multimodal blocks, injected context, and any additional typed appends
1150/// cannot drift from the real turn-start projection.
1151pub fn runtime_input_run_started_content(input: &Input) -> Option<ContentInput> {
1152    let projection = runtime_input_projection(input);
1153    let appends = projection
1154        .injected_context_appends
1155        .into_iter()
1156        .chain(projection.append)
1157        .chain(projection.additional_appends)
1158        .collect::<Vec<_>>();
1159    (!appends.is_empty()).then(|| {
1160        meerkat_core::lifecycle::run_primitive::model_projection_content_input_from_conversation_appends(
1161            &appends,
1162        )
1163    })
1164}
1165
1166/// Validate a runtime-tracked interaction input and return its exact
1167/// singleton `RunStarted` model projection.
1168///
1169/// The validation is part of the public cross-crate seam: the host journal may
1170/// recover either a directed flow step or an explicitly tracked peer input,
1171/// but it must never infer custody from an ordinary persisted input that only
1172/// happens to share the same idempotency key.
1173pub fn directed_input_run_started_content(input: &Input) -> Result<ContentInput, String> {
1174    if validated_directed_interaction_id(input)?.is_none() {
1175        return Err("persisted runtime input does not carry directed interaction custody".into());
1176    }
1177    runtime_input_run_started_content(input)
1178        .ok_or_else(|| "directed runtime input has no turn-start projection".to_string())
1179}
1180
1181fn input_to_context_append(input: &Input) -> Option<ConversationContextAppend> {
1182    let (projection, content) = match input {
1183        Input::Continuation(continuation) => {
1184            return continuation.context_append.clone();
1185        }
1186        Input::Peer(peer) => {
1187            let projection = peer_projection_from_peer_input(peer)?;
1188            let content = peer_notice_renderable(peer)?;
1189            (projection, content)
1190        }
1191        _ => return None,
1192    };
1193
1194    Some(ConversationContextAppend {
1195        key: projection.context_key()?,
1196        content,
1197    })
1198}
1199
1200fn peer_response_terminal_context_append(
1201    peer: &PeerInput,
1202) -> Result<Option<ConversationContextAppend>, PeerResponseTerminalFactError> {
1203    let Some(fact) = peer_response_terminal_fact(peer)? else {
1204        return Ok(None);
1205    };
1206
1207    Ok(Some(ConversationContextAppend {
1208        key: fact.context_key(),
1209        content: CoreRenderable::SystemNotice {
1210            kind: SystemNoticeKind::Comms,
1211            body: Some("Peer terminal response context".to_string()),
1212            blocks: vec![SystemNoticeBlock::Comms {
1213                kind: meerkat_core::types::CommsNoticeKind::ResponseTerminal,
1214                direction: SystemNoticeDirection::Incoming,
1215                peer: Some(SystemNoticePeer {
1216                    id: fact.source.route_identity.peer_id(),
1217                    display_name: Some(fact.source.display_identity.to_string()),
1218                }),
1219                // This context append is derived from the machine-echoed
1220                // terminal apply intent, which carries no content facts - no
1221                // sender declaration is available here.
1222                sender_taint: None,
1223                request_id: Some(fact.correlation_id.to_string()),
1224                intent: None,
1225                status: Some(fact.status.label().to_string()),
1226                summary: Some("Peer terminal response".to_string()),
1227                payload: fact.render_payload.as_ref().cloned(),
1228                content: Vec::new(),
1229            }],
1230        },
1231    }))
1232}
1233
1234/// Lower host-attached injected context entries into typed
1235/// `InjectedContext`-role transcript appends, preserving delivery order.
1236/// The typed slot the content arrived in mints the transcript role.
1237fn injected_context_appends(entries: &[ContentInput]) -> Vec<ConversationAppend> {
1238    entries
1239        .iter()
1240        .map(|entry| ConversationAppend {
1241            role: ConversationAppendRole::InjectedContext,
1242            content: match entry {
1243                ContentInput::Blocks(blocks) => CoreRenderable::Blocks {
1244                    blocks: blocks.clone(),
1245                },
1246                ContentInput::Text(text) => CoreRenderable::Text { text: text.clone() },
1247            },
1248        })
1249        .collect()
1250}
1251
1252pub(crate) fn runtime_input_projection(
1253    input: &Input,
1254) -> crate::ingress_types::RuntimeInputProjection {
1255    crate::ingress_types::RuntimeInputProjection {
1256        injected_context_appends: match input {
1257            Input::Prompt(prompt) => injected_context_appends(&prompt.injected_context),
1258            Input::Peer(peer) => injected_context_appends(&peer.injected_context),
1259            _ => Vec::new(),
1260        },
1261        append: input_to_append(input),
1262        additional_appends: match input {
1263            Input::Prompt(prompt) => prompt.typed_turn_appends.clone(),
1264            _ => Vec::new(),
1265        },
1266        context_append: input_to_context_append(input),
1267        peer_response_terminal: None,
1268    }
1269}
1270
1271pub(crate) fn runtime_input_projection_for_machine_batch(
1272    input: &Input,
1273) -> crate::ingress_types::RuntimeInputProjection {
1274    let mut projection = runtime_input_projection(input);
1275    if let Input::Peer(peer) = input
1276        && let Ok(Some(context_append)) = peer_response_terminal_context_append(peer)
1277    {
1278        projection.context_append = Some(context_append);
1279        // Carry the typed terminal-peer-response fact alongside the rendered
1280        // context append so the realtime/live consumer reads the typed fact
1281        // directly instead of re-parsing the flattened prose.
1282        if let Ok(fact) = peer_response_terminal_fact(peer) {
1283            projection.peer_response_terminal = fact;
1284        }
1285    }
1286    projection
1287}
1288
1289pub(crate) fn context_append_to_pending_system_context_append(
1290    append: &ConversationContextAppend,
1291    peer_response_terminal: Option<&meerkat_core::PeerResponseTerminalFact>,
1292) -> meerkat_core::PendingSystemContextAppend {
1293    meerkat_core::PendingSystemContextAppend {
1294        content: append.content.clone(),
1295        source: Some(append.key.clone()),
1296        idempotency_key: Some(append.key.clone()),
1297        // Durable keyed context append (peer responses, etc.) — not a steer.
1298        source_kind: meerkat_core::session::SystemContextSource::Normal,
1299        // Carry the typed `PeerResponseTerminalFact` so the realtime consumer
1300        // reads it directly (mirrors the `source_kind` precedent that retired
1301        // the `runtime:steer:` string prefix). The fact threads from the
1302        // admitted `RuntimeInputProjection.peer_response_terminal` field.
1303        peer_response_terminal: peer_response_terminal.cloned(),
1304        accepted_at: meerkat_core::time_compat::SystemTime::now(),
1305    }
1306}
1307
1308pub(crate) fn projection_to_pending_system_context_appends(
1309    input_id: &InputId,
1310    projection: &crate::ingress_types::RuntimeInputProjection,
1311) -> Vec<meerkat_core::PendingSystemContextAppend> {
1312    if let Some(append) = projection.context_append.as_ref() {
1313        return std::iter::once(context_append_to_pending_system_context_append(
1314            append,
1315            projection.peer_response_terminal.as_ref(),
1316        ))
1317        .filter(|append| !append.content.render_text().trim().is_empty())
1318        .collect();
1319    }
1320
1321    projection
1322        .append
1323        .as_ref()
1324        .map(|append| {
1325            // The PRODUCER of a runtime-steer append sets the typed marker
1326            // here, at construction. This is the single source of truth for
1327            // the runtime-steer fact — no downstream code reclassifies the
1328            // `source` string. The `runtime:steer:` source/idempotency key is
1329            // retained only as a stable per-input idempotency identifier.
1330            let key = format!("runtime:steer:{input_id}");
1331            meerkat_core::PendingSystemContextAppend {
1332                content: append.content.clone(),
1333                source: Some(key.clone()),
1334                idempotency_key: Some(key),
1335                source_kind: meerkat_core::session::SystemContextSource::RuntimeSteer,
1336                // A runtime steer is never a terminal-peer-response projection.
1337                peer_response_terminal: None,
1338                accepted_at: meerkat_core::time_compat::SystemTime::now(),
1339            }
1340        })
1341        .into_iter()
1342        .filter(|append| !append.content.render_text().trim().is_empty())
1343        .collect()
1344}
1345
1346#[cfg(test)]
1347#[allow(clippy::unwrap_used, clippy::panic)]
1348mod tests {
1349    use super::*;
1350    use chrono::Utc;
1351
1352    fn make_header() -> InputHeader {
1353        InputHeader {
1354            id: InputId::new(),
1355            timestamp: Utc::now(),
1356            source: InputOrigin::Operator,
1357            durability: InputDurability::Durable,
1358            visibility: InputVisibility::default(),
1359            idempotency_key: None,
1360            supersession_key: None,
1361            correlation_id: None,
1362        }
1363    }
1364
1365    fn typed_runtime_notice_append(detail: &str) -> ConversationAppend {
1366        ConversationAppend {
1367            role: ConversationAppendRole::SystemNotice,
1368            content: CoreRenderable::SystemNotice {
1369                kind: meerkat_core::types::SystemNoticeKind::Generic,
1370                body: Some(detail.to_string()),
1371                blocks: vec![meerkat_core::types::SystemNoticeBlock::RuntimeNotice {
1372                    category: "test".to_string(),
1373                    detail: Some(detail.to_string()),
1374                    payload: None,
1375                }],
1376            },
1377        }
1378    }
1379
1380    #[test]
1381    fn prompt_input_serde() {
1382        let input = Input::Prompt(PromptInput {
1383            injected_context: Vec::new(),
1384            header: make_header(),
1385            content: "hello".into(),
1386            typed_turn_appends: Vec::new(),
1387            turn_metadata: None,
1388        });
1389        let json = serde_json::to_value(&input).unwrap();
1390        assert_eq!(json["input_type"], "prompt");
1391        let parsed: Input = serde_json::from_value(json).unwrap();
1392        assert!(matches!(parsed, Input::Prompt(_)));
1393    }
1394
1395    #[test]
1396    fn prompt_input_typed_turn_appends_project_without_user_text() {
1397        let append = typed_runtime_notice_append("peer delivery");
1398        let input = Input::Prompt(PromptInput {
1399            injected_context: Vec::new(),
1400            header: make_header(),
1401            content: ContentInput::Text(String::new()),
1402            typed_turn_appends: vec![append.clone()],
1403            turn_metadata: None,
1404        });
1405
1406        let projection = runtime_input_projection(&input);
1407        assert!(
1408            projection.append.is_none(),
1409            "empty runtime-authored prompt carrier must not synthesize a user append"
1410        );
1411        assert_eq!(projection.additional_appends, vec![append]);
1412    }
1413
1414    /// Injected context on a prompt input projects into a distinct
1415    /// `InjectedContext`-role append slot, preserving delivery order — never
1416    /// the generic `additional_appends` carrier (which chains AFTER the user
1417    /// append).
1418    #[test]
1419    fn prompt_input_injected_context_projects_before_user_append() {
1420        let input = Input::Prompt(PromptInput {
1421            injected_context: vec![
1422                ContentInput::Text("ambient alpha".to_string()),
1423                ContentInput::Text("ambient beta".to_string()),
1424            ],
1425            header: make_header(),
1426            content: "the prompt".into(),
1427            typed_turn_appends: Vec::new(),
1428            turn_metadata: None,
1429        });
1430
1431        let projection = runtime_input_projection(&input);
1432        assert_eq!(projection.injected_context_appends.len(), 2);
1433        assert!(
1434            projection
1435                .injected_context_appends
1436                .iter()
1437                .all(|append| { append.role == ConversationAppendRole::InjectedContext })
1438        );
1439        assert_eq!(
1440            projection.injected_context_appends[0].content,
1441            CoreRenderable::Text {
1442                text: "ambient alpha".to_string()
1443            }
1444        );
1445        assert_eq!(
1446            projection.injected_context_appends[1].content,
1447            CoreRenderable::Text {
1448                text: "ambient beta".to_string()
1449            }
1450        );
1451        assert!(
1452            projection.additional_appends.is_empty(),
1453            "injected context must not ride the generic typed_turn_appends carrier"
1454        );
1455        assert!(projection.append.is_some(), "user append must survive");
1456    }
1457
1458    /// Injected context riding a supervisor bridge delivery projects before
1459    /// the peer's own append (which lowers as a SystemNotice).
1460    #[test]
1461    fn peer_input_injected_context_projects_before_peer_append() {
1462        let mut header = make_header();
1463        header.source = InputOrigin::Peer {
1464            peer_id: "peer-1".into(),
1465            display_identity: Some("Peer One".into()),
1466            runtime_id: None,
1467        };
1468        let input = Input::Peer(PeerInput {
1469            directed_interaction_id: None,
1470            objective_id: None,
1471            injected_context: vec![ContentInput::Text("supervisor ambient".to_string())],
1472            sender_taint: None,
1473            header,
1474            convention: Some(PeerConvention::Message),
1475            content: "work content".into(),
1476            payload: None,
1477            handling_mode: None,
1478        });
1479
1480        let projection = runtime_input_projection(&input);
1481        assert_eq!(projection.injected_context_appends.len(), 1);
1482        assert_eq!(
1483            projection.injected_context_appends[0].role,
1484            ConversationAppendRole::InjectedContext
1485        );
1486        assert!(
1487            projection.append.is_some(),
1488            "peer work append must survive alongside injected context"
1489        );
1490    }
1491
1492    /// Absent `injected_context` deserializes to empty (pre-field persisted
1493    /// inputs stay readable) and empty is omitted on serialization.
1494    #[test]
1495    fn prompt_input_injected_context_serde_default_and_omission() {
1496        let input = Input::Prompt(PromptInput {
1497            injected_context: vec![ContentInput::Text("ambient".to_string())],
1498            header: make_header(),
1499            content: "hello".into(),
1500            typed_turn_appends: Vec::new(),
1501            turn_metadata: None,
1502        });
1503        let json = serde_json::to_value(&input).unwrap();
1504        assert!(json.get("injected_context").is_some());
1505        let parsed: Input = serde_json::from_value(json).unwrap();
1506        let Input::Prompt(prompt) = parsed else {
1507            panic!("expected prompt input");
1508        };
1509        assert_eq!(prompt.injected_context.len(), 1);
1510
1511        let empty = Input::Prompt(PromptInput {
1512            injected_context: Vec::new(),
1513            header: make_header(),
1514            content: "hello".into(),
1515            typed_turn_appends: Vec::new(),
1516            turn_metadata: None,
1517        });
1518        let mut json = serde_json::to_value(&empty).unwrap();
1519        assert!(
1520            json.get("injected_context").is_none(),
1521            "empty injected context must be omitted on the wire"
1522        );
1523        // Pre-field persisted input (no key at all) deserializes to empty.
1524        json.as_object_mut().unwrap().remove("injected_context");
1525        let parsed: Input = serde_json::from_value(json).unwrap();
1526        let Input::Prompt(prompt) = parsed else {
1527            panic!("expected prompt input");
1528        };
1529        assert!(prompt.injected_context.is_empty());
1530    }
1531
1532    #[test]
1533    fn prompt_input_typed_turn_appends_serde_roundtrip() {
1534        let append = typed_runtime_notice_append("typed appends persist");
1535        let input = Input::Prompt(PromptInput {
1536            injected_context: Vec::new(),
1537            header: make_header(),
1538            content: ContentInput::Text(String::new()),
1539            typed_turn_appends: vec![append.clone()],
1540            turn_metadata: None,
1541        });
1542
1543        let json = serde_json::to_value(&input).unwrap();
1544        let parsed: Input = serde_json::from_value(json).unwrap();
1545        let Input::Prompt(prompt) = parsed else {
1546            panic!("expected prompt input");
1547        };
1548        assert_eq!(prompt.content.text_content(), "");
1549        assert_eq!(prompt.typed_turn_appends, vec![append]);
1550    }
1551
1552    #[test]
1553    fn peer_input_message_serde() {
1554        let input = Input::Peer(PeerInput {
1555            directed_interaction_id: None,
1556            objective_id: None,
1557            injected_context: Vec::new(),
1558            sender_taint: None,
1559            header: make_header(),
1560            convention: Some(PeerConvention::Message),
1561            content: "hi there".into(),
1562            payload: None,
1563            handling_mode: None,
1564        });
1565        let json = serde_json::to_value(&input).unwrap();
1566        assert_eq!(json["input_type"], "peer");
1567        let parsed: Input = serde_json::from_value(json).unwrap();
1568        assert!(matches!(parsed, Input::Peer(_)));
1569    }
1570
1571    fn peer_input_with(
1572        peer_id: &str,
1573        convention: Option<PeerConvention>,
1574        correlation_id: Option<CorrelationId>,
1575    ) -> Input {
1576        let mut header = make_header();
1577        header.source = InputOrigin::Peer {
1578            peer_id: peer_id.into(),
1579            display_identity: Some("  display-agent  ".into()),
1580            runtime_id: None,
1581        };
1582        header.correlation_id = correlation_id;
1583        Input::Peer(PeerInput {
1584            directed_interaction_id: None,
1585            objective_id: None,
1586            injected_context: Vec::new(),
1587            sender_taint: None,
1588            header,
1589            convention,
1590            content: "hi there".into(),
1591            payload: None,
1592            handling_mode: None,
1593        })
1594    }
1595
1596    /// Only the `InputKind::PeerMessage` grouping mints a reply capability:
1597    /// request/response conventions and non-peer inputs mint none, and a
1598    /// message delivery without a stamped correlation id has no selector.
1599    #[test]
1600    fn non_message_conventions_mint_no_reply_capability() {
1601        let peer_id = "018f6f79-7a82-7c4e-a552-a3b86f963005";
1602        let correlation = CorrelationId::from_uuid(uuid::Uuid::from_u128(9));
1603
1604        let message = peer_input_with(
1605            peer_id,
1606            Some(PeerConvention::Message),
1607            Some(correlation.clone()),
1608        );
1609        let capability = peer_reply_capability(&message)
1610            .expect("message convention with correlation must mint a capability");
1611        assert_eq!(
1612            capability.peer_id,
1613            meerkat_core::comms::PeerId::parse(peer_id).expect("canonical id")
1614        );
1615        assert_eq!(
1616            capability.in_reply_to,
1617            meerkat_core::InteractionId(uuid::Uuid::from_u128(9))
1618        );
1619        assert_eq!(
1620            capability.display_name.as_deref(),
1621            Some("display-agent"),
1622            "display identity must be trimmed"
1623        );
1624        assert_eq!(
1625            capability.kind,
1626            meerkat_core::comms::PeerReplyDeliveryKind::Message
1627        );
1628
1629        let bare = peer_input_with(peer_id, None, Some(correlation.clone()));
1630        assert!(
1631            peer_reply_capability(&bare).is_some(),
1632            "bare peer input groups as PeerMessage and must mint"
1633        );
1634
1635        let request = peer_input_with(
1636            peer_id,
1637            Some(PeerConvention::Request {
1638                request_id: "req-1".into(),
1639                intent: "review".into(),
1640            }),
1641            Some(correlation.clone()),
1642        );
1643        assert!(peer_reply_capability(&request).is_none());
1644
1645        let progress = peer_input_with(
1646            peer_id,
1647            Some(PeerConvention::ResponseProgress {
1648                request_id: "req-1".into(),
1649                phase: ResponseProgressPhase::Accepted,
1650            }),
1651            Some(correlation.clone()),
1652        );
1653        assert!(peer_reply_capability(&progress).is_none());
1654
1655        let terminal = peer_input_with(
1656            peer_id,
1657            Some(PeerConvention::ResponseTerminal {
1658                request_id: "req-1".into(),
1659                status: ResponseTerminalStatus::Completed,
1660            }),
1661            Some(correlation),
1662        );
1663        assert!(peer_reply_capability(&terminal).is_none());
1664
1665        let no_correlation = peer_input_with(peer_id, Some(PeerConvention::Message), None);
1666        assert!(
1667            peer_reply_capability(&no_correlation).is_none(),
1668            "a delivery without a correlation id has no reply selector"
1669        );
1670
1671        let prompt = Input::Prompt(PromptInput::new("hello", None));
1672        assert!(peer_reply_capability(&prompt).is_none());
1673    }
1674
1675    #[test]
1676    fn non_canonical_peer_id_mints_no_reply_capability() {
1677        let input = peer_input_with(
1678            "peer-1",
1679            Some(PeerConvention::Message),
1680            Some(CorrelationId::from_uuid(uuid::Uuid::from_u128(9))),
1681        );
1682        assert!(
1683            peer_reply_capability(&input).is_none(),
1684            "a non-canonical peer id must fail the mint, never smuggle a raw string"
1685        );
1686    }
1687
1688    #[test]
1689    fn peer_message_blocks_preserve_typed_comms_content_without_prefix_injection() {
1690        let peer_id = "018f6f79-7a82-7c4e-a552-a3b86f963005";
1691        let mut header = make_header();
1692        header.source = InputOrigin::Peer {
1693            peer_id: peer_id.into(),
1694            display_identity: Some("display-agent".into()),
1695            runtime_id: None,
1696        };
1697        let input = Input::Peer(PeerInput {
1698            directed_interaction_id: None,
1699            objective_id: None,
1700            injected_context: Vec::new(),
1701            sender_taint: None,
1702            header,
1703            convention: Some(PeerConvention::Message),
1704            content: ContentInput::Blocks(vec![
1705                meerkat_core::types::ContentBlock::Text {
1706                    text: "caption".into(),
1707                },
1708                meerkat_core::types::ContentBlock::Image {
1709                    media_type: "image/png".into(),
1710                    data: "abc".into(),
1711                },
1712            ]),
1713            payload: None,
1714            handling_mode: None,
1715        });
1716
1717        let Input::Peer(peer) = &input else {
1718            panic!("expected peer input");
1719        };
1720        assert_eq!(
1721            peer_projection_from_peer_input(peer)
1722                .and_then(|projection| projection.block_prefix_text())
1723                .as_deref(),
1724            Some(format!("Peer message from {peer_id}").as_str())
1725        );
1726
1727        let projection = runtime_input_projection(&input);
1728        let append = projection.append.expect("conversation append");
1729        let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1730            panic!("expected typed system notice");
1731        };
1732        let Some(meerkat_core::types::SystemNoticeBlock::Comms { content, peer, .. }) =
1733            blocks.first()
1734        else {
1735            panic!("expected comms block");
1736        };
1737        assert_eq!(
1738            peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1739            Some("display-agent")
1740        );
1741        assert_eq!(
1742            content.first(),
1743            Some(&meerkat_core::types::ContentBlock::Text {
1744                text: "caption".into()
1745            })
1746        );
1747    }
1748
1749    /// Ask 5 gate (receiver end-to-end at the transcript notice): a peer
1750    /// input carrying the envelope's sender-declared taint produces a typed
1751    /// `SystemNoticeBlock::Comms` whose `sender_taint` preserves the
1752    /// declaration, and the model projection appends a marker for `Tainted`
1753    /// ONLY — `Clean` and `None` (no declaration) deliberately render
1754    /// identically while the typed field stays distinct.
1755    #[test]
1756    fn peer_message_sender_taint_reaches_typed_comms_notice_and_model_projection() {
1757        use meerkat_core::comms::SenderContentTaint;
1758
1759        let notice_block = |declared: Option<SenderContentTaint>| {
1760            let mut header = make_header();
1761            header.source = InputOrigin::Peer {
1762                peer_id: "018f6f79-7a82-7c4e-a552-a3b86f963005".into(),
1763                display_identity: Some("display-agent".into()),
1764                runtime_id: None,
1765            };
1766            let input = Input::Peer(PeerInput {
1767                directed_interaction_id: None,
1768                objective_id: None,
1769                injected_context: Vec::new(),
1770                sender_taint: declared,
1771                header,
1772                convention: Some(PeerConvention::Message),
1773                content: "hello from peer".into(),
1774                payload: None,
1775                handling_mode: None,
1776            });
1777            let projection = runtime_input_projection(&input);
1778            let append = projection.append.expect("conversation append");
1779            let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
1780                panic!("expected typed system notice");
1781            };
1782            blocks.first().cloned().expect("comms block")
1783        };
1784
1785        let tainted_block = notice_block(Some(SenderContentTaint::Tainted));
1786        let clean_block = notice_block(Some(SenderContentTaint::Clean));
1787        let undeclared_block = notice_block(None);
1788
1789        let taint_of = |block: &meerkat_core::types::SystemNoticeBlock| {
1790            let meerkat_core::types::SystemNoticeBlock::Comms { sender_taint, .. } = block else {
1791                panic!("expected comms block");
1792            };
1793            *sender_taint
1794        };
1795        assert_eq!(taint_of(&tainted_block), Some(SenderContentTaint::Tainted));
1796        assert_eq!(taint_of(&clean_block), Some(SenderContentTaint::Clean));
1797        assert_eq!(
1798            taint_of(&undeclared_block),
1799            None,
1800            "no declaration must stay None in the transcript, never coalesced into Clean"
1801        );
1802
1803        let tainted_text = tainted_block.model_projection_text();
1804        let clean_text = clean_block.model_projection_text();
1805        let undeclared_text = undeclared_block.model_projection_text();
1806        assert!(
1807            tainted_text.contains("[sender declared this content tainted]"),
1808            "declared taint must be model-visible: {tainted_text}"
1809        );
1810        assert_eq!(
1811            clean_text, undeclared_text,
1812            "Clean and no-declaration deliberately render identically; the typed field is the carrier"
1813        );
1814        assert!(!clean_text.contains("tainted"));
1815    }
1816
1817    #[test]
1818    fn peer_response_terminal_context_is_deferred_to_machine_batch_projection() {
1819        let route_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f2";
1820        let request_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f1";
1821        let mut header = make_header();
1822        header.source = InputOrigin::Peer {
1823            peer_id: route_id.into(),
1824            display_identity: Some("display-agent".into()),
1825            runtime_id: None,
1826        };
1827        let input = Input::Peer(PeerInput {
1828            directed_interaction_id: None,
1829            objective_id: None,
1830            injected_context: Vec::new(),
1831            sender_taint: None,
1832            header,
1833            convention: Some(PeerConvention::ResponseTerminal {
1834                request_id: request_id.into(),
1835                status: ResponseTerminalStatus::Completed,
1836            }),
1837            content: "response body".into(),
1838            payload: Some(serde_json::json!({"answer":"ok"})),
1839            handling_mode: None,
1840        });
1841
1842        let Input::Peer(peer) = &input else {
1843            panic!("expected peer input");
1844        };
1845        let expected_canonical_key = format!("peer_response_terminal:{route_id}:{request_id}");
1846        assert!(
1847            peer_projection_from_peer_input(peer).is_none(),
1848            "terminal peer response projection must not be built before machine batch selection"
1849        );
1850
1851        let projection = runtime_input_projection(&input);
1852        assert!(
1853            projection.context_append.is_none(),
1854            "admission projection must not store terminal peer response context"
1855        );
1856        let projection = runtime_input_projection_for_machine_batch(&input);
1857        let context = projection.context_append.expect("context append");
1858        assert_eq!(context.key, expected_canonical_key);
1859        let CoreRenderable::SystemNotice { blocks, .. } = context.content else {
1860            panic!("expected typed context");
1861        };
1862        let Some(meerkat_core::types::SystemNoticeBlock::Comms { peer, .. }) = blocks.first()
1863        else {
1864            panic!("expected comms block");
1865        };
1866        assert_eq!(
1867            peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
1868            Some("display-agent")
1869        );
1870        assert_eq!(
1871            peer.as_ref().map(|peer| peer.id),
1872            Some(meerkat_core::comms::PeerId::parse(route_id).expect("valid route id"))
1873        );
1874    }
1875
1876    #[test]
1877    fn steer_projection_uses_context_append_as_pending_system_context() {
1878        let input_id = InputId::new();
1879        let projection = crate::ingress_types::RuntimeInputProjection {
1880            injected_context_appends: Vec::new(),
1881            append: Some(ConversationAppend {
1882                role: ConversationAppendRole::SystemNotice,
1883                content: CoreRenderable::Text {
1884                    text: "ordinary append must lose to context append".into(),
1885                },
1886            }),
1887            additional_appends: Vec::new(),
1888            context_append: Some(ConversationContextAppend {
1889                key: "peer_response_terminal:peer:req".into(),
1890                content: CoreRenderable::Text {
1891                    text: "terminal response is ready".into(),
1892                },
1893            }),
1894            peer_response_terminal: None,
1895        };
1896
1897        let appends = projection_to_pending_system_context_appends(&input_id, &projection);
1898
1899        assert_eq!(appends.len(), 1);
1900        assert_eq!(
1901            appends[0].content.render_text(),
1902            "terminal response is ready"
1903        );
1904        assert_eq!(
1905            appends[0].source.as_deref(),
1906            Some("peer_response_terminal:peer:req")
1907        );
1908        assert_eq!(
1909            appends[0].idempotency_key.as_deref(),
1910            Some("peer_response_terminal:peer:req")
1911        );
1912    }
1913
1914    #[test]
1915    fn continuation_projection_can_carry_runtime_context_append() {
1916        let input = Input::Continuation(ContinuationInput {
1917            header: make_header(),
1918            reason: "workgraph_attention".into(),
1919            continuation_kind: ContinuationKind::WorkgraphAttention,
1920            handling_mode: HandlingMode::Steer,
1921            request_id: Some("binding-1".into()),
1922            turn_tool_overlay: Some(TurnToolOverlay {
1923                allowed_tools: Some(vec!["workgraph_add_evidence".into()]),
1924                blocked_tools: None,
1925                dispatch_context: Default::default(),
1926            }),
1927            context_append: Some(ConversationContextAppend {
1928                key: "workgraph_attention:binding-1:2:5".into(),
1929                content: CoreRenderable::Text {
1930                    text: "WorkGraph attention projection".into(),
1931                },
1932            }),
1933            turn_append: None,
1934        });
1935        let projection = runtime_input_projection_for_machine_batch(&input);
1936        let appends = projection_to_pending_system_context_appends(input.id(), &projection);
1937
1938        assert_eq!(appends.len(), 1);
1939        assert_eq!(
1940            appends[0].content.render_text(),
1941            "WorkGraph attention projection"
1942        );
1943        assert_eq!(
1944            appends[0].source.as_deref(),
1945            Some("workgraph_attention:binding-1:2:5")
1946        );
1947        let metadata = crate::runtime_loop::for_input(
1948            &input,
1949            crate::ingress_types::RuntimeInputSemantics {
1950                boundary: meerkat_core::lifecycle::run_primitive::RunApplyBoundary::RunStart,
1951                execution_kind: meerkat_core::lifecycle::RuntimeExecutionKind::ContentTurn,
1952                execution_handling_mode: None,
1953                peer_response_terminal_apply_intent: None,
1954                live_interrupt_required: false,
1955            },
1956        );
1957        assert_eq!(
1958            metadata
1959                .turn_tool_overlay
1960                .and_then(|overlay| overlay.allowed_tools),
1961            Some(vec!["workgraph_add_evidence".into()])
1962        );
1963    }
1964
1965    #[test]
1966    fn steer_projection_falls_back_to_ordinary_peer_append() {
1967        let mut header = make_header();
1968        header.source = InputOrigin::Peer {
1969            peer_id: "peer-a".into(),
1970            display_identity: Some("Peer A".into()),
1971            runtime_id: None,
1972        };
1973        let input = Input::Peer(PeerInput {
1974            directed_interaction_id: None,
1975            objective_id: None,
1976            injected_context: Vec::new(),
1977            sender_taint: None,
1978            header,
1979            convention: Some(PeerConvention::Message),
1980            content: "please look at this while you work".into(),
1981            payload: None,
1982            handling_mode: Some(HandlingMode::Steer),
1983        });
1984        let input_id = input.id().clone();
1985        let projection = runtime_input_projection(&input);
1986
1987        let appends = projection_to_pending_system_context_appends(&input_id, &projection);
1988
1989        assert_eq!(appends.len(), 1);
1990        let rendered = appends[0].content.render_text();
1991        assert!(
1992            rendered.contains("please look at this while you work"),
1993            "peer message append should be renderable as live system context: {rendered:?}"
1994        );
1995        assert_eq!(
1996            appends[0].source.as_deref(),
1997            Some(format!("runtime:steer:{input_id}").as_str())
1998        );
1999        assert_eq!(
2000            appends[0].idempotency_key.as_deref(),
2001            Some(format!("runtime:steer:{input_id}").as_str())
2002        );
2003    }
2004
2005    #[test]
2006    fn steer_projection_filters_empty_context_and_empty_append() {
2007        let input_id = InputId::new();
2008        let context_projection = crate::ingress_types::RuntimeInputProjection {
2009            injected_context_appends: Vec::new(),
2010            append: None,
2011            additional_appends: Vec::new(),
2012            context_append: Some(ConversationContextAppend {
2013                key: "empty-context".into(),
2014                content: CoreRenderable::Text { text: "  ".into() },
2015            }),
2016            peer_response_terminal: None,
2017        };
2018        assert!(
2019            projection_to_pending_system_context_appends(&input_id, &context_projection).is_empty()
2020        );
2021
2022        let append_projection = crate::ingress_types::RuntimeInputProjection {
2023            injected_context_appends: Vec::new(),
2024            append: Some(ConversationAppend {
2025                role: ConversationAppendRole::SystemNotice,
2026                content: CoreRenderable::Text { text: "\n".into() },
2027            }),
2028            additional_appends: Vec::new(),
2029            context_append: None,
2030            peer_response_terminal: None,
2031        };
2032        assert!(
2033            projection_to_pending_system_context_appends(&input_id, &append_projection).is_empty()
2034        );
2035    }
2036
2037    #[test]
2038    fn peer_response_terminal_with_blocks_projects_append_and_context() {
2039        let route_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f2";
2040        let request_id = "018f6f79-7a82-7c4e-a552-a3b86f9630f1";
2041        let mut header = make_header();
2042        header.source = InputOrigin::Peer {
2043            peer_id: route_id.into(),
2044            display_identity: Some("display-agent".into()),
2045            runtime_id: None,
2046        };
2047        let input = Input::Peer(PeerInput {
2048            directed_interaction_id: None,
2049            objective_id: None,
2050            injected_context: Vec::new(),
2051            sender_taint: None,
2052            header,
2053            convention: Some(PeerConvention::ResponseTerminal {
2054                request_id: request_id.into(),
2055                status: ResponseTerminalStatus::Completed,
2056            }),
2057            content: ContentInput::Blocks(vec![meerkat_core::types::ContentBlock::Image {
2058                media_type: "image/jpeg".into(),
2059                data: "abc".into(),
2060            }]),
2061            payload: Some(serde_json::json!({"answer":"ok"})),
2062            handling_mode: None,
2063        });
2064
2065        let projection = runtime_input_projection_for_machine_batch(&input);
2066        let append = projection.append.expect("conversation append");
2067        let CoreRenderable::SystemNotice { blocks, .. } = append.content else {
2068            panic!("expected typed append");
2069        };
2070        let Some(meerkat_core::types::SystemNoticeBlock::Comms { content, peer, .. }) =
2071            blocks.first()
2072        else {
2073            panic!("expected comms block");
2074        };
2075        assert_eq!(
2076            peer.as_ref().and_then(|peer| peer.display_name.as_deref()),
2077            Some("display-agent")
2078        );
2079        assert!(matches!(
2080            content.first(),
2081            Some(meerkat_core::types::ContentBlock::Image { media_type, .. })
2082                if media_type == "image/jpeg"
2083        ));
2084        assert!(
2085            projection.context_append.is_some(),
2086            "terminal response must still apply runtime-owned context"
2087        );
2088    }
2089
2090    #[test]
2091    fn peer_input_request_serde() {
2092        let input = Input::Peer(PeerInput {
2093            directed_interaction_id: None,
2094            objective_id: None,
2095            injected_context: Vec::new(),
2096            sender_taint: None,
2097            header: make_header(),
2098            convention: Some(PeerConvention::Request {
2099                request_id: "req-1".into(),
2100                intent: "mob.peer_added".into(),
2101            }),
2102            content: "Agent joined".into(),
2103            payload: Some(serde_json::json!({"name": "agent-1"})),
2104            handling_mode: None,
2105        });
2106        let json = serde_json::to_value(&input).unwrap();
2107        let parsed: Input = serde_json::from_value(json).unwrap();
2108        if let Input::Peer(p) = parsed {
2109            assert!(matches!(p.convention, Some(PeerConvention::Request { .. })));
2110        } else {
2111            panic!("Expected PeerInput");
2112        }
2113    }
2114
2115    #[test]
2116    fn peer_input_response_terminal_serde() {
2117        let input = Input::Peer(PeerInput {
2118            directed_interaction_id: None,
2119            objective_id: None,
2120            injected_context: Vec::new(),
2121            sender_taint: None,
2122            header: make_header(),
2123            convention: Some(PeerConvention::ResponseTerminal {
2124                request_id: "req-1".into(),
2125                status: ResponseTerminalStatus::Completed,
2126            }),
2127            content: "Done".into(),
2128            payload: Some(serde_json::json!({"ok": true})),
2129            handling_mode: None,
2130        });
2131        let json = serde_json::to_value(&input).unwrap();
2132        let parsed: Input = serde_json::from_value(json).unwrap();
2133        assert!(matches!(parsed, Input::Peer(_)));
2134    }
2135
2136    #[test]
2137    fn peer_input_response_progress_serde() {
2138        let input = Input::Peer(PeerInput {
2139            directed_interaction_id: None,
2140            objective_id: None,
2141            injected_context: Vec::new(),
2142            sender_taint: None,
2143            header: make_header(),
2144            convention: Some(PeerConvention::ResponseProgress {
2145                request_id: "req-1".into(),
2146                phase: ResponseProgressPhase::InProgress,
2147            }),
2148            content: "Working...".into(),
2149            payload: Some(serde_json::json!({"progress": "working"})),
2150            handling_mode: None,
2151        });
2152        let json = serde_json::to_value(&input).unwrap();
2153        let parsed: Input = serde_json::from_value(json).unwrap();
2154        assert!(matches!(parsed, Input::Peer(_)));
2155    }
2156
2157    #[test]
2158    fn flow_step_input_serde() {
2159        let input = Input::FlowStep(FlowStepInput {
2160            header: make_header(),
2161            step_id: "step-1".into(),
2162            content: ContentInput::Blocks(vec![
2163                meerkat_core::types::ContentBlock::Text {
2164                    text: "analyze the data".into(),
2165                },
2166                meerkat_core::types::ContentBlock::Image {
2167                    media_type: "image/png".into(),
2168                    data: meerkat_core::types::ImageData::Inline {
2169                        data: "abc123".into(),
2170                    },
2171                },
2172            ]),
2173            directed_interaction_id: None,
2174            turn_metadata: None,
2175        });
2176        let json = serde_json::to_value(&input).unwrap();
2177        assert_eq!(json["input_type"], "flow_step");
2178        let parsed: Input = serde_json::from_value(json).unwrap();
2179        assert!(matches!(parsed, Input::FlowStep(_)));
2180    }
2181
2182    #[test]
2183    fn flow_step_uses_the_canonical_runtime_run_started_projection() {
2184        let flow_step = FlowStepInput {
2185            header: make_header(),
2186            step_id: "step-1".into(),
2187            content: ContentInput::Text("go\n\"quoted\" \\ path".into()),
2188            directed_interaction_id: None,
2189            turn_metadata: None,
2190        };
2191        let input = Input::FlowStep(flow_step);
2192        let projected = runtime_input_projection(&input)
2193            .append
2194            .expect("flow step projects a run append")
2195            .content
2196            .render_text();
2197
2198        assert_eq!(projected, "Flow step step-1\ngo\n\"quoted\" \\ path");
2199        assert_eq!(
2200            runtime_input_run_started_content(&input)
2201                .expect("flow step starts a model-visible run"),
2202            ContentInput::Text(projected),
2203        );
2204    }
2205
2206    #[test]
2207    fn multimodal_flow_step_uses_the_canonical_runtime_run_started_projection() {
2208        let flow_step = FlowStepInput {
2209            header: make_header(),
2210            step_id: "vision-step".into(),
2211            content: ContentInput::Blocks(vec![
2212                meerkat_core::types::ContentBlock::Text {
2213                    text: "inspect this\nimage".into(),
2214                },
2215                meerkat_core::types::ContentBlock::Image {
2216                    media_type: "image/png".into(),
2217                    data: meerkat_core::types::ImageData::Inline {
2218                        data: "abc123".into(),
2219                    },
2220                },
2221            ]),
2222            directed_interaction_id: None,
2223            turn_metadata: None,
2224        };
2225        let input = Input::FlowStep(flow_step);
2226        let projected = runtime_input_projection(&input)
2227            .append
2228            .expect("multimodal flow step projects a run append")
2229            .content
2230            .render_text();
2231
2232        assert_eq!(
2233            runtime_input_run_started_content(&input)
2234                .expect("multimodal flow step starts a model-visible run"),
2235            ContentInput::Text(projected),
2236        );
2237    }
2238
2239    #[test]
2240    fn directed_peer_run_started_content_preserves_context_and_multimodal_projection() {
2241        let stable = uuid::Uuid::from_u128(0x00000000000040008000000000000123);
2242        let interaction_id = meerkat_core::interaction::InteractionId(stable);
2243        let input = Input::Peer(PeerInput {
2244            directed_interaction_id: Some(interaction_id),
2245            objective_id: Some(meerkat_core::interaction::ObjectiveId::new()),
2246            injected_context: vec![ContentInput::Text("ambient context".to_string())],
2247            sender_taint: None,
2248            header: InputHeader {
2249                id: InputId::from_uuid(stable),
2250                timestamp: Utc::now(),
2251                source: InputOrigin::Peer {
2252                    peer_id: uuid::Uuid::from_u128(7).to_string(),
2253                    display_identity: Some("supervisor".to_string()),
2254                    runtime_id: Some(LogicalRuntimeId::new("rt:session:placed")),
2255                },
2256                durability: InputDurability::Durable,
2257                visibility: InputVisibility::default(),
2258                idempotency_key: Some(IdempotencyKey::new(stable.to_string())),
2259                supersession_key: None,
2260                correlation_id: Some(CorrelationId::from_uuid(stable)),
2261            },
2262            convention: Some(PeerConvention::Message),
2263            content: ContentInput::Blocks(vec![
2264                meerkat_core::types::ContentBlock::Text {
2265                    text: "inspect this image".to_string(),
2266                },
2267                meerkat_core::types::ContentBlock::Image {
2268                    media_type: "image/png".to_string(),
2269                    data: meerkat_core::types::ImageData::Inline {
2270                        data: "abc123".to_string(),
2271                    },
2272                },
2273            ]),
2274            payload: None,
2275            handling_mode: Some(meerkat_core::types::HandlingMode::Queue),
2276        });
2277
2278        let projection = runtime_input_projection(&input);
2279        let appends = projection
2280            .injected_context_appends
2281            .into_iter()
2282            .chain(projection.append)
2283            .chain(projection.additional_appends)
2284            .collect::<Vec<_>>();
2285        let expected = meerkat_core::lifecycle::run_primitive::model_projection_content_input_from_conversation_appends(
2286            &appends,
2287        );
2288        let actual = directed_input_run_started_content(&input)
2289            .expect("valid directed peer owns a turn-start projection");
2290
2291        assert_eq!(actual, expected);
2292        assert!(actual.text_content().contains("ambient context"));
2293        assert!(actual.text_content().contains("inspect this image"));
2294        assert!(
2295            matches!(actual, ContentInput::Blocks(ref blocks) if blocks.iter().any(|block| matches!(block, meerkat_core::types::ContentBlock::Image { .. })))
2296        );
2297    }
2298
2299    #[test]
2300    fn external_event_input_serde() {
2301        let input = Input::ExternalEvent(ExternalEventInput {
2302            objective_id: None,
2303            header: make_header(),
2304            event_type: "webhook.received".into(),
2305            payload: serde_json::json!({"url": "https://example.com"}),
2306            blocks: Some(vec![
2307                meerkat_core::types::ContentBlock::Text {
2308                    text: "look".into(),
2309                },
2310                meerkat_core::types::ContentBlock::Image {
2311                    media_type: "image/png".into(),
2312                    data: meerkat_core::types::ImageData::Inline {
2313                        data: "abc123".into(),
2314                    },
2315                },
2316            ]),
2317            handling_mode: HandlingMode::Queue,
2318            render_metadata: None,
2319        });
2320        let json = serde_json::to_value(&input).unwrap();
2321        assert_eq!(json["input_type"], "external_event");
2322        let parsed: Input = serde_json::from_value(json).unwrap();
2323        assert!(matches!(parsed, Input::ExternalEvent(_)));
2324    }
2325
2326    #[test]
2327    fn legacy_external_event_payload_blocks_are_rejected() {
2328        // The retired shape smuggled multimodal blocks inside the payload
2329        // JSON. It must fail closed with a typed error — never migrate.
2330        let event = ExternalEventInput {
2331            objective_id: None,
2332            header: make_header(),
2333            event_type: "webhook.received".into(),
2334            payload: serde_json::json!({
2335                "body": "see image",
2336                "blocks": [
2337                    { "type": "text", "text": "caption text" },
2338                    { "type": "image", "media_type": "image/png", "source": "inline", "data": "abc123" }
2339                ]
2340            }),
2341            blocks: None,
2342            handling_mode: HandlingMode::Queue,
2343            render_metadata: None,
2344        };
2345
2346        let err = reject_legacy_payload_blocks(&event)
2347            .expect_err("payload-level blocks must fail closed");
2348        assert!(matches!(err, BlobStoreError::Internal(_)));
2349        // Payload is untouched: rejection never strips or rewrites it.
2350        assert!(event.payload.get("blocks").is_some());
2351        assert!(event.blocks.is_none());
2352    }
2353
2354    #[test]
2355    fn external_event_payload_without_blocks_key_passes_rejection_gate() {
2356        let event = ExternalEventInput {
2357            objective_id: None,
2358            header: make_header(),
2359            event_type: "webhook.received".into(),
2360            payload: serde_json::json!({ "body": "plain payload" }),
2361            blocks: Some(vec![meerkat_core::types::ContentBlock::Text {
2362                text: "typed owner content".into(),
2363            }]),
2364            handling_mode: HandlingMode::Queue,
2365            render_metadata: None,
2366        };
2367
2368        reject_legacy_payload_blocks(&event)
2369            .expect("payload without a legacy blocks key must pass");
2370    }
2371
2372    #[test]
2373    fn continuation_input_serde() {
2374        let input = Input::Continuation(ContinuationInput::detached_background_op_completed());
2375        let json = serde_json::to_value(&input).unwrap();
2376        assert_eq!(json["input_type"], "continuation");
2377        let parsed: Input = serde_json::from_value(json).unwrap();
2378        match parsed {
2379            Input::Continuation(continuation) => {
2380                assert_eq!(continuation.handling_mode, HandlingMode::Steer);
2381                assert_eq!(continuation.reason, "detached_background_op_completed");
2382            }
2383            other => panic!("Expected Continuation, got {other:?}"),
2384        }
2385    }
2386
2387    #[test]
2388    fn continuation_input_rejects_legacy_system_generated_tag() {
2389        // The pre-rename `system_generated` tag is a retired persisted shape:
2390        // it must fail closed instead of being folded into `continuation`.
2391        let input = Input::Continuation(ContinuationInput::detached_background_op_completed());
2392        let mut json = serde_json::to_value(&input).unwrap();
2393        json["input_type"] = serde_json::Value::String("system_generated".into());
2394        serde_json::from_value::<Input>(json)
2395            .expect_err("legacy system_generated input_type tag must be rejected");
2396    }
2397
2398    #[test]
2399    fn operation_input_serde() {
2400        let input = Input::Operation(OperationInput {
2401            header: InputHeader {
2402                durability: InputDurability::Derived,
2403                ..make_header()
2404            },
2405            operation_id: OperationId::new(),
2406            event: OpEvent::Cancelled {
2407                id: OperationId::new(),
2408            },
2409        });
2410        let json = serde_json::to_value(&input).unwrap();
2411        assert_eq!(json["input_type"], "operation");
2412        let parsed: Input = serde_json::from_value(json).unwrap();
2413        assert!(matches!(parsed, Input::Operation(_)));
2414    }
2415
2416    #[test]
2417    fn operation_input_rejects_legacy_projected_tag() {
2418        // The pre-rename `projected` tag is a retired persisted shape: it
2419        // must fail closed instead of being folded into `operation`.
2420        let input = Input::Operation(OperationInput {
2421            header: InputHeader {
2422                durability: InputDurability::Derived,
2423                ..make_header()
2424            },
2425            operation_id: OperationId::new(),
2426            event: OpEvent::Cancelled {
2427                id: OperationId::new(),
2428            },
2429        });
2430        let mut json = serde_json::to_value(&input).unwrap();
2431        json["input_type"] = serde_json::Value::String("projected".into());
2432        serde_json::from_value::<Input>(json)
2433            .expect_err("legacy projected input_type tag must be rejected");
2434    }
2435
2436    #[test]
2437    fn legacy_dual_carrier_input_shapes_are_rejected() {
2438        // The retired persisted shape stored the content fact twice: a textual
2439        // carrier (`text` / `body` / `instructions`) plus optional `blocks`.
2440        // The single typed `content` owner replaced both; old shapes must fail
2441        // closed instead of being coerced.
2442        let header = serde_json::to_value(make_header()).unwrap();
2443
2444        let legacy_prompt = serde_json::json!({
2445            "input_type": "prompt",
2446            "header": header.clone(),
2447            "text": "hello",
2448            "blocks": null
2449        });
2450        serde_json::from_value::<Input>(legacy_prompt)
2451            .expect_err("legacy prompt text+blocks shape must be rejected");
2452
2453        let legacy_peer = serde_json::json!({
2454            "input_type": "peer",
2455            "header": header.clone(),
2456            "convention": { "convention_type": "message" },
2457            "body": "hi there"
2458        });
2459        serde_json::from_value::<Input>(legacy_peer)
2460            .expect_err("legacy peer body+blocks shape must be rejected");
2461
2462        let legacy_flow_step = serde_json::json!({
2463            "input_type": "flow_step",
2464            "header": header,
2465            "step_id": "step-1",
2466            "instructions": "analyze the data"
2467        });
2468        serde_json::from_value::<Input>(legacy_flow_step)
2469            .expect_err("legacy flow-step instructions+blocks shape must be rejected");
2470    }
2471
2472    #[test]
2473    fn input_kind_id() {
2474        let prompt = Input::Prompt(PromptInput {
2475            injected_context: Vec::new(),
2476            header: make_header(),
2477            content: "hi".into(),
2478            typed_turn_appends: Vec::new(),
2479            turn_metadata: None,
2480        });
2481        assert_eq!(prompt.kind(), InputKind::Prompt);
2482
2483        let peer_msg = Input::Peer(PeerInput {
2484            directed_interaction_id: None,
2485            objective_id: None,
2486            injected_context: Vec::new(),
2487            sender_taint: None,
2488            header: make_header(),
2489            convention: Some(PeerConvention::Message),
2490            content: "hi".into(),
2491            payload: None,
2492            handling_mode: None,
2493        });
2494        assert_eq!(peer_msg.kind(), InputKind::PeerMessage);
2495
2496        let peer_req = Input::Peer(PeerInput {
2497            directed_interaction_id: None,
2498            objective_id: None,
2499            injected_context: Vec::new(),
2500            sender_taint: None,
2501            header: make_header(),
2502            convention: Some(PeerConvention::Request {
2503                request_id: "r".into(),
2504                intent: "i".into(),
2505            }),
2506            content: "hi".into(),
2507            payload: Some(serde_json::json!({"subject": "x"})),
2508            handling_mode: None,
2509        });
2510        assert_eq!(peer_req.kind(), InputKind::PeerRequest);
2511
2512        let continuation = Input::Continuation(ContinuationInput {
2513            header: make_header(),
2514            reason: "continue".into(),
2515            continuation_kind: ContinuationKind::Ordinary,
2516            handling_mode: HandlingMode::Steer,
2517            request_id: None,
2518            turn_tool_overlay: None,
2519            context_append: None,
2520            turn_append: None,
2521        });
2522        assert_eq!(continuation.kind(), InputKind::Continuation);
2523
2524        let operation = Input::Operation(OperationInput {
2525            header: make_header(),
2526            operation_id: OperationId::new(),
2527            event: OpEvent::Cancelled {
2528                id: OperationId::new(),
2529            },
2530        });
2531        assert_eq!(operation.kind(), InputKind::Operation);
2532    }
2533
2534    #[test]
2535    fn input_source_variants() {
2536        let sources = vec![
2537            InputOrigin::Operator,
2538            InputOrigin::Peer {
2539                peer_id: "p1".into(),
2540                display_identity: None,
2541                runtime_id: None,
2542            },
2543            InputOrigin::Flow {
2544                flow_id: "f1".into(),
2545                step_index: 0,
2546            },
2547            InputOrigin::System,
2548            InputOrigin::External {
2549                source_name: "webhook".into(),
2550            },
2551        ];
2552        for source in sources {
2553            let json = serde_json::to_value(&source).unwrap();
2554            let parsed: InputOrigin = serde_json::from_value(json).unwrap();
2555            assert_eq!(source, parsed);
2556        }
2557    }
2558
2559    #[test]
2560    fn input_durability_serde() {
2561        for d in [
2562            InputDurability::Durable,
2563            InputDurability::Ephemeral,
2564            InputDurability::Derived,
2565        ] {
2566            let json = serde_json::to_value(d).unwrap();
2567            let parsed: InputDurability = serde_json::from_value(json).unwrap();
2568            assert_eq!(d, parsed);
2569        }
2570    }
2571
2572    #[test]
2573    fn peer_input_without_handling_mode_deserializes_as_none() {
2574        // Serialized PeerInput without the optional handling_mode field.
2575        let json = serde_json::json!({
2576            "input_type": "peer",
2577            "header": serde_json::to_value(make_header()).unwrap(),
2578            "convention": { "convention_type": "message" },
2579            "content": "hello"
2580        });
2581        let parsed: Input = serde_json::from_value(json).unwrap();
2582        match parsed {
2583            Input::Peer(p) => assert!(p.handling_mode.is_none()),
2584            other => panic!("Expected Peer, got {other:?}"),
2585        }
2586    }
2587
2588    #[test]
2589    fn peer_input_with_queue_handling_mode_roundtrips() {
2590        let input = Input::Peer(PeerInput {
2591            directed_interaction_id: None,
2592            objective_id: None,
2593            injected_context: Vec::new(),
2594            sender_taint: None,
2595            header: make_header(),
2596            convention: Some(PeerConvention::Message),
2597            content: "hi".into(),
2598            payload: None,
2599            handling_mode: Some(HandlingMode::Queue),
2600        });
2601        let json = serde_json::to_value(&input).unwrap();
2602        assert_eq!(json["handling_mode"], "queue");
2603        let parsed: Input = serde_json::from_value(json).unwrap();
2604        match parsed {
2605            Input::Peer(p) => assert_eq!(p.handling_mode, Some(HandlingMode::Queue)),
2606            other => panic!("Expected Peer, got {other:?}"),
2607        }
2608    }
2609
2610    #[test]
2611    fn peer_response_terminal_input_owns_wire_status_mapping() {
2612        let peer_id = meerkat_core::comms::PeerId::from_uuid(
2613            uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000161").unwrap(),
2614        );
2615        let display_name = meerkat_core::comms::PeerName::new("analyst").unwrap();
2616        let request_id = meerkat_core::PeerCorrelationId::from_uuid(
2617            uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap(),
2618        );
2619        let input = peer_response_terminal_input(
2620            peer_id,
2621            Some(display_name),
2622            request_id,
2623            meerkat_contracts::PeerResponseTerminalStatusWire::Completed,
2624            serde_json::json!({"ok": true}),
2625        );
2626
2627        match input {
2628            Input::Peer(PeerInput {
2629                header:
2630                    InputHeader {
2631                        source:
2632                            InputOrigin::Peer {
2633                                peer_id,
2634                                display_identity,
2635                                runtime_id,
2636                            },
2637                        durability: InputDurability::Durable,
2638                        correlation_id,
2639                        ..
2640                    },
2641                convention: Some(PeerConvention::ResponseTerminal { request_id, status }),
2642                payload: Some(payload),
2643                handling_mode: None,
2644                ..
2645            }) => {
2646                assert_eq!(peer_id, "00000000-0000-4000-8000-000000000161");
2647                assert_eq!(display_identity.as_deref(), Some("analyst"));
2648                assert_eq!(runtime_id, None);
2649                assert_eq!(request_id, "00000000-0000-4000-8000-000000000162");
2650                assert_eq!(
2651                    correlation_id,
2652                    Some(CorrelationId::from_uuid(
2653                        uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap()
2654                    ))
2655                );
2656                assert_eq!(status, ResponseTerminalStatus::Completed);
2657                assert_eq!(payload["ok"], true);
2658            }
2659            other => panic!("expected terminal peer input, got {other:?}"),
2660        }
2661    }
2662
2663    #[test]
2664    fn absent_peer_directed_interaction_defaults_none_and_none_is_omitted() {
2665        let input = peer_response_terminal_input(
2666            meerkat_core::comms::PeerId::from_uuid(uuid::Uuid::new_v4()),
2667            None,
2668            meerkat_core::PeerCorrelationId::from_uuid(uuid::Uuid::new_v4()),
2669            meerkat_contracts::PeerResponseTerminalStatusWire::Completed,
2670            serde_json::json!({"ok": true}),
2671        );
2672        let encoded = serde_json::to_value(&input).expect("serialize ordinary peer input");
2673        assert!(
2674            encoded.get("directed_interaction_id").is_none(),
2675            "ordinary peer persistence must retain the pre-field wire shape"
2676        );
2677
2678        let decoded: Input = serde_json::from_value(encoded).expect("deserialize absent field");
2679        let Input::Peer(peer) = decoded else {
2680            panic!("peer input round-trips as peer input");
2681        };
2682        assert_eq!(peer.directed_interaction_id, None);
2683    }
2684
2685    #[test]
2686    fn peer_response_terminal_validation_is_structural_only() {
2687        let peer_id = meerkat_core::comms::PeerId::from_uuid(
2688            uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000161").unwrap(),
2689        );
2690        let display_name = meerkat_core::comms::PeerName::new("analyst").unwrap();
2691        let request_id = meerkat_core::PeerCorrelationId::from_uuid(
2692            uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000162").unwrap(),
2693        );
2694        let input = peer_response_terminal_input(
2695            peer_id,
2696            Some(display_name),
2697            request_id,
2698            meerkat_contracts::PeerResponseTerminalStatusWire::Cancelled,
2699            serde_json::json!({"ok": false}),
2700        );
2701
2702        validate_peer_response_terminal_fact(&input)
2703            .expect("status support is generated admission authority, structural fact validation should pass");
2704    }
2705
2706    #[test]
2707    fn peer_input_with_steer_handling_mode_roundtrips() {
2708        let input = Input::Peer(PeerInput {
2709            directed_interaction_id: None,
2710            objective_id: None,
2711            injected_context: Vec::new(),
2712            sender_taint: None,
2713            header: make_header(),
2714            convention: Some(PeerConvention::Message),
2715            content: "hi".into(),
2716            payload: None,
2717            handling_mode: Some(HandlingMode::Steer),
2718        });
2719        let json = serde_json::to_value(&input).unwrap();
2720        assert_eq!(json["handling_mode"], "steer");
2721        let parsed: Input = serde_json::from_value(json).unwrap();
2722        match parsed {
2723            Input::Peer(p) => assert_eq!(p.handling_mode, Some(HandlingMode::Steer)),
2724            other => panic!("Expected Peer, got {other:?}"),
2725        }
2726    }
2727
2728    #[test]
2729    fn peer_input_handling_mode_not_serialized_when_none() {
2730        let input = Input::Peer(PeerInput {
2731            directed_interaction_id: None,
2732            objective_id: None,
2733            injected_context: Vec::new(),
2734            sender_taint: None,
2735            header: make_header(),
2736            convention: Some(PeerConvention::Message),
2737            content: "hi".into(),
2738            payload: None,
2739            handling_mode: None,
2740        });
2741        let json = serde_json::to_value(&input).unwrap();
2742        assert!(json.get("handling_mode").is_none());
2743    }
2744}