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