1use chrono::{DateTime, Utc};
19use meerkat_core::event::AgentEvent;
20use meerkat_core::interaction::InteractionId;
21use meerkat_core::lifecycle::{InputId, RunId};
22use meerkat_core::types::{HandlingMode, SessionId};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26use crate::identifiers::PolicyVersion;
27use crate::ingress_types::RuntimeInputSemantics;
28use crate::input::Input;
29use crate::policy::PolicyDecision;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34#[non_exhaustive]
35pub enum InputLifecycleState {
36 Accepted,
37 Queued,
38 Staged,
39 Applied,
40 AppliedPendingConsumption,
41 Consumed,
42 Superseded,
43 Coalesced,
44 Abandoned,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum InputAbandonReason {
52 Retired,
53 Reset,
54 Stopped,
55 Destroyed,
56 Cancelled,
57 MaxAttemptsExhausted { attempts: u32 },
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[serde(tag = "outcome_type", rename_all = "snake_case")]
66#[non_exhaustive]
67pub enum InputTerminalOutcome {
68 Consumed,
69 Superseded { superseded_by: InputId },
70 Coalesced { aggregate_id: InputId },
71 Abandoned { reason: InputAbandonReason },
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct InputStateHistoryEntry {
77 pub timestamp: DateTime<Utc>,
78 pub from: InputLifecycleState,
79 pub to: InputLifecycleState,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub reason: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct PolicySnapshot {
87 pub version: PolicyVersion,
88 pub decision: PolicyDecision,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(tag = "source_type", rename_all = "snake_case")]
94#[non_exhaustive]
95pub enum ReconstructionSource {
96 Projection {
97 rule_id: String,
98 source_event_id: String,
99 },
100 Coalescing {
101 source_input_ids: Vec<InputId>,
102 },
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(tag = "candidate_type", rename_all = "snake_case")]
110pub(crate) enum InteractionTerminalCandidate {
111 RunResult {
112 result: Box<meerkat_core::types::RunResult>,
113 },
114 CompletedWithoutResult,
115 CallbackPending {
116 #[serde(default, skip_serializing_if = "Option::is_none")]
122 tool_use_id: Option<String>,
123 tool_name: String,
124 args: serde_json::Value,
125 },
126 CallbackBatchPending {
127 pending_tool_calls: Vec<meerkat_core::error::PendingCallbackToolCall>,
128 },
129 MachineTerminalFailure {
134 error: meerkat_core::TurnErrorMetadata,
135 },
136 Cancelled,
137 RuntimeTerminated {
138 reason: String,
139 },
140}
141
142impl InteractionTerminalCandidate {
143 pub(crate) fn core_apply_terminal(
144 &self,
145 ) -> Option<meerkat_core::lifecycle::core_executor::CoreApplyTerminal> {
146 use meerkat_core::lifecycle::core_executor::CoreApplyTerminal;
147 match self {
148 Self::RunResult { result } => Some(CoreApplyTerminal::RunResult(result.clone())),
149 Self::CompletedWithoutResult => Some(CoreApplyTerminal::NoPendingBoundary),
150 Self::CallbackPending {
151 tool_use_id,
152 tool_name,
153 args,
154 } => Some(CoreApplyTerminal::CallbackPending {
155 tool_use_id: tool_use_id.clone().unwrap_or_default(),
160 tool_name: tool_name.clone(),
161 args: args.clone(),
162 }),
163 Self::CallbackBatchPending { pending_tool_calls } => {
164 Some(CoreApplyTerminal::CallbackBatchPending {
165 pending_tool_calls: pending_tool_calls.clone(),
166 })
167 }
168 Self::MachineTerminalFailure { error } => {
169 Some(CoreApplyTerminal::MachineTerminalFailure {
170 error: error.clone(),
171 })
172 }
173 Self::Cancelled | Self::RuntimeTerminated { .. } => None,
174 }
175 }
176
177 pub(crate) fn terminal_observation(
178 &self,
179 ) -> crate::meerkat_machine::dsl::RuntimeCompletionTerminalObservation {
180 use crate::meerkat_machine::dsl::RuntimeCompletionTerminalObservation;
181 match self {
182 Self::RunResult { .. } => RuntimeCompletionTerminalObservation::RunResult,
183 Self::CallbackPending { .. } | Self::CallbackBatchPending { .. } => {
184 RuntimeCompletionTerminalObservation::CallbackPending
185 }
186 Self::RuntimeTerminated { .. } => {
187 RuntimeCompletionTerminalObservation::RuntimeTerminated
188 }
189 Self::MachineTerminalFailure { .. } | Self::Cancelled => {
190 RuntimeCompletionTerminalObservation::MachineTerminal
191 }
192 Self::CompletedWithoutResult => RuntimeCompletionTerminalObservation::NoResult,
193 }
194 }
195
196 pub(crate) fn completion_error_metadata(&self) -> Option<meerkat_core::TurnErrorMetadata> {
197 match self {
198 Self::MachineTerminalFailure { error } => Some(error.clone()),
199 _ => None,
200 }
201 }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
207pub(crate) struct InteractionTerminalPublication {
208 pub(crate) terminal_seq: u64,
209 pub(crate) payload_digest: String,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(tag = "phase", rename_all = "snake_case")]
220pub(crate) enum InteractionTerminalOutboxPhase {
221 Candidate,
222 Finalized {
223 finalization_failed: bool,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 finalized_event: Option<AgentEvent>,
226 finalized_payload_digest: String,
227 },
228 Published {
229 finalization_failed: bool,
230 publication: InteractionTerminalPublication,
231 },
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
236#[serde(tag = "scope", rename_all = "snake_case")]
237pub(crate) enum InteractionTerminalBatchKey {
238 Run { run_id: RunId },
239 RuntimeTermination { candidate_owner_input_id: InputId },
240}
241
242impl InteractionTerminalBatchKey {
243 pub(crate) fn run_id(&self) -> Option<&RunId> {
244 match self {
245 Self::Run { run_id } => Some(run_id),
246 Self::RuntimeTermination { .. } => None,
247 }
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
258pub(crate) struct InteractionTerminalOutbox {
259 pub(crate) interaction_id: InteractionId,
260 pub(crate) input_id: InputId,
261 pub(crate) batch_ordinal: u16,
264 pub(crate) batch_key: InteractionTerminalBatchKey,
265 pub(crate) owner_session_id: SessionId,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub(crate) owner_agent_runtime_id: Option<String>,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub(crate) owner_fence_token: Option<u64>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub(crate) owner_runtime_generation: Option<u64>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub(crate) owner_runtime_epoch_id: Option<String>,
274 pub(crate) candidate_owner_input_id: InputId,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub(crate) candidate: Option<InteractionTerminalCandidate>,
279 pub(crate) candidate_digest: String,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub(crate) completion_input_ids: Option<Vec<InputId>>,
285 pub(crate) completion_input_ids_digest: String,
287 pub(crate) phase: InteractionTerminalOutboxPhase,
288}
289
290pub(crate) fn interaction_terminal_payload_digest<T: Serialize>(
291 payload: &T,
292) -> Result<String, String> {
293 serde_json::to_vec(payload)
294 .map(|encoded| format!("{:x}", Sha256::digest(encoded)))
295 .map_err(|error| format!("failed to encode interaction terminal payload: {error}"))
296}
297
298pub(crate) fn interaction_terminal_event_id(event: &AgentEvent) -> Option<InteractionId> {
299 match event {
300 AgentEvent::InteractionComplete { interaction_id, .. }
301 | AgentEvent::InteractionCallbackPending { interaction_id, .. }
302 | AgentEvent::InteractionFailed { interaction_id, .. } => Some(*interaction_id),
303 _ => None,
304 }
305}
306
307pub(crate) fn interaction_terminal_event_for_id(
308 event: &AgentEvent,
309 interaction_id: InteractionId,
310) -> Option<AgentEvent> {
311 match event {
312 AgentEvent::InteractionComplete {
313 result,
314 structured_output,
315 ..
316 } => Some(AgentEvent::InteractionComplete {
317 interaction_id,
318 result: result.clone(),
319 structured_output: structured_output.clone(),
320 }),
321 AgentEvent::InteractionCallbackPending {
322 tool_name,
323 args,
324 pending_tool_calls,
325 ..
326 } => Some(AgentEvent::InteractionCallbackPending {
327 interaction_id,
328 tool_name: tool_name.clone(),
329 args: args.clone(),
330 pending_tool_calls: pending_tool_calls.clone(),
331 }),
332 AgentEvent::InteractionFailed { reason, .. } => Some(AgentEvent::InteractionFailed {
333 interaction_id,
334 reason: reason.clone(),
335 }),
336 _ => None,
337 }
338}
339
340impl InteractionTerminalOutbox {
341 pub(crate) fn validate(&self) -> Result<(), String> {
342 if self.input_id.0 != self.interaction_id.0 {
343 return Err("interaction terminal outbox input/interaction identity mismatch".into());
344 }
345 if self.candidate_digest.is_empty() {
346 return Err("interaction terminal outbox candidate digest is empty".into());
347 }
348 if self.completion_input_ids_digest.is_empty() {
349 return Err("interaction terminal outbox completion recipient digest is empty".into());
350 }
351 if self.owner_agent_runtime_id.is_none()
352 || self.owner_fence_token.is_none()
353 || self.owner_runtime_generation.is_none()
354 {
355 return Err(
356 "interaction terminal outbox is missing required runtime placement binding".into(),
357 );
358 }
359 match (&self.batch_key, self.candidate.as_ref()) {
360 (
361 InteractionTerminalBatchKey::RuntimeTermination {
362 candidate_owner_input_id,
363 },
364 candidate,
365 ) => {
366 if candidate_owner_input_id != &self.candidate_owner_input_id {
367 return Err("runtime-termination batch key/candidate-owner mismatch".into());
368 }
369 if candidate.is_some_and(|candidate| {
370 !matches!(
371 candidate,
372 InteractionTerminalCandidate::RuntimeTerminated { .. }
373 )
374 }) {
375 return Err("runtime-termination batch carried a run-scoped candidate".into());
376 }
377 }
378 (
379 InteractionTerminalBatchKey::Run { .. },
380 Some(InteractionTerminalCandidate::RuntimeTerminated { .. }),
381 ) => {
382 return Err("run-scoped terminal batch carried runtime termination".into());
383 }
384 (InteractionTerminalBatchKey::Run { .. }, _) => {}
385 }
386 let published = matches!(self.phase, InteractionTerminalOutboxPhase::Published { .. });
387 let owns_candidate = self.input_id == self.candidate_owner_input_id;
388 if owns_candidate != (self.batch_ordinal == 0) {
389 return Err("interaction terminal outbox candidate owner/ordinal mismatch".into());
390 }
391 match (&self.completion_input_ids, owns_candidate, published) {
392 (Some(input_ids), true, false) => {
393 if input_ids.is_empty() || input_ids.len() > 256 {
394 return Err(
395 "interaction terminal completion recipient set has invalid size".into(),
396 );
397 }
398 let unique = input_ids.iter().collect::<std::collections::HashSet<_>>();
399 if unique.len() != input_ids.len() {
400 return Err(
401 "interaction terminal completion recipient set contains duplicates".into(),
402 );
403 }
404 if !input_ids.contains(&self.input_id) {
405 return Err(
406 "interaction terminal candidate owner is not a completion recipient".into(),
407 );
408 }
409 if interaction_terminal_payload_digest(input_ids)?
410 != self.completion_input_ids_digest
411 {
412 return Err("interaction terminal completion recipient digest mismatch".into());
413 }
414 }
415 (None, false, false) | (None, _, true) => {}
416 (Some(_), false, _) => {
417 return Err("non-owner interaction outbox duplicated completion recipients".into());
418 }
419 (Some(_), true, true) => {
420 return Err("published interaction outbox retained completion recipients".into());
421 }
422 (None, true, false) => {
423 return Err("interaction outbox candidate owner lost completion recipients".into());
424 }
425 }
426 match (&self.candidate, owns_candidate, published) {
427 (Some(candidate), true, false) => {
428 if interaction_terminal_payload_digest(candidate)? != self.candidate_digest {
429 return Err("interaction terminal outbox candidate digest mismatch".into());
430 }
431 if let InteractionTerminalCandidate::RunResult { result } = candidate
432 && result.session_id != self.owner_session_id
433 {
434 return Err("interaction terminal candidate session/owner mismatch".into());
435 }
436 }
437 (None, false, false) => {}
438 (None, _, true) => {}
439 (Some(_), false, _) => {
440 return Err("non-owner interaction outbox duplicated shared candidate".into());
441 }
442 (Some(_), true, true) => {
443 return Err("published interaction outbox retained its shared candidate".into());
444 }
445 (None, true, false) => {
446 return Err("interaction outbox candidate owner has no candidate".into());
447 }
448 }
449 match &self.phase {
450 InteractionTerminalOutboxPhase::Candidate => {}
451 InteractionTerminalOutboxPhase::Finalized {
452 finalization_failed,
453 finalized_event: Some(event),
454 finalized_payload_digest: digest,
455 } if self.input_id == self.candidate_owner_input_id => {
456 if interaction_terminal_event_id(event) != Some(self.interaction_id) {
457 return Err(
458 "interaction terminal outbox finalized event identity mismatch".into(),
459 );
460 }
461 if interaction_terminal_payload_digest(event)? != *digest {
462 return Err("interaction terminal outbox finalized digest mismatch".into());
463 }
464 let Some(candidate) = self.candidate.as_ref() else {
465 return Err("finalized candidate owner lost shared candidate".into());
466 };
467 if !interaction_terminal_candidate_matches_event(
468 candidate,
469 self.interaction_id,
470 event,
471 *finalization_failed,
472 ) {
473 return Err("interaction terminal finalized event/candidate mismatch".into());
474 }
475 }
476 InteractionTerminalOutboxPhase::Finalized {
477 finalized_event: None,
478 finalized_payload_digest,
479 ..
480 } if self.input_id != self.candidate_owner_input_id
481 && !finalized_payload_digest.is_empty() => {}
482 InteractionTerminalOutboxPhase::Finalized { .. } => {
483 return Err(
484 "interaction terminal outbox finalized payload ownership is invalid".into(),
485 );
486 }
487 InteractionTerminalOutboxPhase::Published { publication, .. } => {
488 if publication.terminal_seq == 0 {
489 return Err("interaction terminal publication sequence must be non-zero".into());
490 }
491 if publication.payload_digest.is_empty() {
492 return Err("interaction terminal publication digest is empty".into());
493 }
494 }
495 }
496 Ok(())
497 }
498}
499
500pub(crate) fn validate_interaction_terminal_outbox_batch_shape(
503 outboxes: &[InteractionTerminalOutbox],
504) -> Result<(), String> {
505 if outboxes.is_empty() || outboxes.len() > 256 {
506 return Err("interaction terminal batch has invalid directed-row count".into());
507 }
508 let owner = &outboxes[0];
509 if owner.batch_ordinal != 0 || owner.input_id != owner.candidate_owner_input_id {
510 return Err("interaction terminal batch has no ordinal-zero candidate owner".into());
511 }
512 let mut row_input_ids = std::collections::HashSet::new();
513 for (ordinal, outbox) in outboxes.iter().enumerate() {
514 outbox.validate()?;
515 if usize::from(outbox.batch_ordinal) != ordinal {
516 return Err("interaction terminal batch ordinals are not contiguous".into());
517 }
518 if outbox.batch_key != owner.batch_key
519 || outbox.candidate_owner_input_id != owner.candidate_owner_input_id
520 || outbox.candidate_digest != owner.candidate_digest
521 || outbox.completion_input_ids_digest != owner.completion_input_ids_digest
522 {
523 return Err("interaction terminal batch has split immutable identity".into());
524 }
525 if !row_input_ids.insert(outbox.input_id.clone()) {
526 return Err("interaction terminal batch repeats a directed input".into());
527 }
528 }
529 Ok(())
530}
531
532pub(crate) fn validate_unpublished_interaction_terminal_outbox_batch(
535 outboxes: &[InteractionTerminalOutbox],
536) -> Result<Vec<InputId>, String> {
537 validate_interaction_terminal_outbox_batch_shape(outboxes)?;
538 let owner = &outboxes[0];
539 let completion_input_ids = owner.completion_input_ids.clone().ok_or_else(|| {
540 "unpublished interaction terminal batch owner lost completion recipients".to_string()
541 })?;
542 for outbox in outboxes {
543 if matches!(
544 outbox.phase,
545 InteractionTerminalOutboxPhase::Published { .. }
546 ) {
547 return Err("published row appeared in an unpublished terminal batch".into());
548 }
549 if !completion_input_ids.contains(&outbox.input_id) {
550 return Err("directed terminal input is not a completion recipient".into());
551 }
552 }
553 Ok(completion_input_ids)
554}
555
556pub(crate) fn interaction_terminal_candidate_matches_event(
557 candidate: &InteractionTerminalCandidate,
558 interaction_id: InteractionId,
559 event: &AgentEvent,
560 finalization_failed: bool,
561) -> bool {
562 use meerkat_core::event::InteractionFailureReason;
563 if interaction_terminal_event_id(event) != Some(interaction_id) {
564 return false;
565 }
566 if finalization_failed {
567 return matches!(
568 (candidate, event),
569 (
570 InteractionTerminalCandidate::RunResult { .. },
571 AgentEvent::InteractionFailed {
572 reason: InteractionFailureReason::FinalizationFailed { .. },
573 ..
574 },
575 ) | (
576 InteractionTerminalCandidate::CompletedWithoutResult
577 | InteractionTerminalCandidate::CallbackPending { .. }
578 | InteractionTerminalCandidate::CallbackBatchPending { .. }
579 | InteractionTerminalCandidate::MachineTerminalFailure { .. },
580 AgentEvent::InteractionFailed {
581 reason: InteractionFailureReason::Abandoned { .. },
582 ..
583 },
584 )
585 );
586 }
587 match (candidate, event) {
588 (
589 InteractionTerminalCandidate::RunResult { result },
590 AgentEvent::InteractionComplete {
591 result: event_result,
592 structured_output: event_structured,
593 ..
594 },
595 ) if result.extraction_error.is_none() => {
596 result.text == *event_result && result.structured_output == *event_structured
597 }
598 (
599 InteractionTerminalCandidate::RunResult { result },
600 AgentEvent::InteractionFailed {
601 reason:
602 InteractionFailureReason::ExtractionFailed {
603 last_output,
604 attempts,
605 reason,
606 },
607 ..
608 },
609 ) if result.extraction_error.is_some() => {
610 let Some(extraction) = result.extraction_error.as_ref() else {
611 return false;
612 };
613 extraction.last_output == *last_output
614 && extraction.attempts == *attempts
615 && extraction.reason == *reason
616 }
617 (
618 InteractionTerminalCandidate::CompletedWithoutResult,
619 AgentEvent::InteractionComplete {
620 result,
621 structured_output,
622 ..
623 },
624 ) => result.is_empty() && structured_output.is_none(),
625 (
626 InteractionTerminalCandidate::CallbackPending {
627 tool_use_id,
628 tool_name,
629 args,
630 },
631 AgentEvent::InteractionCallbackPending {
632 tool_name: event_tool,
633 args: event_args,
634 pending_tool_calls,
635 ..
636 },
637 ) => {
638 tool_name == event_tool
639 && args == event_args
640 && match tool_use_id {
641 Some(tool_use_id) => {
642 pending_tool_calls.as_slice()
643 == [meerkat_core::error::PendingCallbackToolCall {
644 tool_use_id: tool_use_id.clone(),
645 tool_name: tool_name.clone(),
646 args: args.clone(),
647 }]
648 }
649 None => {
653 pending_tool_calls.is_empty()
654 || pending_tool_calls.as_slice()
655 == [meerkat_core::error::PendingCallbackToolCall {
656 tool_use_id: String::new(),
657 tool_name: tool_name.clone(),
658 args: args.clone(),
659 }]
660 }
661 }
662 }
663 (
664 InteractionTerminalCandidate::CallbackBatchPending { pending_tool_calls },
665 AgentEvent::InteractionCallbackPending {
666 pending_tool_calls: event_pending,
667 ..
668 },
669 ) => pending_tool_calls == event_pending,
670 (
671 InteractionTerminalCandidate::MachineTerminalFailure { error },
672 AgentEvent::InteractionFailed {
673 reason: InteractionFailureReason::Abandoned { detail },
674 ..
675 },
676 ) => error.detail.as_deref() == Some(detail.as_str()),
677 (
678 InteractionTerminalCandidate::Cancelled,
679 AgentEvent::InteractionFailed {
680 reason: InteractionFailureReason::Cancelled,
681 ..
682 },
683 ) => true,
684 (
685 InteractionTerminalCandidate::RuntimeTerminated { reason },
686 AgentEvent::InteractionFailed {
687 reason: InteractionFailureReason::Abandoned { detail },
688 ..
689 },
690 ) => reason == detail,
691 _ => false,
692 }
693}
694
695#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct InputStateEvent {
698 pub timestamp: DateTime<Utc>,
699 pub state: InputLifecycleState,
700 #[serde(skip_serializing_if = "Option::is_none")]
701 pub detail: Option<String>,
702}
703
704#[derive(Debug, Clone, PartialEq, Eq)]
715pub struct InputStateSeed {
716 pub phase: InputLifecycleState,
717 pub last_run_id: Option<RunId>,
718 pub last_boundary_sequence: Option<u64>,
719 pub admission_sequence: Option<u64>,
720 pub terminal_outcome: Option<InputTerminalOutcome>,
721 pub attempt_count: u32,
722 pub recovery_lane: Option<HandlingMode>,
723}
724
725impl InputStateSeed {
726 pub fn new_accepted() -> Self {
729 Self {
730 phase: InputLifecycleState::Accepted,
731 last_run_id: None,
732 last_boundary_sequence: None,
733 admission_sequence: None,
734 terminal_outcome: None,
735 attempt_count: 0,
736 recovery_lane: None,
737 }
738 }
739}
740
741#[derive(Debug, Clone)]
748pub struct StoredInputState {
749 pub state: InputState,
750 pub seed: InputStateSeed,
751}
752
753impl StoredInputState {
754 pub fn new_accepted(input_id: InputId) -> Self {
756 Self {
757 state: InputState::new_accepted(input_id),
758 seed: InputStateSeed::new_accepted(),
759 }
760 }
761}
762
763#[derive(Debug, Clone)]
766pub struct InputStatePersistenceRecord {
767 bundle: StoredInputState,
768}
769
770impl InputStatePersistenceRecord {
771 pub(crate) fn from_machine_snapshot(bundle: StoredInputState) -> Result<Self, String> {
775 crate::meerkat_machine::authorize_stored_input_state_seed(
776 &bundle.state.input_id,
777 &bundle.seed,
778 )?;
779 Ok(Self { bundle })
780 }
781
782 pub fn as_stored(&self) -> &StoredInputState {
784 &self.bundle
785 }
786
787 pub fn clone_stored(&self) -> StoredInputState {
789 self.bundle.clone()
790 }
791
792 pub fn into_stored(self) -> StoredInputState {
794 self.bundle
795 }
796}
797
798#[derive(Debug, Clone)]
809pub struct InputState {
810 pub input_id: InputId,
811 pub history: Vec<InputStateHistoryEntry>,
812 pub updated_at: DateTime<Utc>,
813 pub policy: Option<PolicySnapshot>,
814 pub runtime_semantics: Option<RuntimeInputSemantics>,
817 pub durability: Option<crate::input::InputDurability>,
818 pub idempotency_key: Option<crate::identifiers::IdempotencyKey>,
819 pub recovery_count: u32,
820 pub reconstruction_source: Option<ReconstructionSource>,
821 pub(crate) interaction_terminal_outbox: Option<InteractionTerminalOutbox>,
824 pub persisted_input: Option<Input>,
825 pub created_at: DateTime<Utc>,
826}
827
828impl InputState {
829 pub fn new_accepted(input_id: InputId) -> Self {
833 let now = Utc::now();
834 Self {
835 input_id,
836 history: Vec::new(),
837 updated_at: now,
838 policy: None,
839 runtime_semantics: None,
840 durability: None,
841 idempotency_key: None,
842 recovery_count: 0,
843 reconstruction_source: None,
844 interaction_terminal_outbox: None,
845 persisted_input: None,
846 created_at: now,
847 }
848 }
849
850 pub fn history(&self) -> &[InputStateHistoryEntry] {
851 &self.history
852 }
853
854 pub fn updated_at(&self) -> DateTime<Utc> {
855 self.updated_at
856 }
857}
858
859#[derive(Serialize, Deserialize)]
872struct InputStateSerde {
873 stored_input_state_version: u32,
874 input_id: InputId,
875 current_state: InputLifecycleState,
876 #[serde(skip_serializing_if = "Option::is_none")]
877 policy: Option<PolicySnapshot>,
878 #[serde(default, skip_serializing_if = "Option::is_none")]
879 runtime_semantics: Option<RuntimeInputSemantics>,
880 #[serde(skip_serializing_if = "Option::is_none")]
881 terminal_outcome: Option<InputTerminalOutcome>,
882 #[serde(skip_serializing_if = "Option::is_none")]
883 durability: Option<crate::input::InputDurability>,
884 #[serde(skip_serializing_if = "Option::is_none")]
885 idempotency_key: Option<crate::identifiers::IdempotencyKey>,
886 #[serde(default)]
887 attempt_count: u32,
888 #[serde(default)]
889 recovery_count: u32,
890 #[serde(default, skip_serializing_if = "Vec::is_empty")]
891 history: Vec<InputStateHistoryEntry>,
892 #[serde(skip_serializing_if = "Option::is_none")]
893 reconstruction_source: Option<ReconstructionSource>,
894 #[serde(default, skip_serializing_if = "Option::is_none")]
895 interaction_terminal_outbox: Option<InteractionTerminalOutbox>,
896 #[serde(default, skip_serializing_if = "Option::is_none")]
897 persisted_input: Option<Input>,
898 #[serde(default, skip_serializing_if = "Option::is_none")]
899 last_run_id: Option<RunId>,
900 #[serde(default, skip_serializing_if = "Option::is_none")]
901 last_boundary_sequence: Option<u64>,
902 #[serde(default, skip_serializing_if = "Option::is_none")]
903 admission_sequence: Option<u64>,
904 #[serde(default, skip_serializing_if = "Option::is_none")]
905 recovery_lane: Option<HandlingMode>,
906 created_at: DateTime<Utc>,
907 updated_at: DateTime<Utc>,
908}
909
910impl Serialize for StoredInputState {
911 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
912 let helper = InputStateSerde {
913 stored_input_state_version:
914 meerkat_core::generated::session_persistence_version_authority::stored_input_state_version(
915 ),
916 input_id: self.state.input_id.clone(),
917 current_state: self.seed.phase,
918 policy: self.state.policy.clone(),
919 runtime_semantics: self.state.runtime_semantics,
920 terminal_outcome: self.seed.terminal_outcome.clone(),
921 durability: self.state.durability,
922 idempotency_key: self.state.idempotency_key.clone(),
923 attempt_count: self.seed.attempt_count,
924 recovery_count: self.state.recovery_count,
925 history: self.state.history.clone(),
926 reconstruction_source: self.state.reconstruction_source.clone(),
927 interaction_terminal_outbox: self.state.interaction_terminal_outbox.clone(),
928 persisted_input: self.state.persisted_input.clone(),
929 last_run_id: self.seed.last_run_id.clone(),
930 last_boundary_sequence: self.seed.last_boundary_sequence,
931 admission_sequence: self.seed.admission_sequence,
932 recovery_lane: self.seed.recovery_lane,
933 created_at: self.state.created_at,
934 updated_at: self.state.updated_at,
935 };
936 helper.serialize(serializer)
937 }
938}
939
940impl<'de> Deserialize<'de> for StoredInputState {
941 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
942 let helper = InputStateSerde::deserialize(deserializer)?;
943 let observed_stored_input_state_version = helper.stored_input_state_version;
944 let _stored_input_state_version =
945 meerkat_core::generated::session_persistence_version_authority::restore_stored_input_state_version(
946 observed_stored_input_state_version,
947 )
948 .map_err(<D::Error as serde::de::Error>::custom)?;
949 if observed_stored_input_state_version == 3 && helper.interaction_terminal_outbox.is_some()
950 {
951 return Err(<D::Error as serde::de::Error>::custom(
952 "stored input state v3 cannot carry a v4 interaction terminal outbox",
953 ));
954 }
955 if let Some(outbox) = helper.interaction_terminal_outbox.as_ref() {
956 outbox
957 .validate()
958 .map_err(<D::Error as serde::de::Error>::custom)?;
959 }
960 let state = InputState {
961 input_id: helper.input_id,
962 history: helper.history,
963 updated_at: helper.updated_at,
964 policy: helper.policy,
965 runtime_semantics: helper.runtime_semantics,
966 durability: helper.durability,
967 idempotency_key: helper.idempotency_key,
968 recovery_count: helper.recovery_count,
969 reconstruction_source: helper.reconstruction_source,
970 interaction_terminal_outbox: helper.interaction_terminal_outbox,
971 persisted_input: helper.persisted_input,
972 created_at: helper.created_at,
973 };
974 let seed = InputStateSeed {
975 phase: helper.current_state,
976 last_run_id: helper.last_run_id,
977 last_boundary_sequence: helper.last_boundary_sequence,
978 admission_sequence: helper.admission_sequence,
979 terminal_outcome: helper.terminal_outcome,
980 attempt_count: helper.attempt_count,
981 recovery_lane: helper.recovery_lane,
982 };
983 Ok(StoredInputState { state, seed })
984 }
985}
986
987#[cfg(test)]
988#[allow(clippy::unwrap_used)]
989mod tests {
990 use super::*;
991 use crate::policy::{
992 ApplyMode, ConsumePoint, DrainPolicy, QueueMode, RoutingDisposition, WakeMode,
993 };
994 use meerkat_core::ops::{OpEvent, OperationId};
995
996 fn terminal_outbox_batch_fixture() -> Vec<InteractionTerminalOutbox> {
997 let mut completion_input_ids = vec![InputId::new(), InputId::new(), InputId::new()];
998 completion_input_ids.sort_by_key(|input_id| input_id.0);
999 let directed_input_ids = vec![
1000 completion_input_ids[0].clone(),
1001 completion_input_ids[2].clone(),
1002 ];
1003 let candidate = InteractionTerminalCandidate::CompletedWithoutResult;
1004 let candidate_digest = interaction_terminal_payload_digest(&candidate).unwrap();
1005 let completion_input_ids_digest =
1006 interaction_terminal_payload_digest(&completion_input_ids).unwrap();
1007 let candidate_owner_input_id = directed_input_ids[0].clone();
1008 let batch_key = InteractionTerminalBatchKey::Run {
1009 run_id: RunId::new(),
1010 };
1011 directed_input_ids
1012 .into_iter()
1013 .enumerate()
1014 .map(|(ordinal, input_id)| {
1015 let owns_candidate = input_id == candidate_owner_input_id;
1016 InteractionTerminalOutbox {
1017 interaction_id: InteractionId(input_id.0),
1018 input_id,
1019 batch_ordinal: ordinal as u16,
1020 batch_key: batch_key.clone(),
1021 owner_session_id: SessionId::new(),
1022 owner_agent_runtime_id: Some("fixture-runtime".to_string()),
1023 owner_fence_token: Some(7),
1024 owner_runtime_generation: Some(3),
1025 owner_runtime_epoch_id: Some("fixture-epoch".to_string()),
1026 candidate_owner_input_id: candidate_owner_input_id.clone(),
1027 candidate: owns_candidate.then(|| candidate.clone()),
1028 candidate_digest: candidate_digest.clone(),
1029 completion_input_ids: owns_candidate.then(|| completion_input_ids.clone()),
1030 completion_input_ids_digest: completion_input_ids_digest.clone(),
1031 phase: InteractionTerminalOutboxPhase::Candidate,
1032 }
1033 })
1034 .collect()
1035 }
1036
1037 #[test]
1038 fn terminal_outbox_batch_preserves_full_mixed_completion_recipients() {
1039 let outboxes = terminal_outbox_batch_fixture();
1040 let recipients = validate_unpublished_interaction_terminal_outbox_batch(&outboxes).unwrap();
1041
1042 assert_eq!(recipients.len(), 3);
1043 assert_eq!(outboxes.len(), 2);
1044 assert!(recipients.contains(&outboxes[0].input_id));
1045 assert!(recipients.contains(&outboxes[1].input_id));
1046 }
1047
1048 #[test]
1049 fn terminal_outbox_batch_rejects_noncontiguous_or_reordered_ordinals() {
1050 let mut outboxes = terminal_outbox_batch_fixture();
1051 outboxes[1].batch_ordinal = 2;
1052 assert!(
1053 validate_unpublished_interaction_terminal_outbox_batch(&outboxes)
1054 .unwrap_err()
1055 .contains("ordinals are not contiguous")
1056 );
1057 }
1058
1059 #[test]
1060 fn terminal_outbox_owner_rejects_duplicate_completion_recipients() {
1061 let mut outboxes = terminal_outbox_batch_fixture();
1062 let owner = &mut outboxes[0];
1063 let recipients = owner.completion_input_ids.as_mut().unwrap();
1064 recipients.push(recipients[0].clone());
1065 owner.completion_input_ids_digest =
1066 interaction_terminal_payload_digest(recipients).unwrap();
1067
1068 assert!(
1069 owner
1070 .validate()
1071 .unwrap_err()
1072 .contains("contains duplicates")
1073 );
1074 }
1075
1076 #[test]
1077 fn terminal_outbox_resource_bounds_reject_257_rows_or_recipients() {
1078 let fixture = terminal_outbox_batch_fixture();
1079 let oversized_rows = vec![fixture[0].clone(); 257];
1080 assert!(
1081 validate_interaction_terminal_outbox_batch_shape(&oversized_rows)
1082 .unwrap_err()
1083 .contains("invalid directed-row count")
1084 );
1085
1086 let mut owner = fixture[0].clone();
1087 let recipients = (0..257).map(|_| InputId::new()).collect::<Vec<_>>();
1088 owner.completion_input_ids_digest =
1089 interaction_terminal_payload_digest(&recipients).unwrap();
1090 owner.completion_input_ids = Some(recipients);
1091 assert!(
1092 owner
1093 .validate()
1094 .unwrap_err()
1095 .contains("recipient set has invalid size")
1096 );
1097 }
1098
1099 #[test]
1100 fn published_terminal_outbox_compaction_retains_only_immutable_proofs() {
1101 let mut outbox = terminal_outbox_batch_fixture().remove(0);
1102 let recipient_digest = outbox.completion_input_ids_digest.clone();
1103 let candidate_digest = outbox.candidate_digest.clone();
1104 outbox.candidate = None;
1105 outbox.completion_input_ids = None;
1106 outbox.phase = InteractionTerminalOutboxPhase::Published {
1107 finalization_failed: false,
1108 publication: InteractionTerminalPublication {
1109 terminal_seq: 9,
1110 payload_digest: "published-event-digest".to_string(),
1111 },
1112 };
1113
1114 outbox.validate().unwrap();
1115 assert_eq!(outbox.candidate_digest, candidate_digest);
1116 assert_eq!(outbox.completion_input_ids_digest, recipient_digest);
1117 }
1118
1119 #[test]
1120 fn published_terminal_batch_rejects_split_immutable_recipient_proof() {
1121 let mut outboxes = terminal_outbox_batch_fixture();
1122 for outbox in &mut outboxes {
1123 outbox.candidate = None;
1124 outbox.completion_input_ids = None;
1125 outbox.phase = InteractionTerminalOutboxPhase::Published {
1126 finalization_failed: false,
1127 publication: InteractionTerminalPublication {
1128 terminal_seq: u64::from(outbox.batch_ordinal) + 1,
1129 payload_digest: format!("event-{}", outbox.batch_ordinal),
1130 },
1131 };
1132 }
1133 outboxes[1].completion_input_ids_digest = "split-proof".to_string();
1134
1135 assert!(
1136 validate_interaction_terminal_outbox_batch_shape(&outboxes)
1137 .unwrap_err()
1138 .contains("split immutable identity")
1139 );
1140 }
1141
1142 #[test]
1143 fn new_accepted_starts_with_no_shell_history() {
1144 let id = InputId::new();
1145 let state = InputState::new_accepted(id.clone());
1146 assert_eq!(state.input_id, id);
1147 assert!(state.history.is_empty());
1148 }
1149
1150 #[test]
1151 fn seed_new_accepted_defaults_match_queue_lifecycle() {
1152 let seed = InputStateSeed::new_accepted();
1153 assert_eq!(seed.phase, InputLifecycleState::Accepted);
1154 assert!(seed.last_run_id.is_none());
1155 assert!(seed.last_boundary_sequence.is_none());
1156 assert!(seed.admission_sequence.is_none());
1157 assert!(seed.terminal_outcome.is_none());
1158 assert_eq!(seed.attempt_count, 0);
1159 }
1160
1161 #[test]
1162 fn lifecycle_state_serde() {
1163 for state in [
1164 InputLifecycleState::Accepted,
1165 InputLifecycleState::Queued,
1166 InputLifecycleState::Staged,
1167 InputLifecycleState::Applied,
1168 InputLifecycleState::AppliedPendingConsumption,
1169 InputLifecycleState::Consumed,
1170 InputLifecycleState::Superseded,
1171 InputLifecycleState::Coalesced,
1172 InputLifecycleState::Abandoned,
1173 ] {
1174 let json = serde_json::to_value(state).unwrap();
1175 let parsed: InputLifecycleState = serde_json::from_value(json).unwrap();
1176 assert_eq!(state, parsed);
1177 }
1178 }
1179
1180 #[test]
1181 fn stored_input_state_serde_roundtrip_preserves_fields() {
1182 let mut state = InputState::new_accepted(InputId::new());
1183 let policy = PolicyDecision {
1184 apply_mode: ApplyMode::StageRunStart,
1185 wake_mode: WakeMode::WakeIfIdle,
1186 queue_mode: QueueMode::Fifo,
1187 consume_point: ConsumePoint::OnRunComplete,
1188 drain_policy: DrainPolicy::QueueNextTurn,
1189 routing_disposition: RoutingDisposition::Queue,
1190 record_transcript: true,
1191 emit_operator_content: true,
1192 policy_version: PolicyVersion(1),
1193 };
1194 state.policy = Some(PolicySnapshot {
1195 version: PolicyVersion(1),
1196 decision: policy.clone(),
1197 });
1198 state.runtime_semantics = Some(
1199 crate::policy_table::generated_admission_projection_for_kind(
1200 crate::identifiers::KindId::new(crate::identifiers::InputKind::Prompt),
1201 true,
1202 )
1203 .expect("generated admission projection")
1204 .runtime_semantics,
1205 );
1206 state.history.push(InputStateHistoryEntry {
1207 timestamp: state.updated_at,
1208 from: InputLifecycleState::Accepted,
1209 to: InputLifecycleState::Queued,
1210 reason: Some("QueueAccepted".into()),
1211 });
1212 let bundle = StoredInputState {
1213 state,
1214 seed: InputStateSeed {
1215 phase: InputLifecycleState::Queued,
1216 last_run_id: None,
1217 last_boundary_sequence: None,
1218 admission_sequence: Some(42),
1219 terminal_outcome: None,
1220 attempt_count: 0,
1221 recovery_lane: Some(HandlingMode::Queue),
1222 },
1223 };
1224
1225 let json = serde_json::to_value(&bundle).unwrap();
1226 let parsed: StoredInputState = serde_json::from_value(json).unwrap();
1227 assert_eq!(parsed.state.input_id, bundle.state.input_id);
1228 assert_eq!(parsed.seed.phase, bundle.seed.phase);
1229 assert_eq!(
1230 parsed.seed.admission_sequence,
1231 bundle.seed.admission_sequence
1232 );
1233 assert_eq!(parsed.seed.recovery_lane, bundle.seed.recovery_lane);
1234 assert_eq!(
1235 parsed.state.runtime_semantics,
1236 bundle.state.runtime_semantics
1237 );
1238 assert_eq!(parsed.state.history.len(), 1);
1239 }
1240
1241 #[test]
1242 fn stored_input_state_v3_fixture_migrates_to_v4() {
1243 let fixture = include_str!("../tests/fixtures/stored_input_state_v3.json");
1244 let restored: StoredInputState =
1245 serde_json::from_str(fixture).expect("serialized v3 fixture must migrate");
1246 assert_eq!(restored.seed.phase, InputLifecycleState::Queued);
1247 assert_eq!(restored.seed.attempt_count, 2);
1248 assert_eq!(restored.state.recovery_count, 1);
1249 assert_eq!(restored.seed.admission_sequence, Some(17));
1250 assert_eq!(restored.seed.recovery_lane, Some(HandlingMode::Queue));
1251 assert!(restored.state.interaction_terminal_outbox.is_none());
1252
1253 let migrated = serde_json::to_value(&restored).expect("serialize migrated v4 row");
1254 assert_eq!(
1255 migrated["stored_input_state_version"],
1256 meerkat_core::generated::session_persistence_version_authority::STORED_INPUT_STATE_VERSION,
1257 );
1258 }
1259
1260 #[test]
1267 fn stored_input_state_v087_callback_pending_row_still_decodes() {
1268 let candidate_json =
1269 r#"{"candidate_type":"callback_pending","tool_name":"external","args":{"value":1}}"#;
1270 let completion_ids_json = r#"["00000000-0000-0000-0000-0000000000aa"]"#;
1271 let candidate_digest = format!("{:x}", Sha256::digest(candidate_json.as_bytes()));
1272 let completion_ids_digest = format!("{:x}", Sha256::digest(completion_ids_json.as_bytes()));
1273 let row = format!(
1274 r#"{{
1275 "stored_input_state_version": 4,
1276 "input_id": "00000000-0000-0000-0000-0000000000aa",
1277 "current_state": "applied",
1278 "created_at": "2026-01-01T00:00:00Z",
1279 "updated_at": "2026-01-01T00:00:00Z",
1280 "interaction_terminal_outbox": {{
1281 "interaction_id": "00000000-0000-0000-0000-0000000000aa",
1282 "input_id": "00000000-0000-0000-0000-0000000000aa",
1283 "batch_ordinal": 0,
1284 "batch_key": {{"scope":"run","run_id":"00000000-0000-0000-0000-0000000000bb"}},
1285 "owner_session_id": "00000000-0000-0000-0000-0000000000cc",
1286 "owner_agent_runtime_id": "runtime-a",
1287 "owner_fence_token": 7,
1288 "owner_runtime_generation": 3,
1289 "owner_runtime_epoch_id": "epoch-3",
1290 "candidate_owner_input_id": "00000000-0000-0000-0000-0000000000aa",
1291 "candidate": {candidate_json},
1292 "candidate_digest": "{candidate_digest}",
1293 "completion_input_ids": {completion_ids_json},
1294 "completion_input_ids_digest": "{completion_ids_digest}",
1295 "phase": {{"phase":"candidate"}}
1296 }}
1297 }}"#
1298 );
1299
1300 let restored: StoredInputState =
1301 serde_json::from_str(&row).expect("v0.8.7 callback-pending row must decode");
1302 let outbox = restored
1303 .state
1304 .interaction_terminal_outbox
1305 .as_ref()
1306 .expect("outbox survives decode");
1307 let candidate = outbox.candidate.as_ref().expect("owner keeps candidate");
1308 assert!(matches!(
1309 candidate,
1310 InteractionTerminalCandidate::CallbackPending {
1311 tool_use_id: None,
1312 ..
1313 }
1314 ));
1315 assert_eq!(
1316 interaction_terminal_payload_digest(candidate).unwrap(),
1317 outbox.candidate_digest,
1318 "legacy candidate must re-serialize byte-identically under its stored digest"
1319 );
1320 assert!(matches!(
1322 candidate.core_apply_terminal(),
1323 Some(meerkat_core::lifecycle::core_executor::CoreApplyTerminal::CallbackPending {
1324 tool_use_id,
1325 ..
1326 }) if tool_use_id.is_empty()
1327 ));
1328 }
1329
1330 #[test]
1335 fn legacy_callback_candidate_matches_legacy_and_reprojected_events() {
1336 let interaction_id = InteractionId(uuid::Uuid::new_v4());
1337 let args = serde_json::json!({"value": 1});
1338 let legacy_candidate = InteractionTerminalCandidate::CallbackPending {
1339 tool_use_id: None,
1340 tool_name: "external".to_string(),
1341 args: args.clone(),
1342 };
1343 let event = |pending_tool_calls| AgentEvent::InteractionCallbackPending {
1344 interaction_id,
1345 tool_name: "external".to_string(),
1346 args: args.clone(),
1347 pending_tool_calls,
1348 };
1349
1350 let legacy_event = event(Vec::new());
1351 let reprojected_event = event(vec![meerkat_core::error::PendingCallbackToolCall {
1352 tool_use_id: String::new(),
1353 tool_name: "external".to_string(),
1354 args: args.clone(),
1355 }]);
1356 assert!(interaction_terminal_candidate_matches_event(
1357 &legacy_candidate,
1358 interaction_id,
1359 &legacy_event,
1360 false,
1361 ));
1362 assert!(interaction_terminal_candidate_matches_event(
1363 &legacy_candidate,
1364 interaction_id,
1365 &reprojected_event,
1366 false,
1367 ));
1368
1369 let modern_candidate = InteractionTerminalCandidate::CallbackPending {
1370 tool_use_id: Some("call-9".to_string()),
1371 tool_name: "external".to_string(),
1372 args: args.clone(),
1373 };
1374 assert!(!interaction_terminal_candidate_matches_event(
1375 &modern_candidate,
1376 interaction_id,
1377 &legacy_event,
1378 false,
1379 ));
1380 let exact_event = event(vec![meerkat_core::error::PendingCallbackToolCall {
1381 tool_use_id: "call-9".to_string(),
1382 tool_name: "external".to_string(),
1383 args: args.clone(),
1384 }]);
1385 assert!(interaction_terminal_candidate_matches_event(
1386 &modern_candidate,
1387 interaction_id,
1388 &exact_event,
1389 false,
1390 ));
1391 }
1392
1393 #[test]
1394 fn stored_input_state_unlisted_legacy_version_still_fails_closed() {
1395 let mut fixture: serde_json::Value =
1396 serde_json::from_str(include_str!("../tests/fixtures/stored_input_state_v3.json"))
1397 .expect("fixture json");
1398 for rejected in [2, 5] {
1399 fixture["stored_input_state_version"] = serde_json::json!(rejected);
1400 let error = serde_json::from_value::<StoredInputState>(fixture.clone())
1401 .expect_err("unlisted historical and future versions must fail closed");
1402 assert!(error.to_string().contains("expected current 4"));
1403 }
1404 }
1405
1406 #[test]
1407 fn stored_input_state_rejects_legacy_persisted_input_tags() {
1408 let continuation_bundle = StoredInputState {
1412 state: InputState {
1413 persisted_input: Some(Input::Continuation(
1414 crate::input::ContinuationInput::detached_background_op_completed(),
1415 )),
1416 ..InputState::new_accepted(InputId::new())
1417 },
1418 seed: InputStateSeed::new_accepted(),
1419 };
1420 let mut continuation_json = serde_json::to_value(&continuation_bundle).unwrap();
1421 continuation_json["persisted_input"]["input_type"] =
1422 serde_json::Value::String("system_generated".into());
1423 serde_json::from_value::<StoredInputState>(continuation_json)
1424 .expect_err("legacy system_generated persisted input tag must be rejected");
1425
1426 let operation_bundle = StoredInputState {
1427 state: InputState {
1428 persisted_input: Some(Input::Operation(crate::input::OperationInput {
1429 header: crate::input::InputHeader {
1430 id: InputId::new(),
1431 timestamp: Utc::now(),
1432 source: crate::input::InputOrigin::System,
1433 durability: crate::input::InputDurability::Derived,
1434 visibility: crate::input::InputVisibility::default(),
1435 idempotency_key: None,
1436 supersession_key: None,
1437 correlation_id: None,
1438 },
1439 operation_id: OperationId::new(),
1440 event: OpEvent::Cancelled {
1441 id: OperationId::new(),
1442 },
1443 })),
1444 ..InputState::new_accepted(InputId::new())
1445 },
1446 seed: InputStateSeed::new_accepted(),
1447 };
1448 let mut operation_json = serde_json::to_value(&operation_bundle).unwrap();
1449 operation_json["persisted_input"]["input_type"] =
1450 serde_json::Value::String("projected".into());
1451 serde_json::from_value::<StoredInputState>(operation_json)
1452 .expect_err("legacy projected persisted input tag must be rejected");
1453 }
1454
1455 #[test]
1456 fn stored_input_state_rejects_legacy_dual_carrier_persisted_input_shape() {
1457 let bundle = StoredInputState {
1461 state: InputState {
1462 persisted_input: Some(Input::Prompt(crate::input::PromptInput::new("hello", None))),
1463 ..InputState::new_accepted(InputId::new())
1464 },
1465 seed: InputStateSeed::new_accepted(),
1466 };
1467 let mut json = serde_json::to_value(&bundle).unwrap();
1468 let persisted = json["persisted_input"]
1469 .as_object_mut()
1470 .expect("persisted_input object");
1471 persisted.remove("content");
1472 persisted.insert("text".into(), serde_json::Value::String("hello".into()));
1473 persisted.insert("blocks".into(), serde_json::Value::Null);
1474 serde_json::from_value::<StoredInputState>(json)
1475 .expect_err("legacy text+blocks persisted prompt shape must be rejected");
1476 }
1477
1478 #[test]
1479 fn abandon_reason_serde() {
1480 for reason in [
1481 InputAbandonReason::Retired,
1482 InputAbandonReason::Reset,
1483 InputAbandonReason::Destroyed,
1484 InputAbandonReason::Cancelled,
1485 ] {
1486 let json = serde_json::to_value(&reason).unwrap();
1487 let parsed: InputAbandonReason = serde_json::from_value(json).unwrap();
1488 assert_eq!(reason, parsed);
1489 }
1490 }
1491
1492 #[test]
1493 fn terminal_outcome_consumed_serde() {
1494 let outcome = InputTerminalOutcome::Consumed;
1495 let json = serde_json::to_value(&outcome).unwrap();
1496 assert_eq!(json["outcome_type"], "consumed");
1497 let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
1498 assert_eq!(outcome, parsed);
1499 }
1500
1501 #[test]
1502 fn terminal_outcome_superseded_serde() {
1503 let outcome = InputTerminalOutcome::Superseded {
1504 superseded_by: InputId::new(),
1505 };
1506 let json = serde_json::to_value(&outcome).unwrap();
1507 assert_eq!(json["outcome_type"], "superseded");
1508 let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
1509 assert!(matches!(parsed, InputTerminalOutcome::Superseded { .. }));
1510 }
1511
1512 #[test]
1513 fn terminal_outcome_abandoned_serde() {
1514 let outcome = InputTerminalOutcome::Abandoned {
1515 reason: InputAbandonReason::Retired,
1516 };
1517 let json = serde_json::to_value(&outcome).unwrap();
1518 let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
1519 assert!(matches!(
1520 parsed,
1521 InputTerminalOutcome::Abandoned {
1522 reason: InputAbandonReason::Retired,
1523 }
1524 ));
1525 }
1526
1527 #[test]
1528 fn callback_batch_candidate_accepts_abandoned_projection_on_finalization_failure() {
1529 let interaction_id = InteractionId(uuid::Uuid::new_v4());
1530 let candidate = InteractionTerminalCandidate::CallbackBatchPending {
1531 pending_tool_calls: vec![meerkat_core::error::PendingCallbackToolCall {
1532 tool_use_id: "call-1".to_string(),
1533 tool_name: "external".to_string(),
1534 args: serde_json::json!({"value": 1}),
1535 }],
1536 };
1537 let event = AgentEvent::InteractionFailed {
1538 interaction_id,
1539 reason: meerkat_core::event::InteractionFailureReason::abandoned(
1540 "terminal publication failed",
1541 ),
1542 };
1543
1544 assert!(interaction_terminal_candidate_matches_event(
1545 &candidate,
1546 interaction_id,
1547 &event,
1548 true,
1549 ));
1550 }
1551
1552 #[test]
1553 fn reconstruction_source_serde() {
1554 let sources = vec![
1555 ReconstructionSource::Projection {
1556 rule_id: "rule-1".into(),
1557 source_event_id: "evt-1".into(),
1558 },
1559 ReconstructionSource::Coalescing {
1560 source_input_ids: vec![InputId::new(), InputId::new()],
1561 },
1562 ];
1563 for source in sources {
1564 let json = serde_json::to_value(&source).unwrap();
1565 assert!(json["source_type"].is_string());
1566 let parsed: ReconstructionSource = serde_json::from_value(json).unwrap();
1567 let _ = parsed;
1568 }
1569 }
1570
1571 #[test]
1572 fn input_state_event_serde() {
1573 let event = InputStateEvent {
1574 timestamp: Utc::now(),
1575 state: InputLifecycleState::Queued,
1576 detail: Some("queued for processing".into()),
1577 };
1578 let json = serde_json::to_value(&event).unwrap();
1579 let parsed: InputStateEvent = serde_json::from_value(json).unwrap();
1580 assert_eq!(parsed.state, InputLifecycleState::Queued);
1581 }
1582}