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, UseCase,
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")]
62pub enum AppFacadeRequest {
63 Invoke {
65 context: InvocationContext,
67 request: FacadeRequest,
69 },
70 Prepare {
72 context: InvocationContext,
74 request: FacadeRequest,
76 },
77 Commit {
79 context: InvocationContext,
81 action: PreparedAction,
83 },
84 Reject {
86 context: InvocationContext,
88 action: PreparedAction,
90 },
91}
92
93#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
95#[serde(tag = "type", rename_all = "snake_case")]
96pub enum CommandResponse {
97 Accepted {
99 context: InvocationContext,
101 disposition: AdmissionDisposition,
103 queue_revision: Option<Revision>,
105 },
106 Rejected {
108 context: InvocationContext,
110 code: String,
112 message: String,
114 },
115}
116
117#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum AdmissionDisposition {
121 Queued,
123 InteractionReady,
125 CancelRequested,
127}
128
129#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
131pub struct ObservationMessage {
132 pub context: InvocationContext,
134 pub observation: AgentObservation,
136}
137
138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
140pub struct ExecutionFailure {
141 pub context: InvocationContext,
142 pub code: String,
143 pub message: String,
144}
145
146#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
148pub struct EventAck {
149 pub event_id: EventId,
150 pub checkpoint: CheckpointReference,
152}
153
154#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
156pub struct CheckpointPurgeRequest {
157 pub purge_id: String,
158 pub conversation_key: String,
159 pub scope: Scope,
160 pub surface_id: ConversationSurface,
161 pub thread_id: ThreadId,
162}
163
164#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
166#[serde(tag = "status", rename_all = "snake_case")]
167pub enum CheckpointPurgeAck {
168 Deleted {
169 purge_id: String,
170 conversation_key: String,
171 thread_id: ThreadId,
172 deleted_objects: u32,
173 },
174 Failed {
175 purge_id: String,
176 conversation_key: String,
177 thread_id: ThreadId,
178 code: String,
179 message: String,
180 },
181}
182
183#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
185pub struct LlmRoute {
186 pub backend: String,
187 pub profile: String,
188 pub model: String,
189 pub provider_preferences: Vec<String>,
190 pub temperature: Option<f64>,
191 pub max_output_tokens: Option<u32>,
192 pub cache_control: Option<bool>,
193 pub reasoning_effort: Option<String>,
194 pub context_window_tokens: u32,
196}
197
198#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
200pub struct LlmRouteBinding {
201 pub use_case: UseCase,
202 pub model_mode: ModelMode,
203 pub revision: Option<String>,
204 pub route: LlmRoute,
205}
206
207#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
209pub struct LlmExecutionPlan {
210 pub routes: Vec<LlmRouteBinding>,
211}
212
213impl LlmExecutionPlan {
214 #[must_use]
216 pub fn route_for(
217 &self,
218 use_case: &UseCase,
219 model_mode: &ModelMode,
220 ) -> Option<&LlmRouteBinding> {
221 self.routes
222 .iter()
223 .find(|item| item.use_case == *use_case && item.model_mode == *model_mode)
224 }
225}
226
227#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
230pub struct CommandRequest {
231 pub command: AgentCommand,
232 pub context_generation: Option<ContextGeneration>,
234 pub llm_plan: Option<LlmExecutionPlan>,
235 pub base_checkpoint: Option<CheckpointReference>,
237 pub abandoned_run_id: Option<RunId>,
239}
240
241#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
243pub struct EventMessage {
244 pub event: DurableEvent,
245 pub checkpoint: CheckpointReference,
246}
247
248#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
250#[serde(tag = "type", rename_all = "snake_case")]
251pub enum AppFacadeResponse {
252 Result {
254 result: FacadeResult,
256 },
257 Prepared {
259 action: PreparedAction,
261 },
262 Rejected,
264 Failed {
266 code: String,
268 message: String,
270 retry_after_ms: Option<u64>,
272 },
273}
274
275#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
277#[serde(tag = "family", content = "payload", rename_all = "snake_case")]
278pub enum Payload {
279 Command(Box<CommandRequest>),
281 CommandResponse(CommandResponse),
283 Event(EventMessage),
285 EventAck(EventAck),
287 CheckpointPurge(CheckpointPurgeRequest),
289 CheckpointPurgeAck(CheckpointPurgeAck),
291 Observation(ObservationMessage),
293 ExecutionFailure(ExecutionFailure),
295 AppFacadeRequest(AppFacadeRequest),
297 AppFacadeResponse(AppFacadeResponse),
299}
300
301#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
303pub struct Envelope {
304 pub metadata: Metadata,
306 pub payload: Payload,
308}
309
310impl Envelope {
311 pub fn decode_json(input: &str) -> Result<Self, ProtocolError> {
319 let input_value: serde_json::Value =
320 serde_json::from_str(input).map_err(|error| ProtocolError::InvalidJson {
321 message: error.to_string(),
322 })?;
323 let mut deserializer = serde_json::Deserializer::from_str(input);
324 let mut unknown = Vec::new();
325 let envelope: Self = serde_ignored::deserialize(&mut deserializer, |path| {
326 unknown.push(path.to_string());
327 })
328 .map_err(|error| ProtocolError::InvalidJson {
329 message: error.to_string(),
330 })?;
331 deserializer
332 .end()
333 .map_err(|error| ProtocolError::InvalidJson {
334 message: error.to_string(),
335 })?;
336 if let Some(path) = unknown.into_iter().next() {
337 return Err(ProtocolError::UnknownField { path });
338 }
339 let canonical =
340 serde_json::to_value(&envelope).map_err(|error| ProtocolError::InvalidJson {
341 message: error.to_string(),
342 })?;
343 if let Some(path) = first_extra_field(&input_value, &canonical, "$") {
344 return Err(ProtocolError::UnknownField { path });
345 }
346 envelope.validate()?;
347 Ok(envelope)
348 }
349
350 pub fn validate(&self) -> Result<(), ProtocolError> {
357 if self.metadata.version != CURRENT_VERSION {
358 return Err(ProtocolError::UnsupportedVersion {
359 actual: self.metadata.version,
360 });
361 }
362 if self.metadata.message_id.trim().is_empty() {
363 return Err(ProtocolError::MissingIdentifier("message_id"));
364 }
365 if self.metadata.correlation_id.trim().is_empty() {
366 return Err(ProtocolError::MissingIdentifier("correlation_id"));
367 }
368 validate_dispatch(&self.metadata.dispatch)?;
369 if let Some(context) = self.context() {
370 validate_context(context)?;
371 }
372 if let Payload::Command(request) = &self.payload {
373 validate_command_binding(request)?;
374 }
375 if let Payload::CheckpointPurge(request) = &self.payload {
376 validate_purge_request(request)?;
377 }
378 if let Payload::CheckpointPurgeAck(ack) = &self.payload {
379 validate_purge_ack(ack)?;
380 }
381 if let Payload::EventAck(ack) = &self.payload {
382 validate_checkpoint_reference(&ack.checkpoint)?;
383 }
384 match &self.payload {
385 Payload::Command(request) => {
386 if let Some(reference) = &request.base_checkpoint {
387 validate_checkpoint_reference(reference)?;
388 }
389 if matches!(&request.command, AgentCommand::Cancel { .. })
390 && (request.base_checkpoint.is_some() || request.abandoned_run_id.is_some())
391 {
392 return Err(ProtocolError::InvalidBinding(
393 "cancel must target live execution without checkpoint recovery metadata"
394 .to_owned(),
395 ));
396 }
397 if request.abandoned_run_id.is_some() && request.base_checkpoint.is_none() {
398 return Err(ProtocolError::InvalidBinding(
399 "an abandoned run requires a base checkpoint".to_owned(),
400 ));
401 }
402 }
403 Payload::Event(message) => validate_checkpoint_reference(&message.checkpoint)?,
404 _ => {}
405 }
406 Ok(())
407 }
408
409 fn context(&self) -> Option<&InvocationContext> {
410 match &self.payload {
411 Payload::Command(request) => match &request.command {
412 AgentCommand::Enqueue { context, .. }
413 | AgentCommand::SubmitInteraction { context, .. }
414 | AgentCommand::Cancel { context } => Some(context),
415 },
416 Payload::Event(message) => Some(&message.event.context),
417 Payload::Observation(message) => Some(&message.context),
418 Payload::ExecutionFailure(message) => Some(&message.context),
419 Payload::AppFacadeRequest(request) => match request {
420 AppFacadeRequest::Invoke { context, .. }
421 | AppFacadeRequest::Prepare { context, .. }
422 | AppFacadeRequest::Commit { context, .. }
423 | AppFacadeRequest::Reject { context, .. } => Some(context),
424 },
425 Payload::CommandResponse(response) => match response {
426 CommandResponse::Accepted { context, .. }
427 | CommandResponse::Rejected { context, .. } => Some(context),
428 },
429 Payload::EventAck(_)
430 | Payload::CheckpointPurge(_)
431 | Payload::CheckpointPurgeAck(_)
432 | Payload::AppFacadeResponse(_) => None,
433 }
434 }
435}
436
437fn validate_purge_request(request: &CheckpointPurgeRequest) -> Result<(), ProtocolError> {
438 if request.purge_id.trim().is_empty() {
439 return Err(ProtocolError::MissingIdentifier("purge_id"));
440 }
441 if request.conversation_key.trim().is_empty() {
442 return Err(ProtocolError::MissingIdentifier("conversation_key"));
443 }
444 if request.scope.scope_id.as_str().trim().is_empty() {
445 return Err(ProtocolError::MissingIdentifier("scope_id"));
446 }
447 if request.thread_id.as_str().trim().is_empty() {
448 return Err(ProtocolError::MissingIdentifier("thread_id"));
449 }
450 Ok(())
451}
452
453fn validate_purge_ack(ack: &CheckpointPurgeAck) -> Result<(), ProtocolError> {
454 let (purge_id, conversation_key, thread_id) = match ack {
455 CheckpointPurgeAck::Deleted {
456 purge_id,
457 conversation_key,
458 thread_id,
459 ..
460 }
461 | CheckpointPurgeAck::Failed {
462 purge_id,
463 conversation_key,
464 thread_id,
465 ..
466 } => (purge_id, conversation_key, thread_id),
467 };
468 if purge_id.trim().is_empty() {
469 return Err(ProtocolError::MissingIdentifier("purge_id"));
470 }
471 if conversation_key.trim().is_empty() {
472 return Err(ProtocolError::MissingIdentifier("conversation_key"));
473 }
474 if thread_id.as_str().trim().is_empty() {
475 return Err(ProtocolError::MissingIdentifier("thread_id"));
476 }
477 if let CheckpointPurgeAck::Failed { code, .. } = ack
478 && code.trim().is_empty()
479 {
480 return Err(ProtocolError::MissingIdentifier("code"));
481 }
482 Ok(())
483}
484
485fn validate_dispatch(dispatch: &DispatchBinding) -> Result<(), ProtocolError> {
486 if dispatch.dispatch_id.trim().is_empty() {
487 return Err(ProtocolError::MissingIdentifier("dispatch_id"));
488 }
489 if dispatch.agent_session_id.trim().is_empty() {
490 return Err(ProtocolError::MissingIdentifier("agent_session_id"));
491 }
492 Ok(())
493}
494
495fn validate_checkpoint_reference(reference: &CheckpointReference) -> Result<(), ProtocolError> {
496 if reference.object_key.trim().is_empty() {
497 return Err(ProtocolError::MissingIdentifier("checkpoint_object_key"));
498 }
499 if reference.format_version == 0
500 || reference.sha256.len() != 64
501 || !reference
502 .sha256
503 .bytes()
504 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
505 {
506 return Err(ProtocolError::InvalidBinding(
507 "checkpoint reference is invalid".to_owned(),
508 ));
509 }
510 Ok(())
511}
512
513fn validate_command_binding(request: &CommandRequest) -> Result<(), ProtocolError> {
514 match (&request.command, &request.context_generation) {
515 (AgentCommand::Enqueue { .. } | AgentCommand::SubmitInteraction { .. }, Some(value))
516 if value.is_complete() => {}
517 (AgentCommand::Enqueue { .. } | AgentCommand::SubmitInteraction { .. }, _) => {
518 return Err(ProtocolError::InvalidBinding(
519 "enqueue and submit_interaction require a context generation".to_owned(),
520 ));
521 }
522 (AgentCommand::Cancel { .. }, None) => {}
523 (AgentCommand::Cancel { .. }, Some(_)) => {
524 return Err(ProtocolError::InvalidBinding(
525 "cancel must not carry a context generation".to_owned(),
526 ));
527 }
528 }
529 match (&request.command, &request.llm_plan) {
530 (AgentCommand::Enqueue { options, .. }, Some(plan)) => {
531 validate_llm_plan(plan)?;
532 if plan
533 .route_for(&options.use_case, &options.model_mode)
534 .is_none()
535 {
536 return Err(ProtocolError::InvalidBinding(
537 "LLM plan does not contain the command's primary selector".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 if plan.routes.is_empty() {
558 return Err(ProtocolError::InvalidBinding(
559 "LLM plan must contain at least one route".to_owned(),
560 ));
561 }
562 let mut selectors = std::collections::HashSet::new();
563 for binding in &plan.routes {
564 let route = &binding.route;
565 if binding.use_case.0.trim().is_empty()
566 || binding.model_mode.0.trim().is_empty()
567 || route.backend.trim().is_empty()
568 || route.profile.trim().is_empty()
569 || route.model.trim().is_empty()
570 {
571 return Err(ProtocolError::InvalidBinding(
572 "LLM use case, model mode, backend, profile, and model are required".to_owned(),
573 ));
574 }
575 if !selectors.insert((binding.use_case.0.as_str(), binding.model_mode.0.as_str())) {
576 return Err(ProtocolError::InvalidBinding(
577 "LLM plan contains a duplicate selector".to_owned(),
578 ));
579 }
580 if binding
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.max_output_tokens == Some(0)
594 || route.context_window_tokens <= 20_000
595 {
596 return Err(ProtocolError::InvalidBinding(
597 "LLM route contains invalid optional settings".to_owned(),
598 ));
599 }
600 }
601 Ok(())
602}
603
604fn first_extra_field(
605 input: &serde_json::Value,
606 canonical: &serde_json::Value,
607 path: &str,
608) -> Option<String> {
609 match (input, canonical) {
610 (serde_json::Value::Object(input), serde_json::Value::Object(canonical)) => {
611 for (key, value) in input {
612 let child_path = format!("{path}.{key}");
613 let Some(expected) = canonical.get(key) else {
614 return Some(child_path);
615 };
616 if let Some(extra) = first_extra_field(value, expected, &child_path) {
617 return Some(extra);
618 }
619 }
620 None
621 }
622 (serde_json::Value::Array(input), serde_json::Value::Array(canonical)) => input
623 .iter()
624 .zip(canonical)
625 .enumerate()
626 .find_map(|(index, (value, expected))| {
627 first_extra_field(value, expected, &format!("{path}[{index}]"))
628 }),
629 _ => None,
630 }
631}
632
633fn validate_context(context: &InvocationContext) -> Result<(), ProtocolError> {
634 validate_scope(&context.scope)?;
635 for (name, value) in [
636 ("user_id", context.actor.user_id.as_str()),
637 ("thread_id", context.thread_id.as_str()),
638 ("run_id", context.run_id.as_str()),
639 ("operation_id", context.operation_id.as_str()),
640 ] {
641 if value.trim().is_empty() {
642 return Err(ProtocolError::MissingIdentifier(name));
643 }
644 }
645 if context
646 .actor
647 .client_id
648 .as_ref()
649 .is_some_and(|client| client.as_str().trim().is_empty())
650 {
651 return Err(ProtocolError::MissingIdentifier("client_id"));
652 }
653 Ok(())
654}
655
656fn validate_scope(scope: &Scope) -> Result<(), ProtocolError> {
657 if scope.scope_id.as_str().trim().is_empty() {
658 return Err(ProtocolError::MissingIdentifier("scope_id"));
659 }
660 if scope
661 .tenant_id
662 .as_ref()
663 .is_some_and(|tenant| tenant.as_str().trim().is_empty())
664 {
665 return Err(ProtocolError::MissingIdentifier("tenant_id"));
666 }
667 Ok(())
668}
669
670#[derive(Clone, Debug, Error, Eq, PartialEq)]
672pub enum ProtocolError {
673 #[error("invalid protocol JSON: {message}")]
675 InvalidJson {
676 message: String,
678 },
679 #[error("unknown protocol field: {path}")]
681 UnknownField {
682 path: String,
684 },
685 #[error("unsupported protocol version {actual}")]
687 UnsupportedVersion {
688 actual: u16,
690 },
691 #[error("missing required identifier: {0}")]
693 MissingIdentifier(&'static str),
694 #[error("invalid command binding: {0}")]
696 InvalidBinding(String),
697}
698
699#[cfg(test)]
700mod tests {
701 use crate::execution::{
702 AccessMode, Actor, AgentEvent, ClientId, ContentPart, EventId, Message, MessageRole,
703 ModelMode, OperationId, RunId, RunOptions, Scope, ScopeId, TenantId, ThreadId, UseCase,
704 UserId,
705 };
706
707 use super::*;
708
709 #[test]
710 fn rejects_unknown_protocol_version() {
711 let envelope = Envelope {
712 metadata: Metadata {
713 version: CURRENT_VERSION + 1,
714 message_id: "message-1".to_owned(),
715 correlation_id: "correlation-1".to_owned(),
716 causation_id: None,
717 sent_at_unix_ms: 0,
718 dispatch: dispatch(),
719 },
720 payload: Payload::Event(EventMessage {
721 event: DurableEvent {
722 id: EventId::from("event-1"),
723 sequence: 1,
724 context: InvocationContext {
725 scope: Scope {
726 tenant_id: None,
727 scope_id: ScopeId::from("scope-1"),
728 },
729 actor: Actor {
730 user_id: UserId::from("user-1"),
731 client_id: Some(ClientId::from("client-1")),
732 },
733 surface_id: ConversationSurface::client_personal("user-1").unwrap(),
734 thread_id: ThreadId::from("thread-1"),
735 run_id: RunId::from("run-1"),
736 operation_id: OperationId::from("operation-1"),
737 deadline_unix_ms: None,
738 traceparent: None,
739 },
740 event: AgentEvent::Started,
741 },
742 checkpoint: checkpoint(),
743 }),
744 };
745
746 assert_eq!(
747 envelope.validate(),
748 Err(ProtocolError::UnsupportedVersion {
749 actual: CURRENT_VERSION + 1
750 })
751 );
752 }
753
754 #[test]
755 fn command_fixture_round_trips_without_shape_drift() {
756 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
757 let envelope = Envelope::decode_json(fixture).expect("valid golden fixture");
758
759 let expected = Envelope {
760 metadata: Metadata {
761 version: CURRENT_VERSION,
762 message_id: "message-1".to_owned(),
763 correlation_id: "correlation-1".to_owned(),
764 causation_id: None,
765 sent_at_unix_ms: 1_700_000_000_000,
766 dispatch: dispatch(),
767 },
768 payload: Payload::Command(Box::new(CommandRequest {
769 command: AgentCommand::Enqueue {
770 context: InvocationContext {
771 scope: Scope {
772 tenant_id: Some(TenantId::from("tenant-1")),
773 scope_id: ScopeId::from("scope-1"),
774 },
775 actor: Actor {
776 user_id: UserId::from("user-1"),
777 client_id: Some(ClientId::from("client-1")),
778 },
779 surface_id: ConversationSurface::client_personal("user-1").unwrap(),
780 thread_id: ThreadId::from("thread-1"),
781 run_id: RunId::from("run-1"),
782 operation_id: OperationId::from("operation-1"),
783 deadline_unix_ms: None,
784 traceparent: None,
785 },
786 message: Message {
787 continuation: None,
788 role: MessageRole::User,
789 content: vec![ContentPart::Text {
790 text: "hello".to_owned(),
791 }],
792 },
793 options: RunOptions {
794 use_case: UseCase("chat".to_owned()),
795 model_mode: ModelMode("auto".to_owned()),
796 access_mode: AccessMode::Interactive,
797 allow_tools: true,
798 max_steps: 8,
799 credit_budget: None,
800 debug: false,
801 },
802 },
803 context_generation: Some(ContextGeneration {
804 user_scope: "scope-1".to_owned(),
805 identity: "identity-1".to_owned(),
806 memory: "memory-1".to_owned(),
807 integration_guide: "guide-1".to_owned(),
808 installed_integrations: "installed-1".to_owned(),
809 scope_integrations: "scope-integrations-1".to_owned(),
810 }),
811 llm_plan: Some(LlmExecutionPlan {
812 routes: [
813 "chat",
814 "agent_info_merge",
815 "automation_judge",
816 "automation_diagnose",
817 "app_guide",
818 "workflow.action_operate",
819 "workflow.automation_generate",
820 "workflow.description_generate",
821 ]
822 .into_iter()
823 .map(|use_case| LlmRouteBinding {
824 use_case: UseCase(use_case.to_owned()),
825 model_mode: ModelMode("auto".to_owned()),
826 revision: Some("1700000000000".to_owned()),
827 route: LlmRoute {
828 backend: "openrouter".to_owned(),
829 profile: "cloud_openrouter".to_owned(),
830 model: "openai/gpt-5.4-mini".to_owned(),
831 provider_preferences: vec!["OpenAI".to_owned()],
832 temperature: Some(0.5),
833 max_output_tokens: Some(8192),
834 cache_control: Some(true),
835 reasoning_effort: None,
836 context_window_tokens: 128_000,
837 },
838 })
839 .collect(),
840 }),
841 base_checkpoint: None,
842 abandoned_run_id: None,
843 })),
844 };
845 assert_eq!(envelope, expected);
846 assert_eq!(
847 serde_json::to_value(envelope).expect("serializes"),
848 serde_json::from_str::<serde_json::Value>(fixture).expect("fixture JSON")
849 );
850 }
851
852 fn dispatch() -> DispatchBinding {
853 DispatchBinding {
854 dispatch_id: "dispatch-1".to_owned(),
855 agent_session_id: "agent-session-1".to_owned(),
856 }
857 }
858
859 fn checkpoint() -> CheckpointReference {
860 CheckpointReference {
861 object_key: "checkpoints/abc.json".to_owned(),
862 sha256: "a".repeat(64),
863 format_version: 1,
864 }
865 }
866
867 #[test]
868 fn every_v1_golden_fixture_round_trips() {
869 let schema: serde_json::Value = serde_json::from_str(include_str!(
870 "../../../schema/execution/envelope.v1.schema.json"
871 ))
872 .expect("valid JSON Schema document");
873 let validator = jsonschema::validator_for(&schema).expect("valid JSON Schema semantics");
874 for fixture in [
875 include_str!("../../../fixtures/execution/enqueue.v1.json"),
876 include_str!("../../../fixtures/execution/completed-event.v1.json"),
877 include_str!("../../../fixtures/execution/interaction-required-event.v1.json"),
878 include_str!("../../../fixtures/execution/failed-event.v1.json"),
879 include_str!("../../../fixtures/execution/app-facade-prepare.v1.json"),
880 include_str!("../../../fixtures/execution/observation.v1.json"),
881 include_str!("../../../fixtures/execution/admission.v1.json"),
882 include_str!("../../../fixtures/execution/app-facade-prepared.v1.json"),
883 include_str!("../../../fixtures/execution/app-facade-user-action.v1.json"),
884 include_str!("../../../fixtures/execution/event-ack.v1.json"),
885 include_str!("../../../fixtures/execution/checkpoint-purge.v1.json"),
886 include_str!("../../../fixtures/execution/checkpoint-purge-ack.v1.json"),
887 ] {
888 let json: serde_json::Value =
889 serde_json::from_str(fixture).expect("valid golden fixture JSON");
890 validator
891 .validate(&json)
892 .expect("golden fixture matches the normative schema");
893 let envelope = Envelope::decode_json(fixture).expect("fixture matches Rust DTOs");
894 assert_eq!(serde_json::to_value(envelope).expect("serializes"), json);
895 }
896 }
897
898 #[test]
899 fn rejects_blank_scoped_identifiers() {
900 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
901 let mut envelope: Envelope = serde_json::from_str(fixture).expect("valid golden fixture");
902 let Payload::Command(request) = &mut envelope.payload else {
903 panic!("enqueue fixture changed family");
904 };
905 let CommandRequest {
906 command: AgentCommand::Enqueue { context, .. },
907 ..
908 } = request.as_mut()
909 else {
910 panic!("enqueue fixture changed family");
911 };
912 context.scope.scope_id = ScopeId::from(" ");
913 assert_eq!(
914 envelope.validate(),
915 Err(ProtocolError::MissingIdentifier("scope_id"))
916 );
917 }
918
919 #[test]
920 fn strict_decoder_rejects_unknown_nested_fields() {
921 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
922 let input = fixture.replacen(
923 "\"sent_at_unix_ms\": 1700000000000",
924 "\"sent_at_unix_ms\": 1700000000000, \"unexpected\": true",
925 1,
926 );
927 assert!(matches!(
928 Envelope::decode_json(&input),
929 Err(ProtocolError::UnknownField { .. })
930 ));
931
932 let mut input: serde_json::Value = serde_json::from_str(fixture).expect("fixture JSON");
933 input["payload"]["payload"]["options"]["unexpected"] = serde_json::Value::Bool(true);
934 assert!(matches!(
935 Envelope::decode_json(&input.to_string()),
936 Err(ProtocolError::UnknownField { .. })
937 ));
938 }
939
940 #[test]
941 fn abandoned_run_requires_a_base_checkpoint() {
942 let mut value: serde_json::Value =
943 serde_json::from_str(include_str!("../../../fixtures/execution/enqueue.v1.json"))
944 .unwrap();
945 value["payload"]["payload"]["abandoned_run_id"] =
946 serde_json::Value::String("run-abandoned".to_owned());
947
948 assert!(matches!(
949 Envelope::decode_json(&value.to_string()),
950 Err(ProtocolError::InvalidBinding(_))
951 ));
952 }
953}