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)]
186#[serde(deny_unknown_fields)]
187pub struct LlmModelSnapshot {
188 pub capabilities: llm_api::ModelCapabilities,
189 pub generation_support: LlmGenerationSupport,
190}
191
192#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase", deny_unknown_fields)]
196pub struct LlmGenerationSupport {
197 pub temperature: Option<bool>,
198 pub max_tokens: Option<bool>,
199 pub reasoning_efforts: Option<Vec<String>>,
200 pub temperature_with_reasoning: Option<bool>,
201 pub temperature_max: Option<f64>,
202 pub max_output_tokens: Option<u32>,
203}
204
205impl LlmGenerationSupport {
206 pub fn validate(&self) -> Result<(), &'static str> {
207 if self
208 .temperature_max
209 .is_some_and(|v| !v.is_finite() || v < 0.0)
210 || self.max_output_tokens == Some(0)
211 {
212 return Err("invalid generation support limits");
213 }
214 for effort in self.reasoning_efforts.iter().flatten() {
215 llm_api::GenerationParameters {
216 temperature: None,
217 reasoning_effort: Some(effort.clone()),
218 }
219 .validate()?;
220 }
221 Ok(())
222 }
223}
224
225#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
227pub struct LlmRoute {
228 pub backend: String,
229 pub profile: String,
230 pub model: String,
231 pub provider_preferences: Vec<String>,
232 pub temperature: Option<f64>,
233 pub max_output_tokens: Option<u32>,
234 pub cache_control: Option<bool>,
235 pub reasoning_effort: Option<String>,
236 pub context_window_tokens: u32,
238 pub model_snapshot: LlmModelSnapshot,
240}
241
242#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
244pub struct LlmRouteBinding {
245 pub use_case: UseCase,
246 pub model_mode: ModelMode,
247 pub revision: Option<String>,
248 pub route: LlmRoute,
249}
250
251#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
253pub struct LlmExecutionPlan {
254 pub routes: Vec<LlmRouteBinding>,
255}
256
257impl LlmExecutionPlan {
258 #[must_use]
260 pub fn route_for(
261 &self,
262 use_case: &UseCase,
263 model_mode: &ModelMode,
264 ) -> Option<&LlmRouteBinding> {
265 self.routes
266 .iter()
267 .find(|item| item.use_case == *use_case && item.model_mode == *model_mode)
268 }
269}
270
271#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
274pub struct CommandRequest {
275 pub command: AgentCommand,
276 pub context_generation: Option<ContextGeneration>,
278 pub llm_plan: Option<LlmExecutionPlan>,
279 pub base_checkpoint: Option<CheckpointReference>,
281 pub abandoned_run_id: Option<RunId>,
283}
284
285#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287pub struct EventMessage {
288 pub event: DurableEvent,
289 pub checkpoint: CheckpointReference,
290}
291
292#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
294#[serde(tag = "type", rename_all = "snake_case")]
295pub enum AppFacadeResponse {
296 Result {
298 result: FacadeResult,
300 },
301 Prepared {
303 action: PreparedAction,
305 },
306 Rejected,
308 Failed {
310 code: String,
312 message: String,
314 retry_after_ms: Option<u64>,
316 },
317}
318
319#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
321#[serde(tag = "family", content = "payload", rename_all = "snake_case")]
322pub enum Payload {
323 Command(Box<CommandRequest>),
325 CommandResponse(CommandResponse),
327 Event(EventMessage),
329 EventAck(EventAck),
331 CheckpointPurge(CheckpointPurgeRequest),
333 CheckpointPurgeAck(CheckpointPurgeAck),
335 Observation(ObservationMessage),
337 ExecutionFailure(ExecutionFailure),
339 AppFacadeRequest(AppFacadeRequest),
341 AppFacadeResponse(AppFacadeResponse),
343}
344
345#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
347pub struct Envelope {
348 pub metadata: Metadata,
350 pub payload: Payload,
352}
353
354impl Envelope {
355 pub fn decode_json(input: &str) -> Result<Self, ProtocolError> {
363 let input_value: serde_json::Value =
364 serde_json::from_str(input).map_err(|error| ProtocolError::InvalidJson {
365 message: error.to_string(),
366 })?;
367 let mut deserializer = serde_json::Deserializer::from_str(input);
368 let mut unknown = Vec::new();
369 let envelope: Self = serde_ignored::deserialize(&mut deserializer, |path| {
370 unknown.push(path.to_string());
371 })
372 .map_err(|error| ProtocolError::InvalidJson {
373 message: error.to_string(),
374 })?;
375 deserializer
376 .end()
377 .map_err(|error| ProtocolError::InvalidJson {
378 message: error.to_string(),
379 })?;
380 if let Some(path) = unknown.into_iter().next() {
381 return Err(ProtocolError::UnknownField { path });
382 }
383 let canonical =
384 serde_json::to_value(&envelope).map_err(|error| ProtocolError::InvalidJson {
385 message: error.to_string(),
386 })?;
387 if let Some(path) = first_extra_field(&input_value, &canonical, "$") {
388 return Err(ProtocolError::UnknownField { path });
389 }
390 envelope.validate()?;
391 Ok(envelope)
392 }
393
394 pub fn validate(&self) -> Result<(), ProtocolError> {
401 if self.metadata.version != CURRENT_VERSION {
402 return Err(ProtocolError::UnsupportedVersion {
403 actual: self.metadata.version,
404 });
405 }
406 if self.metadata.message_id.trim().is_empty() {
407 return Err(ProtocolError::MissingIdentifier("message_id"));
408 }
409 if self.metadata.correlation_id.trim().is_empty() {
410 return Err(ProtocolError::MissingIdentifier("correlation_id"));
411 }
412 validate_dispatch(&self.metadata.dispatch)?;
413 if let Some(context) = self.context() {
414 validate_context(context)?;
415 }
416 if let Payload::Command(request) = &self.payload {
417 validate_command_binding(request)?;
418 }
419 if let Payload::CheckpointPurge(request) = &self.payload {
420 validate_purge_request(request)?;
421 }
422 if let Payload::CheckpointPurgeAck(ack) = &self.payload {
423 validate_purge_ack(ack)?;
424 }
425 if let Payload::EventAck(ack) = &self.payload {
426 validate_checkpoint_reference(&ack.checkpoint)?;
427 }
428 match &self.payload {
429 Payload::Command(request) => {
430 if let Some(reference) = &request.base_checkpoint {
431 validate_checkpoint_reference(reference)?;
432 }
433 if matches!(&request.command, AgentCommand::Cancel { .. })
434 && (request.base_checkpoint.is_some() || request.abandoned_run_id.is_some())
435 {
436 return Err(ProtocolError::InvalidBinding(
437 "cancel must target live execution without checkpoint recovery metadata"
438 .to_owned(),
439 ));
440 }
441 if request.abandoned_run_id.is_some() && request.base_checkpoint.is_none() {
442 return Err(ProtocolError::InvalidBinding(
443 "an abandoned run requires a base checkpoint".to_owned(),
444 ));
445 }
446 }
447 Payload::Event(message) => validate_checkpoint_reference(&message.checkpoint)?,
448 _ => {}
449 }
450 Ok(())
451 }
452
453 fn context(&self) -> Option<&InvocationContext> {
454 match &self.payload {
455 Payload::Command(request) => match &request.command {
456 AgentCommand::Enqueue { context, .. }
457 | AgentCommand::SubmitInteraction { context, .. }
458 | AgentCommand::Cancel { context } => Some(context),
459 },
460 Payload::Event(message) => Some(&message.event.context),
461 Payload::Observation(message) => Some(&message.context),
462 Payload::ExecutionFailure(message) => Some(&message.context),
463 Payload::AppFacadeRequest(request) => match request {
464 AppFacadeRequest::Invoke { context, .. }
465 | AppFacadeRequest::Prepare { context, .. }
466 | AppFacadeRequest::Commit { context, .. }
467 | AppFacadeRequest::Reject { context, .. } => Some(context),
468 },
469 Payload::CommandResponse(response) => match response {
470 CommandResponse::Accepted { context, .. }
471 | CommandResponse::Rejected { context, .. } => Some(context),
472 },
473 Payload::EventAck(_)
474 | Payload::CheckpointPurge(_)
475 | Payload::CheckpointPurgeAck(_)
476 | Payload::AppFacadeResponse(_) => None,
477 }
478 }
479}
480
481fn validate_purge_request(request: &CheckpointPurgeRequest) -> Result<(), ProtocolError> {
482 if request.purge_id.trim().is_empty() {
483 return Err(ProtocolError::MissingIdentifier("purge_id"));
484 }
485 if request.conversation_key.trim().is_empty() {
486 return Err(ProtocolError::MissingIdentifier("conversation_key"));
487 }
488 if request.scope.scope_id.as_str().trim().is_empty() {
489 return Err(ProtocolError::MissingIdentifier("scope_id"));
490 }
491 if request.thread_id.as_str().trim().is_empty() {
492 return Err(ProtocolError::MissingIdentifier("thread_id"));
493 }
494 Ok(())
495}
496
497fn validate_purge_ack(ack: &CheckpointPurgeAck) -> Result<(), ProtocolError> {
498 let (purge_id, conversation_key, thread_id) = match ack {
499 CheckpointPurgeAck::Deleted {
500 purge_id,
501 conversation_key,
502 thread_id,
503 ..
504 }
505 | CheckpointPurgeAck::Failed {
506 purge_id,
507 conversation_key,
508 thread_id,
509 ..
510 } => (purge_id, conversation_key, thread_id),
511 };
512 if purge_id.trim().is_empty() {
513 return Err(ProtocolError::MissingIdentifier("purge_id"));
514 }
515 if conversation_key.trim().is_empty() {
516 return Err(ProtocolError::MissingIdentifier("conversation_key"));
517 }
518 if thread_id.as_str().trim().is_empty() {
519 return Err(ProtocolError::MissingIdentifier("thread_id"));
520 }
521 if let CheckpointPurgeAck::Failed { code, .. } = ack
522 && code.trim().is_empty()
523 {
524 return Err(ProtocolError::MissingIdentifier("code"));
525 }
526 Ok(())
527}
528
529fn validate_dispatch(dispatch: &DispatchBinding) -> Result<(), ProtocolError> {
530 if dispatch.dispatch_id.trim().is_empty() {
531 return Err(ProtocolError::MissingIdentifier("dispatch_id"));
532 }
533 if dispatch.agent_session_id.trim().is_empty() {
534 return Err(ProtocolError::MissingIdentifier("agent_session_id"));
535 }
536 Ok(())
537}
538
539fn validate_checkpoint_reference(reference: &CheckpointReference) -> Result<(), ProtocolError> {
540 if reference.object_key.trim().is_empty() {
541 return Err(ProtocolError::MissingIdentifier("checkpoint_object_key"));
542 }
543 if reference.format_version == 0
544 || reference.sha256.len() != 64
545 || !reference
546 .sha256
547 .bytes()
548 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
549 {
550 return Err(ProtocolError::InvalidBinding(
551 "checkpoint reference is invalid".to_owned(),
552 ));
553 }
554 Ok(())
555}
556
557fn validate_command_binding(request: &CommandRequest) -> Result<(), ProtocolError> {
558 match (&request.command, &request.context_generation) {
559 (AgentCommand::Enqueue { .. } | AgentCommand::SubmitInteraction { .. }, Some(value))
560 if value.is_complete() => {}
561 (AgentCommand::Enqueue { .. } | AgentCommand::SubmitInteraction { .. }, _) => {
562 return Err(ProtocolError::InvalidBinding(
563 "enqueue and submit_interaction require a context generation".to_owned(),
564 ));
565 }
566 (AgentCommand::Cancel { .. }, None) => {}
567 (AgentCommand::Cancel { .. }, Some(_)) => {
568 return Err(ProtocolError::InvalidBinding(
569 "cancel must not carry a context generation".to_owned(),
570 ));
571 }
572 }
573 match (&request.command, &request.llm_plan) {
574 (AgentCommand::Enqueue { options, .. }, Some(plan)) => {
575 validate_llm_plan(plan)?;
576 if plan
577 .route_for(&options.use_case, &options.model_mode)
578 .is_none()
579 {
580 return Err(ProtocolError::InvalidBinding(
581 "LLM plan does not contain the command's primary selector".to_owned(),
582 ));
583 }
584 Ok(())
585 }
586 (AgentCommand::Enqueue { .. }, None) => Err(ProtocolError::InvalidBinding(
587 "enqueue requires a frozen LLM plan".to_owned(),
588 )),
589 (AgentCommand::SubmitInteraction { .. }, Some(plan)) => validate_llm_plan(plan),
590 (AgentCommand::SubmitInteraction { .. }, None) => Err(ProtocolError::InvalidBinding(
591 "submit_interaction requires the run's frozen LLM plan".to_owned(),
592 )),
593 (_, Some(_)) => Err(ProtocolError::InvalidBinding(
594 "only commands that invoke the model may carry an LLM plan".to_owned(),
595 )),
596 (_, None) => Ok(()),
597 }
598}
599
600fn validate_llm_plan(plan: &LlmExecutionPlan) -> Result<(), ProtocolError> {
601 if plan.routes.is_empty() {
602 return Err(ProtocolError::InvalidBinding(
603 "LLM plan must contain at least one route".to_owned(),
604 ));
605 }
606 let mut selectors = std::collections::HashSet::new();
607 for binding in &plan.routes {
608 let route = &binding.route;
609 route
610 .model_snapshot
611 .generation_support
612 .validate()
613 .map_err(|error| ProtocolError::InvalidBinding(error.into()))?;
614 llm_api::GenerationParameters {
615 temperature: route.temperature,
616 reasoning_effort: route.reasoning_effort.clone(),
617 }
618 .validate()
619 .map_err(|error| ProtocolError::InvalidBinding(error.into()))?;
620 if binding.use_case.0.trim().is_empty()
621 || binding.model_mode.0.trim().is_empty()
622 || route.backend.trim().is_empty()
623 || route.profile.trim().is_empty()
624 || route.model.trim().is_empty()
625 {
626 return Err(ProtocolError::InvalidBinding(
627 "LLM use case, model mode, backend, profile, and model are required".to_owned(),
628 ));
629 }
630 if !selectors.insert((binding.use_case.0.as_str(), binding.model_mode.0.as_str())) {
631 return Err(ProtocolError::InvalidBinding(
632 "LLM plan contains a duplicate selector".to_owned(),
633 ));
634 }
635 if binding
636 .revision
637 .as_ref()
638 .is_some_and(|value| value.trim().is_empty())
639 || route
640 .provider_preferences
641 .iter()
642 .any(|value| value.trim().is_empty())
643 || route
644 .reasoning_effort
645 .as_ref()
646 .is_some_and(|value| value.trim().is_empty())
647 || route.temperature.is_some_and(|value| !value.is_finite())
648 || route.max_output_tokens == Some(0)
649 || route.context_window_tokens <= 20_000
650 {
651 return Err(ProtocolError::InvalidBinding(
652 "LLM route contains invalid optional settings".to_owned(),
653 ));
654 }
655 }
656 Ok(())
657}
658
659fn first_extra_field(
660 input: &serde_json::Value,
661 canonical: &serde_json::Value,
662 path: &str,
663) -> Option<String> {
664 match (input, canonical) {
665 (serde_json::Value::Object(input), serde_json::Value::Object(canonical)) => {
666 for (key, value) in input {
667 let child_path = format!("{path}.{key}");
668 let Some(expected) = canonical.get(key) else {
669 return Some(child_path);
670 };
671 if let Some(extra) = first_extra_field(value, expected, &child_path) {
672 return Some(extra);
673 }
674 }
675 None
676 }
677 (serde_json::Value::Array(input), serde_json::Value::Array(canonical)) => input
678 .iter()
679 .zip(canonical)
680 .enumerate()
681 .find_map(|(index, (value, expected))| {
682 first_extra_field(value, expected, &format!("{path}[{index}]"))
683 }),
684 _ => None,
685 }
686}
687
688fn validate_context(context: &InvocationContext) -> Result<(), ProtocolError> {
689 validate_scope(&context.scope)?;
690 for (name, value) in [
691 ("user_id", context.actor.user_id.as_str()),
692 ("thread_id", context.thread_id.as_str()),
693 ("run_id", context.run_id.as_str()),
694 ("operation_id", context.operation_id.as_str()),
695 ] {
696 if value.trim().is_empty() {
697 return Err(ProtocolError::MissingIdentifier(name));
698 }
699 }
700 if context
701 .actor
702 .client_id
703 .as_ref()
704 .is_some_and(|client| client.as_str().trim().is_empty())
705 {
706 return Err(ProtocolError::MissingIdentifier("client_id"));
707 }
708 Ok(())
709}
710
711fn validate_scope(scope: &Scope) -> Result<(), ProtocolError> {
712 if scope.scope_id.as_str().trim().is_empty() {
713 return Err(ProtocolError::MissingIdentifier("scope_id"));
714 }
715 if scope
716 .tenant_id
717 .as_ref()
718 .is_some_and(|tenant| tenant.as_str().trim().is_empty())
719 {
720 return Err(ProtocolError::MissingIdentifier("tenant_id"));
721 }
722 Ok(())
723}
724
725#[derive(Clone, Debug, Error, Eq, PartialEq)]
727pub enum ProtocolError {
728 #[error("invalid protocol JSON: {message}")]
730 InvalidJson {
731 message: String,
733 },
734 #[error("unknown protocol field: {path}")]
736 UnknownField {
737 path: String,
739 },
740 #[error("unsupported protocol version {actual}")]
742 UnsupportedVersion {
743 actual: u16,
745 },
746 #[error("missing required identifier: {0}")]
748 MissingIdentifier(&'static str),
749 #[error("invalid command binding: {0}")]
751 InvalidBinding(String),
752}
753
754#[cfg(test)]
755mod tests {
756 use crate::execution::{
757 AccessMode, Actor, AgentEvent, ClientId, ContentPart, EventId, Message, MessageRole,
758 ModelMode, OperationId, RunId, RunOptions, Scope, ScopeId, TenantId, ThreadId, UseCase,
759 UserId,
760 };
761
762 use super::*;
763
764 #[test]
765 fn rejects_unknown_protocol_version() {
766 let envelope = Envelope {
767 metadata: Metadata {
768 version: CURRENT_VERSION + 1,
769 message_id: "message-1".to_owned(),
770 correlation_id: "correlation-1".to_owned(),
771 causation_id: None,
772 sent_at_unix_ms: 0,
773 dispatch: dispatch(),
774 },
775 payload: Payload::Event(EventMessage {
776 event: DurableEvent {
777 id: EventId::from("event-1"),
778 sequence: 1,
779 context: InvocationContext {
780 scope: Scope {
781 tenant_id: None,
782 scope_id: ScopeId::from("scope-1"),
783 },
784 actor: Actor {
785 user_id: UserId::from("user-1"),
786 client_id: Some(ClientId::from("client-1")),
787 },
788 surface_id: ConversationSurface::client_personal("user-1").unwrap(),
789 thread_id: ThreadId::from("thread-1"),
790 run_id: RunId::from("run-1"),
791 operation_id: OperationId::from("operation-1"),
792 deadline_unix_ms: None,
793 traceparent: None,
794 },
795 event: AgentEvent::Started,
796 },
797 checkpoint: checkpoint(),
798 }),
799 };
800
801 assert_eq!(
802 envelope.validate(),
803 Err(ProtocolError::UnsupportedVersion {
804 actual: CURRENT_VERSION + 1
805 })
806 );
807 }
808
809 #[test]
810 fn command_fixture_round_trips_without_shape_drift() {
811 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
812 let envelope = Envelope::decode_json(fixture).expect("valid golden fixture");
813
814 let expected = Envelope {
815 metadata: Metadata {
816 version: CURRENT_VERSION,
817 message_id: "message-1".to_owned(),
818 correlation_id: "correlation-1".to_owned(),
819 causation_id: None,
820 sent_at_unix_ms: 1_700_000_000_000,
821 dispatch: dispatch(),
822 },
823 payload: Payload::Command(Box::new(CommandRequest {
824 command: AgentCommand::Enqueue {
825 context: InvocationContext {
826 scope: Scope {
827 tenant_id: Some(TenantId::from("tenant-1")),
828 scope_id: ScopeId::from("scope-1"),
829 },
830 actor: Actor {
831 user_id: UserId::from("user-1"),
832 client_id: Some(ClientId::from("client-1")),
833 },
834 surface_id: ConversationSurface::client_personal("user-1").unwrap(),
835 thread_id: ThreadId::from("thread-1"),
836 run_id: RunId::from("run-1"),
837 operation_id: OperationId::from("operation-1"),
838 deadline_unix_ms: None,
839 traceparent: None,
840 },
841 message: Message {
842 continuation: None,
843 role: MessageRole::User,
844 content: vec![ContentPart::Text {
845 text: "hello".to_owned(),
846 }],
847 },
848 options: RunOptions {
849 use_case: UseCase("chat".to_owned()),
850 model_mode: ModelMode("auto".to_owned()),
851 access_mode: AccessMode::Interactive,
852 allow_tools: true,
853 max_steps: 8,
854 credit_budget: None,
855 debug: false,
856 },
857 },
858 context_generation: Some(ContextGeneration {
859 user_scope: "scope-1".to_owned(),
860 identity: "identity-1".to_owned(),
861 memory: "memory-1".to_owned(),
862 integration_guide: "guide-1".to_owned(),
863 installed_integrations: "installed-1".to_owned(),
864 scope_integrations: "scope-integrations-1".to_owned(),
865 }),
866 llm_plan: Some(LlmExecutionPlan {
867 routes: [
868 "chat",
869 "agent_info_merge",
870 "automation_judge",
871 "automation_diagnose",
872 "app_guide",
873 "workflow.action_operate",
874 "workflow.automation_generate",
875 "workflow.description_generate",
876 ]
877 .into_iter()
878 .map(|use_case| LlmRouteBinding {
879 use_case: UseCase(use_case.to_owned()),
880 model_mode: ModelMode("auto".to_owned()),
881 revision: Some("1700000000000".to_owned()),
882 route: LlmRoute {
883 backend: "openrouter".to_owned(),
884 profile: "cloud_openrouter".to_owned(),
885 model: "openai/gpt-5.4-mini".to_owned(),
886 provider_preferences: vec!["OpenAI".to_owned()],
887 temperature: Some(0.5),
888 max_output_tokens: Some(8192),
889 cache_control: Some(true),
890 reasoning_effort: None,
891 context_window_tokens: 128_000,
892 model_snapshot: LlmModelSnapshot {
893 capabilities: llm_api::ModelCapabilities {
894 vision: true,
895 tool_calling: true,
896 structured_output: true,
897 },
898 generation_support: LlmGenerationSupport {
899 temperature: Some(true),
900 max_tokens: Some(true),
901 reasoning_efforts: Some(vec!["none".into(), "high".into()]),
902 temperature_with_reasoning: Some(true),
903 ..LlmGenerationSupport::default()
904 },
905 },
906 },
907 })
908 .collect(),
909 }),
910 base_checkpoint: None,
911 abandoned_run_id: None,
912 })),
913 };
914 assert_eq!(envelope, expected);
915 assert_eq!(
916 serde_json::to_value(envelope).expect("serializes"),
917 serde_json::from_str::<serde_json::Value>(fixture).expect("fixture JSON")
918 );
919 }
920
921 #[test]
922 fn snapshot_is_required_and_support_metadata_is_strict() {
923 let mut fixture: serde_json::Value = serde_json::from_str(ENQUEUE_FIXTURE).unwrap();
924 fixture["payload"]["payload"]["llm_plan"]["routes"][0]["route"]
925 .as_object_mut()
926 .unwrap()
927 .remove("model_snapshot");
928 assert!(Envelope::decode_json(&fixture.to_string()).is_err());
929 let mut envelope = Envelope::decode_json(ENQUEUE_FIXTURE).unwrap();
930 let Payload::Command(command) = &mut envelope.payload else {
931 panic!("command");
932 };
933 command.llm_plan.as_mut().unwrap().routes[0]
934 .route
935 .model_snapshot
936 .generation_support
937 .max_output_tokens = Some(0);
938 assert!(envelope.validate().is_err());
939 }
940
941 fn dispatch() -> DispatchBinding {
942 DispatchBinding {
943 dispatch_id: "dispatch-1".to_owned(),
944 agent_session_id: "agent-session-1".to_owned(),
945 }
946 }
947
948 fn checkpoint() -> CheckpointReference {
949 CheckpointReference {
950 object_key: "checkpoints/abc.json".to_owned(),
951 sha256: "a".repeat(64),
952 format_version: 1,
953 }
954 }
955
956 #[test]
957 fn every_v1_golden_fixture_round_trips() {
958 let schema: serde_json::Value = serde_json::from_str(include_str!(
959 "../../../schema/execution/envelope.v1.schema.json"
960 ))
961 .expect("valid JSON Schema document");
962 let validator = jsonschema::validator_for(&schema).expect("valid JSON Schema semantics");
963 for fixture in [
964 include_str!("../../../fixtures/execution/enqueue.v1.json"),
965 include_str!("../../../fixtures/execution/completed-event.v1.json"),
966 include_str!("../../../fixtures/execution/interaction-required-event.v1.json"),
967 include_str!("../../../fixtures/execution/failed-event.v1.json"),
968 include_str!("../../../fixtures/execution/app-facade-prepare.v1.json"),
969 include_str!("../../../fixtures/execution/observation.v1.json"),
970 include_str!("../../../fixtures/execution/admission.v1.json"),
971 include_str!("../../../fixtures/execution/app-facade-prepared.v1.json"),
972 include_str!("../../../fixtures/execution/app-facade-user-action.v1.json"),
973 include_str!("../../../fixtures/execution/event-ack.v1.json"),
974 include_str!("../../../fixtures/execution/checkpoint-purge.v1.json"),
975 include_str!("../../../fixtures/execution/checkpoint-purge-ack.v1.json"),
976 ] {
977 let json: serde_json::Value =
978 serde_json::from_str(fixture).expect("valid golden fixture JSON");
979 validator
980 .validate(&json)
981 .expect("golden fixture matches the normative schema");
982 let envelope = Envelope::decode_json(fixture).expect("fixture matches Rust DTOs");
983 assert_eq!(serde_json::to_value(envelope).expect("serializes"), json);
984 }
985 }
986
987 #[test]
988 fn rejects_blank_scoped_identifiers() {
989 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
990 let mut envelope: Envelope = serde_json::from_str(fixture).expect("valid golden fixture");
991 let Payload::Command(request) = &mut envelope.payload else {
992 panic!("enqueue fixture changed family");
993 };
994 let CommandRequest {
995 command: AgentCommand::Enqueue { context, .. },
996 ..
997 } = request.as_mut()
998 else {
999 panic!("enqueue fixture changed family");
1000 };
1001 context.scope.scope_id = ScopeId::from(" ");
1002 assert_eq!(
1003 envelope.validate(),
1004 Err(ProtocolError::MissingIdentifier("scope_id"))
1005 );
1006 }
1007
1008 #[test]
1009 fn strict_decoder_rejects_unknown_nested_fields() {
1010 let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
1011 let input = fixture.replacen(
1012 "\"sent_at_unix_ms\": 1700000000000",
1013 "\"sent_at_unix_ms\": 1700000000000, \"unexpected\": true",
1014 1,
1015 );
1016 assert!(matches!(
1017 Envelope::decode_json(&input),
1018 Err(ProtocolError::UnknownField { .. })
1019 ));
1020
1021 let mut input: serde_json::Value = serde_json::from_str(fixture).expect("fixture JSON");
1022 input["payload"]["payload"]["options"]["unexpected"] = serde_json::Value::Bool(true);
1023 assert!(matches!(
1024 Envelope::decode_json(&input.to_string()),
1025 Err(ProtocolError::UnknownField { .. })
1026 ));
1027 }
1028
1029 #[test]
1030 fn abandoned_run_requires_a_base_checkpoint() {
1031 let mut value: serde_json::Value =
1032 serde_json::from_str(include_str!("../../../fixtures/execution/enqueue.v1.json"))
1033 .unwrap();
1034 value["payload"]["payload"]["abandoned_run_id"] =
1035 serde_json::Value::String("run-abandoned".to_owned());
1036
1037 assert!(matches!(
1038 Envelope::decode_json(&value.to_string()),
1039 Err(ProtocolError::InvalidBinding(_))
1040 ));
1041 }
1042}