Skip to main content

meerkat_runtime/
input.rs

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