1pub const ENQUEUE_FIXTURE: &str = include_str!("../../../fixtures/execution/enqueue.v1.json");
8
9use crate::execution::{
10 AgentCommand, AgentObservation, ContextGeneration, ConversationSurface, DurableEvent, EventId,
11 FacadeRequest, FacadeResult, InvocationContext, ModelMode, PreparedAction, Revision, RunId,
12 Scope, ThreadId,
13};
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17pub const CURRENT_VERSION: u16 = 1;
19
20#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24pub struct DispatchBinding {
25 pub dispatch_id: String,
27 pub agent_session_id: String,
29}
30
31#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
33pub struct CheckpointReference {
34 pub object_key: String,
36 pub sha256: String,
38 pub format_version: u32,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
44pub struct Metadata {
45 pub version: u16,
47 pub message_id: String,
49 pub correlation_id: String,
51 pub causation_id: Option<String>,
53 pub sent_at_unix_ms: u64,
55 pub dispatch: DispatchBinding,
57}
58
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
61#[serde(tag = "phase", rename_all = "snake_case")]
62#[serde(deny_unknown_fields)]
63pub enum AppFacadeRequest {
64 Invoke {
66 context: InvocationContext,
68 request: FacadeRequest,
70 },
71 Prepare {
73 context: InvocationContext,
75 request: FacadeRequest,
77 },
78 Commit {
80 context: InvocationContext,
82 action: PreparedAction,
84 },
85 Reject {
87 context: InvocationContext,
89 action: PreparedAction,
91 },
92}
93
94#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96#[serde(tag = "type", rename_all = "snake_case")]
97#[serde(deny_unknown_fields)]
98pub enum CommandResponse {
99 Accepted {
101 context: InvocationContext,
103 disposition: AdmissionDisposition,
105 queue_revision: Option<Revision>,
107 },
108 Rejected {
110 context: InvocationContext,
112 code: String,
114 message: String,
116 },
117}
118
119#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum AdmissionDisposition {
123 Queued,
125 InteractionReady,
127 CancelRequested,
129}
130
131#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
133pub struct ObservationMessage {
134 pub sequence: u64,
136 pub context: InvocationContext,
138 pub observation: AgentObservation,
140}
141
142#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
144pub struct ExecutionFailure {
145 pub context: InvocationContext,
146 pub code: String,
147 pub message: String,
148}
149
150#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
152pub struct EventAck {
153 pub event_id: EventId,
154 pub checkpoint: CheckpointReference,
156}
157
158#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
160pub struct CheckpointPurgeRequest {
161 pub purge_id: String,
162 pub conversation_key: String,
163 pub scope: Scope,
164 pub surface_id: ConversationSurface,
165 pub thread_id: ThreadId,
166}
167
168#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
170#[serde(tag = "status", rename_all = "snake_case")]
171#[serde(deny_unknown_fields)]
172pub enum CheckpointPurgeAck {
173 Deleted {
174 purge_id: String,
175 conversation_key: String,
176 thread_id: ThreadId,
177 deleted_objects: u32,
178 },
179 Failed {
180 purge_id: String,
181 conversation_key: String,
182 thread_id: ThreadId,
183 code: String,
184 message: String,
185 },
186}
187
188#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct LlmModelSnapshot {
193 pub capabilities: llm_api::ModelCapabilities,
194 pub generation_support: LlmGenerationSupport,
195}
196
197pub use llm_api::GenerationSupport as LlmGenerationSupport;
200
201#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
205pub struct LlmRoute {
206 pub backend: String,
207 pub profile: String,
208 pub model: String,
209 pub provider_preferences: Vec<String>,
210 pub temperature: Option<f64>,
211 pub cache_control: Option<bool>,
212 pub reasoning_effort: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub thinking: Option<bool>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub fast_mode: Option<bool>,
217 pub context_window_tokens: u32,
219 pub model_snapshot: LlmModelSnapshot,
221}
222
223#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
226pub struct LlmExecutionPlan {
227 pub model_mode: ModelMode,
228 pub revision: Option<String>,
229 pub route: LlmRoute,
230}
231
232#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
235pub struct CommandRequest {
236 pub command: AgentCommand,
237 pub context_generation: Option<ContextGeneration>,
239 pub llm_plan: Option<LlmExecutionPlan>,
240 pub base_checkpoint: Option<CheckpointReference>,
242 pub abandoned_run_id: Option<RunId>,
244}
245
246#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
248pub struct EventMessage {
249 pub event: DurableEvent,
250 pub checkpoint: CheckpointReference,
251}
252
253#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
255#[serde(tag = "type", rename_all = "snake_case")]
256#[serde(deny_unknown_fields)]
257pub enum AppFacadeResponse {
258 Result {
260 result: FacadeResult,
262 },
263 Prepared {
265 action: PreparedAction,
267 },
268 #[serde(deserialize_with = "crate::execution::deserialize_empty_variant")]
270 Rejected,
271 Failed {
273 code: String,
275 message: String,
277 retry_after_ms: Option<u64>,
279 },
280}
281
282#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
284#[serde(tag = "family", content = "payload", rename_all = "snake_case")]
285pub enum Payload {
286 Command(Box<CommandRequest>),
288 CommandResponse(CommandResponse),
290 Event(EventMessage),
292 EventAck(EventAck),
294 CheckpointPurge(CheckpointPurgeRequest),
296 CheckpointPurgeAck(CheckpointPurgeAck),
298 Observation(ObservationMessage),
300 ExecutionFailure(ExecutionFailure),
302 AppFacadeRequest(AppFacadeRequest),
304 AppFacadeResponse(AppFacadeResponse),
306}
307
308#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
310pub struct Envelope {
311 pub metadata: Metadata,
313 pub payload: Payload,
315}
316
317impl Envelope {
318 pub fn decode_json(input: &str) -> Result<Self, ProtocolError> {
328 let mut deserializer = serde_json::Deserializer::from_str(input);
329 let mut unknown = Vec::new();
330 let envelope: Self = serde_ignored::deserialize(&mut deserializer, |path| {
331 unknown.push(path.to_string());
332 })
333 .map_err(|error| ProtocolError::InvalidJson {
334 message: error.to_string(),
335 })?;
336 deserializer
337 .end()
338 .map_err(|error| ProtocolError::InvalidJson {
339 message: error.to_string(),
340 })?;
341 if let Some(path) = unknown.into_iter().next() {
342 return Err(ProtocolError::UnknownField { path });
343 }
344 envelope.validate()?;
345 Ok(envelope)
346 }
347
348 pub fn validate(&self) -> Result<(), ProtocolError> {
355 if self.metadata.version != CURRENT_VERSION {
356 return Err(ProtocolError::UnsupportedVersion {
357 actual: self.metadata.version,
358 });
359 }
360 if self.metadata.message_id.trim().is_empty() {
361 return Err(ProtocolError::MissingIdentifier("message_id"));
362 }
363 if self.metadata.correlation_id.trim().is_empty() {
364 return Err(ProtocolError::MissingIdentifier("correlation_id"));
365 }
366 validate_dispatch(&self.metadata.dispatch)?;
367 if matches!(&self.payload, Payload::Observation(message) if message.sequence == 0) {
368 return Err(ProtocolError::InvalidBinding(
369 "observation sequence must be positive".into(),
370 ));
371 }
372 if let Some(context) = self.context() {
373 validate_context(context)?;
374 }
375 if let Payload::Command(request) = &self.payload {
376 validate_command_binding(request)?;
377 }
378 if let Payload::CheckpointPurge(request) = &self.payload {
379 validate_purge_request(request)?;
380 }
381 if let Payload::CheckpointPurgeAck(ack) = &self.payload {
382 validate_purge_ack(ack)?;
383 }
384 if let Payload::EventAck(ack) = &self.payload {
385 validate_checkpoint_reference(&ack.checkpoint)?;
386 }
387 match &self.payload {
388 Payload::Command(request) => {
389 if let Some(reference) = &request.base_checkpoint {
390 validate_checkpoint_reference(reference)?;
391 }
392 if matches!(&request.command, AgentCommand::Cancel { .. })
393 && (request.base_checkpoint.is_some() || request.abandoned_run_id.is_some())
394 {
395 return Err(ProtocolError::InvalidBinding(
396 "cancel must target live execution without checkpoint recovery metadata"
397 .to_owned(),
398 ));
399 }
400 if request.abandoned_run_id.is_some() && request.base_checkpoint.is_none() {
401 return Err(ProtocolError::InvalidBinding(
402 "an abandoned run requires a base checkpoint".to_owned(),
403 ));
404 }
405 }
406 Payload::Event(message) => validate_checkpoint_reference(&message.checkpoint)?,
407 _ => {}
408 }
409 Ok(())
410 }
411
412 fn context(&self) -> Option<&InvocationContext> {
413 match &self.payload {
414 Payload::Command(request) => match &request.command {
415 AgentCommand::Enqueue { context, .. }
416 | AgentCommand::SubmitInteraction { context, .. }
417 | AgentCommand::Cancel { context } => Some(context),
418 },
419 Payload::Event(message) => Some(&message.event.context),
420 Payload::Observation(message) => Some(&message.context),
421 Payload::ExecutionFailure(message) => Some(&message.context),
422 Payload::AppFacadeRequest(request) => match request {
423 AppFacadeRequest::Invoke { context, .. }
424 | AppFacadeRequest::Prepare { context, .. }
425 | AppFacadeRequest::Commit { context, .. }
426 | AppFacadeRequest::Reject { context, .. } => Some(context),
427 },
428 Payload::CommandResponse(response) => match response {
429 CommandResponse::Accepted { context, .. }
430 | CommandResponse::Rejected { context, .. } => Some(context),
431 },
432 Payload::EventAck(_)
433 | Payload::CheckpointPurge(_)
434 | Payload::CheckpointPurgeAck(_)
435 | Payload::AppFacadeResponse(_) => None,
436 }
437 }
438}
439
440fn validate_purge_request(request: &CheckpointPurgeRequest) -> Result<(), ProtocolError> {
441 if request.purge_id.trim().is_empty() {
442 return Err(ProtocolError::MissingIdentifier("purge_id"));
443 }
444 if request.conversation_key.trim().is_empty() {
445 return Err(ProtocolError::MissingIdentifier("conversation_key"));
446 }
447 if request.scope.scope_id.as_str().trim().is_empty() {
448 return Err(ProtocolError::MissingIdentifier("scope_id"));
449 }
450 if request.thread_id.as_str().trim().is_empty() {
451 return Err(ProtocolError::MissingIdentifier("thread_id"));
452 }
453 Ok(())
454}
455
456fn validate_purge_ack(ack: &CheckpointPurgeAck) -> Result<(), ProtocolError> {
457 let (purge_id, conversation_key, thread_id) = match ack {
458 CheckpointPurgeAck::Deleted {
459 purge_id,
460 conversation_key,
461 thread_id,
462 ..
463 }
464 | CheckpointPurgeAck::Failed {
465 purge_id,
466 conversation_key,
467 thread_id,
468 ..
469 } => (purge_id, conversation_key, thread_id),
470 };
471 if purge_id.trim().is_empty() {
472 return Err(ProtocolError::MissingIdentifier("purge_id"));
473 }
474 if conversation_key.trim().is_empty() {
475 return Err(ProtocolError::MissingIdentifier("conversation_key"));
476 }
477 if thread_id.as_str().trim().is_empty() {
478 return Err(ProtocolError::MissingIdentifier("thread_id"));
479 }
480 if let CheckpointPurgeAck::Failed { code, .. } = ack
481 && code.trim().is_empty()
482 {
483 return Err(ProtocolError::MissingIdentifier("code"));
484 }
485 Ok(())
486}
487
488fn validate_dispatch(dispatch: &DispatchBinding) -> Result<(), ProtocolError> {
489 if dispatch.dispatch_id.trim().is_empty() {
490 return Err(ProtocolError::MissingIdentifier("dispatch_id"));
491 }
492 if dispatch.agent_session_id.trim().is_empty() {
493 return Err(ProtocolError::MissingIdentifier("agent_session_id"));
494 }
495 Ok(())
496}
497
498fn validate_checkpoint_reference(reference: &CheckpointReference) -> Result<(), ProtocolError> {
499 if reference.object_key.trim().is_empty() {
500 return Err(ProtocolError::MissingIdentifier("checkpoint_object_key"));
501 }
502 if reference.format_version == 0
503 || reference.sha256.len() != 64
504 || !reference
505 .sha256
506 .bytes()
507 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
508 {
509 return Err(ProtocolError::InvalidBinding(
510 "checkpoint reference is invalid".to_owned(),
511 ));
512 }
513 Ok(())
514}
515
516fn validate_command_binding(request: &CommandRequest) -> Result<(), ProtocolError> {
517 match (&request.command, &request.context_generation) {
518 (AgentCommand::Enqueue { .. } | AgentCommand::SubmitInteraction { .. }, Some(value))
519 if value.is_complete() => {}
520 (AgentCommand::Enqueue { .. } | AgentCommand::SubmitInteraction { .. }, _) => {
521 return Err(ProtocolError::InvalidBinding(
522 "enqueue and submit_interaction require a context generation".to_owned(),
523 ));
524 }
525 (AgentCommand::Cancel { .. }, None) => {}
526 (AgentCommand::Cancel { .. }, Some(_)) => {
527 return Err(ProtocolError::InvalidBinding(
528 "cancel must not carry a context generation".to_owned(),
529 ));
530 }
531 }
532 match (&request.command, &request.llm_plan) {
533 (AgentCommand::Enqueue { options, .. }, Some(plan)) => {
534 validate_llm_plan(plan)?;
535 if plan.model_mode != options.model_mode {
536 return Err(ProtocolError::InvalidBinding(
537 "LLM plan mode differs from the command's selected mode".to_owned(),
538 ));
539 }
540 Ok(())
541 }
542 (AgentCommand::Enqueue { .. }, None) => Err(ProtocolError::InvalidBinding(
543 "enqueue requires a frozen LLM plan".to_owned(),
544 )),
545 (AgentCommand::SubmitInteraction { .. }, Some(plan)) => validate_llm_plan(plan),
546 (AgentCommand::SubmitInteraction { .. }, None) => Err(ProtocolError::InvalidBinding(
547 "submit_interaction requires the run's frozen LLM plan".to_owned(),
548 )),
549 (_, Some(_)) => Err(ProtocolError::InvalidBinding(
550 "only commands that invoke the model may carry an LLM plan".to_owned(),
551 )),
552 (_, None) => Ok(()),
553 }
554}
555
556fn validate_llm_plan(plan: &LlmExecutionPlan) -> Result<(), ProtocolError> {
557 let route = &plan.route;
558 route
559 .model_snapshot
560 .generation_support
561 .validate()
562 .map_err(|error| ProtocolError::InvalidBinding(error.into()))?;
563 llm_api::GenerationParameters {
564 temperature: route.temperature,
565 reasoning_effort: route.reasoning_effort.clone(),
566 thinking: route.thinking,
567 fast_mode: route.fast_mode,
568 }
569 .validate()
570 .map_err(|error| ProtocolError::InvalidBinding(error.into()))?;
571 if plan.model_mode.0.trim().is_empty()
572 || route.backend.trim().is_empty()
573 || route.profile.trim().is_empty()
574 || route.model.trim().is_empty()
575 {
576 return Err(ProtocolError::InvalidBinding(
577 "LLM model mode, backend, profile, and model are required".to_owned(),
578 ));
579 }
580 if plan
581 .revision
582 .as_ref()
583 .is_some_and(|value| value.trim().is_empty())
584 || route
585 .provider_preferences
586 .iter()
587 .any(|value| value.trim().is_empty())
588 || route
589 .reasoning_effort
590 .as_ref()
591 .is_some_and(|value| value.trim().is_empty())
592 || route.temperature.is_some_and(|value| !value.is_finite())
593 || route.context_window_tokens <= 20_000
594 {
595 return Err(ProtocolError::InvalidBinding(
596 "LLM route contains invalid optional settings".to_owned(),
597 ));
598 }
599 Ok(())
600}
601
602fn validate_context(context: &InvocationContext) -> Result<(), ProtocolError> {
603 validate_scope(&context.scope)?;
604 for (name, value) in [
605 ("thread_id", context.thread_id.as_str()),
606 ("run_id", context.run_id.as_str()),
607 ("operation_id", context.operation_id.as_str()),
608 ] {
609 if value.trim().is_empty() {
610 return Err(ProtocolError::MissingIdentifier(name));
611 }
612 }
613 if !context.actor.valid_for(&context.scope, &context.surface_id) {
614 return Err(ProtocolError::InvalidBinding(
615 "Actor does not match the conversation scope and surface".into(),
616 ));
617 }
618 Ok(())
619}
620
621fn validate_scope(scope: &Scope) -> Result<(), ProtocolError> {
622 if scope.scope_id.as_str().trim().is_empty() {
623 return Err(ProtocolError::MissingIdentifier("scope_id"));
624 }
625 if scope
626 .tenant_id
627 .as_ref()
628 .is_some_and(|tenant| tenant.as_str().trim().is_empty())
629 {
630 return Err(ProtocolError::MissingIdentifier("tenant_id"));
631 }
632 Ok(())
633}
634
635#[derive(Clone, Debug, Error, Eq, PartialEq)]
637pub enum ProtocolError {
638 #[error("invalid protocol JSON: {message}")]
640 InvalidJson {
641 message: String,
643 },
644 #[error("unknown protocol field: {path}")]
646 UnknownField {
647 path: String,
649 },
650 #[error("unsupported protocol version {actual}")]
652 UnsupportedVersion {
653 actual: u16,
655 },
656 #[error("missing required identifier: {0}")]
658 MissingIdentifier(&'static str),
659 #[error("invalid command binding: {0}")]
661 InvalidBinding(String),
662}
663
664#[cfg(test)]
665mod tests {
666 use crate::execution::{
667 AccessMode, Actor, AgentEvent, ClientId, ContentPart, EventId, Message, MessageRole,
668 ModelMode, OperationId, RunId, RunOptions, Scope, ScopeId, TenantId, ThreadId, UseCase,
669 UserId,
670 };
671
672 use super::*;
673
674 #[test]
675 fn rejects_unknown_protocol_version() {
676 let envelope = Envelope {
677 metadata: Metadata {
678 version: CURRENT_VERSION + 1,
679 message_id: "message-1".to_owned(),
680 correlation_id: "correlation-1".to_owned(),
681 causation_id: None,
682 sent_at_unix_ms: 0,
683 dispatch: dispatch(),
684 },
685 payload: Payload::Event(EventMessage {
686 event: DurableEvent {
687 id: EventId::from("event-1"),
688 sequence: 1,
689 context: InvocationContext {
690 scope: Scope {
691 tenant_id: None,
692 scope_id: ScopeId::from("scope-1"),
693 },
694 actor: Actor::user(
695 UserId::from("user-1"),
696 Some(ClientId::from("client-1")),
697 ),
698 surface_id: ConversationSurface::client_personal("user-1").unwrap(),
699 thread_id: ThreadId::from("thread-1"),
700 run_id: RunId::from("run-1"),
701 operation_id: OperationId::from("operation-1"),
702 deadline_unix_ms: None,
703 traceparent: None,
704 },
705 event: AgentEvent::Started,
706 },
707 checkpoint: checkpoint(),
708 }),
709 };
710
711 assert_eq!(
712 envelope.validate(),
713 Err(ProtocolError::UnsupportedVersion {
714 actual: CURRENT_VERSION + 1
715 })
716 );
717 }
718
719 #[test]
720 fn command_fixture_round_trips_without_shape_drift() {
721 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
722 let envelope = Envelope::decode_json(fixture).expect("valid golden fixture");
723
724 let expected = Envelope {
725 metadata: Metadata {
726 version: CURRENT_VERSION,
727 message_id: "message-1".to_owned(),
728 correlation_id: "correlation-1".to_owned(),
729 causation_id: None,
730 sent_at_unix_ms: 1_700_000_000_000,
731 dispatch: dispatch(),
732 },
733 payload: Payload::Command(Box::new(CommandRequest {
734 command: AgentCommand::Enqueue {
735 context: InvocationContext {
736 scope: Scope {
737 tenant_id: Some(TenantId::from("tenant-1")),
738 scope_id: ScopeId::from("scope-1"),
739 },
740 actor: Actor::user(
741 UserId::from("user-1"),
742 Some(ClientId::from("client-1")),
743 ),
744 surface_id: ConversationSurface::client_personal("user-1").unwrap(),
745 thread_id: ThreadId::from("thread-1"),
746 run_id: RunId::from("run-1"),
747 operation_id: OperationId::from("operation-1"),
748 deadline_unix_ms: None,
749 traceparent: None,
750 },
751 message: Message {
752 continuation: None,
753 role: MessageRole::User,
754 content: vec![ContentPart::Text {
755 text: "hello".to_owned(),
756 }],
757 },
758 options: RunOptions {
759 use_case: UseCase("chat".to_owned()),
760 model_mode: ModelMode("auto".to_owned()),
761 access_mode: AccessMode::Interactive,
762 allow_tools: true,
763 max_steps: 8,
764 credit_budget: None,
765 debug: false,
766 },
767 },
768 context_generation: Some(ContextGeneration {
769 user_scope: "scope-1".to_owned(),
770 identity: "identity-1".to_owned(),
771 memory: "memory-1".to_owned(),
772 integration_guide: "guide-1".to_owned(),
773 installed_integrations: "installed-1".to_owned(),
774 scope_integrations: "scope-integrations-1".to_owned(),
775 }),
776 llm_plan: Some(LlmExecutionPlan {
777 model_mode: ModelMode("auto".to_owned()),
778 revision: Some("1700000000000".to_owned()),
779 route: LlmRoute {
780 backend: "openrouter".to_owned(),
781 profile: "cloud_openrouter".to_owned(),
782 model: "openai/gpt-5.4-mini".to_owned(),
783 provider_preferences: vec!["OpenAI".to_owned()],
784 temperature: Some(0.5),
785 cache_control: Some(true),
786 reasoning_effort: None,
787 thinking: None,
788 fast_mode: None,
789 context_window_tokens: 128_000,
790 model_snapshot: LlmModelSnapshot {
791 capabilities: llm_api::ModelCapabilities {
792 input: vec!["image".to_owned()],
793 tool_calling: true,
794 structured_output: true,
795 },
796 generation_support: LlmGenerationSupport {
797 temperature: Some(true),
798 max_tokens: Some(true),
799 thinking: Some(true),
800 reasoning_efforts: Some(vec!["high".into()]),
801 temperature_with_reasoning: Some(true),
802 ..LlmGenerationSupport::default()
803 },
804 },
805 },
806 }),
807 base_checkpoint: None,
808 abandoned_run_id: None,
809 })),
810 };
811 assert_eq!(envelope, expected);
812 assert_eq!(
813 serde_json::to_value(envelope).expect("serializes"),
814 serde_json::from_str::<serde_json::Value>(fixture).expect("fixture JSON")
815 );
816 }
817
818 #[test]
819 fn snapshot_is_required_and_support_metadata_is_strict() {
820 let mut fixture: serde_json::Value = serde_json::from_str(ENQUEUE_FIXTURE).unwrap();
821 fixture["payload"]["payload"]["llm_plan"]["route"]
822 .as_object_mut()
823 .unwrap()
824 .remove("model_snapshot");
825 assert!(Envelope::decode_json(&fixture.to_string()).is_err());
826 let mut envelope = Envelope::decode_json(ENQUEUE_FIXTURE).unwrap();
827 let Payload::Command(command) = &mut envelope.payload else {
828 panic!("command");
829 };
830 command
831 .llm_plan
832 .as_mut()
833 .unwrap()
834 .route
835 .model_snapshot
836 .generation_support
837 .max_output_tokens = Some(0);
838 assert!(envelope.validate().is_err());
839 }
840
841 #[test]
842 fn generation_support_travels_in_catalog_spelling() {
843 let support = LlmGenerationSupport {
844 temperature: Some(true),
845 max_tokens: Some(true),
846 thinking: Some(true),
847 reasoning_efforts: Some(vec!["low".into(), "high".into()]),
848 reasoning_effort_default: Some("high".into()),
849 temperature_with_reasoning: Some(false),
850 temperature_max: Some(2.0),
851 max_output_tokens: Some(8_192),
852 fast_mode: Some(true),
853 };
854 let encoded = serde_json::to_value(&support).unwrap();
855 assert_eq!(
856 encoded["reasoningEfforts"],
857 serde_json::json!(["low", "high"])
858 );
859 assert_eq!(
860 encoded["temperatureWithReasoning"],
861 serde_json::json!(false)
862 );
863 assert_eq!(
864 serde_json::from_value::<LlmGenerationSupport>(encoded).unwrap(),
865 support
866 );
867 let ignored = serde_json::from_value::<LlmGenerationSupport>(serde_json::json!({
868 "temperature": true, "maxTokens": true, "reasoningEfforts": ["low"],
869 "temperatureWithReasoning": null, "temperatureMax": null, "maxOutputTokens": null,
870 "reasoningIntensity": "high"
871 }))
872 .unwrap();
873 assert_eq!(ignored.temperature, Some(true));
874 assert_eq!(
875 ignored.reasoning_efforts.as_deref(),
876 Some(["low".to_string()].as_slice())
877 );
878 }
879
880 fn dispatch() -> DispatchBinding {
881 DispatchBinding {
882 dispatch_id: "dispatch-1".to_owned(),
883 agent_session_id: "agent-session-1".to_owned(),
884 }
885 }
886
887 fn checkpoint() -> CheckpointReference {
888 CheckpointReference {
889 object_key: "checkpoints/abc.json".to_owned(),
890 sha256: "a".repeat(64),
891 format_version: 1,
892 }
893 }
894
895 #[test]
896 fn every_v1_golden_fixture_round_trips() {
897 let schema: serde_json::Value = serde_json::from_str(include_str!(
898 "../../../schema/execution/envelope.v1.schema.json"
899 ))
900 .expect("valid JSON Schema document");
901 let validator = jsonschema::validator_for(&schema).expect("valid JSON Schema semantics");
902 for fixture in [
903 include_str!("../../../fixtures/execution/enqueue.v1.json"),
904 include_str!("../../../fixtures/execution/completed-event.v1.json"),
905 include_str!("../../../fixtures/execution/interaction-required-event.v1.json"),
906 include_str!("../../../fixtures/execution/failed-event.v1.json"),
907 include_str!("../../../fixtures/execution/app-facade-prepare.v1.json"),
908 include_str!("../../../fixtures/execution/observation.v1.json"),
909 include_str!("../../../fixtures/execution/admission.v1.json"),
910 include_str!("../../../fixtures/execution/app-facade-prepared.v1.json"),
911 include_str!("../../../fixtures/execution/app-facade-user-action.v1.json"),
912 include_str!("../../../fixtures/execution/event-ack.v1.json"),
913 include_str!("../../../fixtures/execution/checkpoint-purge.v1.json"),
914 include_str!("../../../fixtures/execution/checkpoint-purge-ack.v1.json"),
915 ] {
916 let json: serde_json::Value =
917 serde_json::from_str(fixture).expect("valid golden fixture JSON");
918 validator
919 .validate(&json)
920 .expect("golden fixture matches the normative schema");
921 let envelope = Envelope::decode_json(fixture).expect("fixture matches Rust DTOs");
922 assert_eq!(serde_json::to_value(envelope).expect("serializes"), json);
923 }
924 }
925
926 #[test]
927 fn rejects_blank_scoped_identifiers() {
928 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
929 let mut envelope: Envelope = serde_json::from_str(fixture).expect("valid golden fixture");
930 let Payload::Command(request) = &mut envelope.payload else {
931 panic!("enqueue fixture changed family");
932 };
933 let CommandRequest {
934 command: AgentCommand::Enqueue { context, .. },
935 ..
936 } = request.as_mut()
937 else {
938 panic!("enqueue fixture changed family");
939 };
940 context.scope.scope_id = ScopeId::from(" ");
941 assert_eq!(
942 envelope.validate(),
943 Err(ProtocolError::MissingIdentifier("scope_id"))
944 );
945 }
946
947 #[test]
948 fn strict_decoder_checks_other_commands_and_message_parts() {
949 let golden: serde_json::Value = serde_json::from_str(ENQUEUE_FIXTURE).unwrap();
950 let context = golden["payload"]["payload"]["command"]["context"].clone();
951 for command in [
952 serde_json::json!({"type": "cancel", "context": context}),
953 serde_json::json!({"type": "submit_interaction", "context": context,
954 "interaction": {"batch_id": "batch-1", "decisions": [{"action_id": "action-1", "proceed": true}]}}),
955 ] {
956 let mut input = golden.clone();
957 if command["type"] == "cancel" {
958 input["payload"]["payload"]["llm_plan"] = serde_json::Value::Null;
959 input["payload"]["payload"]["context_generation"] = serde_json::Value::Null;
960 }
961 input["payload"]["payload"]["command"] = command;
962 Envelope::decode_json(&input.to_string()).expect("valid command");
963 input["payload"]["payload"]["command"]["unexpected"] = serde_json::Value::Null;
964 assert!(Envelope::decode_json(&input.to_string()).is_err());
965 }
966 for part in [
967 serde_json::json!({"type": "text", "text": "hello"}),
968 serde_json::json!({"type": "artifact", "uri": crate::execution::ArtifactReference::new("tenant-1", "scope-1", "a".repeat(64), crate::execution::ArtifactMetadata::image("image/png", 1, 1, 1).unwrap()).unwrap().uri().unwrap()}),
969 serde_json::json!({"type": "tool_call", "id": "call-1", "name": "test", "arguments": {"arbitrary": {"nested": null}}}),
970 serde_json::json!({"type": "tool_result", "call_id": "call-1", "result": {"arbitrary": true}, "is_error": false}),
971 ] {
972 let mut input = golden.clone();
973 input["payload"]["payload"]["command"]["message"]["content"][0] = part;
974 Envelope::decode_json(&input.to_string()).expect("valid content and opaque JSON");
975 input["payload"]["payload"]["command"]["message"]["content"][0]["unexpected"] =
976 serde_json::Value::Bool(true);
977 assert!(Envelope::decode_json(&input.to_string()).is_err());
978 }
979 assert!(
980 serde_json::from_value::<crate::execution::AgentEvent>(
981 serde_json::json!({"type": "started", "unexpected": true})
982 )
983 .is_err()
984 );
985 assert!(
986 serde_json::from_value::<AppFacadeResponse>(
987 serde_json::json!({"type": "rejected", "unexpected": null})
988 )
989 .is_err()
990 );
991 assert!(
992 serde_json::from_value::<crate::execution::AgentEvent>(
993 serde_json::json!({"type": "started"})
994 )
995 .is_ok()
996 );
997 assert!(
998 serde_json::from_value::<AppFacadeResponse>(serde_json::json!({"type": "rejected"}))
999 .is_ok()
1000 );
1001 }
1002
1003 #[test]
1004 fn decoder_checks_every_closed_object_in_golden_envelopes() {
1005 fn object_paths(value: &serde_json::Value, pointer: &str, paths: &mut Vec<String>) {
1006 match value {
1007 serde_json::Value::Object(fields) => {
1008 paths.push(pointer.to_owned());
1009 for (key, child) in fields {
1010 let key = key.replace('~', "~0").replace('/', "~1");
1011 object_paths(child, &format!("{pointer}/{key}"), paths);
1012 }
1013 }
1014 serde_json::Value::Array(items) => {
1015 for (index, child) in items.iter().enumerate() {
1016 object_paths(child, &format!("{pointer}/{index}"), paths);
1017 }
1018 }
1019 _ => {}
1020 }
1021 }
1022
1023 let schema: serde_json::Value = serde_json::from_str(include_str!(
1024 "../../../schema/execution/envelope.v1.schema.json"
1025 ))
1026 .unwrap();
1027 let validator = jsonschema::validator_for(&schema).unwrap();
1028 let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/execution");
1029 let mut accepted_unknown = Vec::new();
1030 let mut checked = 0;
1031 for file in std::fs::read_dir(fixtures).unwrap() {
1032 let file = file.unwrap().path();
1033 if file.extension().is_none_or(|extension| extension != "json") {
1034 continue;
1035 }
1036 let input = std::fs::read_to_string(&file).unwrap();
1037 let golden: serde_json::Value = serde_json::from_str(&input).unwrap();
1038 validator
1039 .validate(&golden)
1040 .expect("valid canonical fixture");
1041 Envelope::decode_json(&input).expect("valid fixture decodes");
1042 let mut paths = Vec::new();
1043 object_paths(&golden, "", &mut paths);
1044 for pointer in paths {
1045 for extra in [serde_json::Value::Bool(true), serde_json::Value::Null] {
1046 let mut input = golden.clone();
1047 input
1048 .pointer_mut(&pointer)
1049 .unwrap()
1050 .as_object_mut()
1051 .unwrap()
1052 .insert("__unknown_probe".to_owned(), extra);
1053 let decoded = Envelope::decode_json(&input.to_string());
1054 if validator.is_valid(&input) {
1055 decoded.expect("protocol-owned opaque JSON must remain extensible");
1056 } else {
1057 checked += 1;
1058 if decoded.is_ok() {
1059 accepted_unknown.push(format!(
1060 "{}:{pointer}",
1061 file.file_name().unwrap().to_string_lossy()
1062 ));
1063 }
1064 }
1065 }
1066 }
1067 }
1068 assert!(checked > 100, "exercise the entire fixture graph");
1069 assert!(
1070 accepted_unknown.is_empty(),
1071 "unknown fields accepted at {accepted_unknown:#?}"
1072 );
1073 }
1074
1075 #[test]
1076 fn admission_requires_one_model_matching_the_selected_mode() {
1077 let mut input: serde_json::Value =
1078 serde_json::from_str(include_str!("../../../fixtures/execution/enqueue.v1.json"))
1079 .unwrap();
1080 input["payload"]["payload"]["llm_plan"]["model_mode"] = "smart".into();
1081 assert!(matches!(
1082 Envelope::decode_json(&input.to_string()),
1083 Err(ProtocolError::InvalidBinding(_))
1084 ));
1085 input["payload"]["payload"]["command"]["options"]["model_mode"] = "smart".into();
1086 assert!(Envelope::decode_json(&input.to_string()).is_ok());
1087 let plan = input["payload"]["payload"]["llm_plan"].take();
1088 input["payload"]["payload"]["llm_plan"] = serde_json::json!({"routes": [plan]});
1089 assert!(Envelope::decode_json(&input.to_string()).is_err());
1090 }
1091
1092 #[test]
1093 fn strict_decoder_rejects_unknown_nested_fields() {
1094 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
1095 let input = fixture.replacen(
1096 "\"sent_at_unix_ms\": 1700000000000",
1097 "\"sent_at_unix_ms\": 1700000000000, \"unexpected\": true",
1098 1,
1099 );
1100 assert!(matches!(
1101 Envelope::decode_json(&input),
1102 Err(ProtocolError::UnknownField { .. })
1103 ));
1104
1105 let mut input: serde_json::Value = serde_json::from_str(fixture).expect("fixture JSON");
1106 input["payload"]["payload"]["options"]["unexpected"] = serde_json::Value::Bool(true);
1107 assert!(matches!(
1108 Envelope::decode_json(&input.to_string()),
1109 Err(ProtocolError::UnknownField { .. })
1110 ));
1111 }
1112
1113 #[test]
1114 fn strict_decoder_treats_skip_optional_nulls_as_absent() {
1115 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
1116 let mut input: serde_json::Value = serde_json::from_str(fixture).expect("fixture JSON");
1117 input["payload"]["payload"]["llm_plan"]["route"]["fast_mode"] = serde_json::Value::Null;
1118 input["payload"]["payload"]["llm_plan"]["route"]["thinking"] = serde_json::Value::Null;
1119 input["payload"]["payload"]["llm_plan"]["route"]["model_snapshot"]["generation_support"]
1120 ["fastMode"] = serde_json::Value::Null;
1121 input["payload"]["payload"]["llm_plan"]["route"]["model_snapshot"]["generation_support"]
1122 ["reasoningEffortDefault"] = serde_json::Value::Null;
1123 input["payload"]["payload"]["command"]["message"]["continuation"] = serde_json::Value::Null;
1124
1125 let envelope = Envelope::decode_json(&input.to_string())
1126 .expect("null on skip-optional fields deserializes as absent");
1127 let canonical = serde_json::to_value(&envelope).expect("serializes");
1128 let canonical_route = &canonical["payload"]["payload"]["llm_plan"]["route"];
1129 assert!(canonical_route.get("fast_mode").is_none());
1130 assert!(canonical_route.get("thinking").is_none());
1131 let canonical_support = &canonical_route["model_snapshot"]["generation_support"];
1132 assert!(canonical_support.get("fastMode").is_none());
1133 assert!(canonical_support.get("reasoningEffortDefault").is_none());
1134 assert!(
1135 canonical["payload"]["payload"]["command"]["message"]
1136 .get("continuation")
1137 .is_none()
1138 );
1139
1140 let schema: serde_json::Value = serde_json::from_str(include_str!(
1141 "../../../schema/execution/envelope.v1.schema.json"
1142 ))
1143 .expect("schema");
1144 let validator = jsonschema::validator_for(&schema).expect("schema");
1145 assert!(
1146 validator.validate(&input).is_err(),
1147 "canonical emit omits skip-optional keys; schema must not advertise null"
1148 );
1149 }
1150
1151 #[test]
1152 fn schema_rejects_generation_support_and_continuation_nulls() {
1153 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
1154 let schema: serde_json::Value = serde_json::from_str(include_str!(
1155 "../../../schema/execution/envelope.v1.schema.json"
1156 ))
1157 .expect("schema");
1158 let validator = jsonschema::validator_for(&schema).expect("schema");
1159 let golden: serde_json::Value = serde_json::from_str(fixture).expect("fixture JSON");
1160 assert!(
1161 validator.validate(&golden).is_ok(),
1162 "golden enqueue fixture must remain emit-valid"
1163 );
1164
1165 let mut support_null = golden.clone();
1166 support_null["payload"]["payload"]["llm_plan"]["route"]["model_snapshot"]["generation_support"]
1167 ["fastMode"] = serde_json::Value::Null;
1168 assert!(
1169 validator.validate(&support_null).is_err(),
1170 "skip-optional generation_support.fastMode must omit, not null"
1171 );
1172
1173 let mut continuation_null = golden;
1174 continuation_null["payload"]["payload"]["command"]["message"]["continuation"] =
1175 serde_json::Value::Null;
1176 assert!(
1177 validator.validate(&continuation_null).is_err(),
1178 "skip-optional message.continuation must omit, not null"
1179 );
1180 }
1181
1182 #[test]
1183 fn strict_decoder_still_rejects_unknown_null_fields() {
1184 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
1185 let mut input: serde_json::Value = serde_json::from_str(fixture).expect("fixture JSON");
1186 input["payload"]["payload"]["llm_plan"]["route"]["unexpected"] = serde_json::Value::Null;
1187 assert!(matches!(
1188 Envelope::decode_json(&input.to_string()),
1189 Err(ProtocolError::UnknownField { .. })
1190 ));
1191 }
1192
1193 #[test]
1194 fn abandoned_run_requires_a_base_checkpoint() {
1195 let mut value: serde_json::Value =
1196 serde_json::from_str(include_str!("../../../fixtures/execution/enqueue.v1.json"))
1197 .unwrap();
1198 value["payload"]["payload"]["abandoned_run_id"] =
1199 serde_json::Value::String("run-abandoned".to_owned());
1200
1201 assert!(matches!(
1202 Envelope::decode_json(&value.to_string()),
1203 Err(ProtocolError::InvalidBinding(_))
1204 ));
1205 }
1206}