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