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