1use std::fmt;
24
25use serde::de::{self, Deserializer, Visitor};
26use serde::{Deserialize, Serialize, Serializer};
27
28use crate::context::execution::ContextCandidate;
29use crate::context::measurement::ToolMeasurement;
30use crate::types::durable_content::DurableContent;
31
32use super::root::{LogicalAgentSpec, MessageRole};
33use super::scalar::{
34 AttemptId, BoundedJson, CallId, EffectId, FiniteF64, HandleId, InputId, MAX_ID_BYTES,
35 MemoryBindingId, NodeId, SCALAR_ERROR_MARKER, TaskId, WireScalarError, WireU64,
36};
37use super::syscall::{MemoryKind, SyscallCausation};
38
39#[doc(hidden)]
44pub(crate) fn validate_opaque(label: &'static str, value: &str) -> Result<(), WireScalarError> {
45 if value.is_empty() {
46 return Err(WireScalarError::new(format!("{label} must not be empty")));
47 }
48 if value.len() > MAX_ID_BYTES {
49 return Err(WireScalarError::new(format!(
50 "{label} is {} bytes; the bound is {MAX_ID_BYTES}",
51 value.len()
52 )));
53 }
54 if value.chars().any(char::is_control) {
55 return Err(WireScalarError::new(format!(
56 "{label} must not contain control characters"
57 )));
58 }
59 Ok(())
60}
61
62macro_rules! wire_opaque_ref {
65 ($(#[$doc:meta])* $name:ident, $label:literal) => {
66 $(#[$doc])*
67 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
68 pub struct $name(String);
69
70 impl $name {
71 pub fn new(value: impl Into<String>) -> Result<Self, WireScalarError> {
72 let value = value.into();
73 $crate::runtime::kernel::wire::effect::validate_opaque($label, &value)?;
74 Ok(Self(value))
75 }
76
77 pub fn as_str(&self) -> &str {
78 &self.0
79 }
80
81 pub fn into_string(self) -> String {
82 self.0
83 }
84 }
85
86 impl fmt::Display for $name {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 f.write_str(&self.0)
89 }
90 }
91
92 impl Serialize for $name {
93 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
94 serializer.serialize_str(&self.0)
95 }
96 }
97
98 impl<'de> Deserialize<'de> for $name {
99 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
100 struct RefVisitor;
101
102 impl Visitor<'_> for RefVisitor {
103 type Value = $name;
104
105 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 f.write_str(concat!("a non-empty ", $label, " string"))
107 }
108
109 fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
110 $name::new(value).map_err(|err| {
111 E::custom(format!("{SCALAR_ERROR_MARKER}: {}", err.message))
112 })
113 }
114
115 fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
116 Err(E::custom(format!(
117 "{SCALAR_ERROR_MARKER}: {} must be a branded string, got {value}",
118 $label
119 )))
120 }
121
122 fn visit_i64<E: de::Error>(self, value: i64) -> Result<Self::Value, E> {
123 Err(E::custom(format!(
124 "{SCALAR_ERROR_MARKER}: {} must be a branded string, got {value}",
125 $label
126 )))
127 }
128
129 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
130 Err(E::custom(format!(
131 "{SCALAR_ERROR_MARKER}: {} must be a branded string, got null",
132 $label
133 )))
134 }
135 }
136
137 deserializer.deserialize_any(RefVisitor)
138 }
139 }
140 };
141}
142
143pub(crate) use wire_opaque_ref;
144
145wire_opaque_ref!(
146 PayloadRef,
153 "payload ref"
154);
155wire_opaque_ref!(
156 Digest,
159 "digest"
160);
161wire_opaque_ref!(
162 LaunchToken,
166 "launch token"
167);
168wire_opaque_ref!(
169 MemoryRecordRef,
171 "memory record ref"
172);
173
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct KernelEffect {
185 pub effect_id: EffectId,
186 pub causation_input_id: InputId,
187 pub effect: EffectKind,
188}
189
190impl KernelEffect {
191 pub fn tag(&self) -> EffectKindTag {
192 self.effect.tag()
193 }
194
195 pub fn accept_outcome(&self, outcome: &EffectOutcome) -> Result<(), EffectResolutionMismatch> {
203 match outcome {
204 EffectOutcome::Failed(_) => Ok(()),
205 EffectOutcome::Succeeded(success) => {
206 let expected = self.effect.tag().expected_success();
207 let received = success.result.tag();
208 if expected == received {
209 Ok(())
210 } else {
211 Err(EffectResolutionMismatch {
212 effect_id: self.effect_id.clone(),
213 expected,
214 received,
215 })
216 }
217 }
218 }
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct EffectResolutionMismatch {
225 pub effect_id: EffectId,
226 pub expected: EffectSuccessTag,
227 pub received: EffectSuccessTag,
228}
229
230impl fmt::Display for EffectResolutionMismatch {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 write!(
233 f,
234 "effect {} expects a {} resolution, got {}",
235 self.effect_id,
236 self.expected.as_str(),
237 self.received.as_str()
238 )
239 }
240}
241
242impl std::error::Error for EffectResolutionMismatch {}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249#[serde(tag = "kind", rename_all = "snake_case")]
250pub enum EffectKind {
251 CallProvider(CallProviderEffect),
252 ExecuteTools(ExecuteToolsEffect),
253 RequestApproval(RequestApprovalEffect),
254 SpawnTasks(SpawnTasksEffect),
255 PreemptTasks(PreemptTasksEffect),
256 PersistMemory(PersistMemoryEffect),
257 QueryMemory(QueryMemoryEffect),
258 ArchivePageOut(ArchivePageOutEffect),
259 LoadPayload(LoadPayloadEffect),
262 EvaluateMilestone(EvaluateMilestoneEffect),
263}
264
265impl EffectKind {
266 pub fn tag(&self) -> EffectKindTag {
267 match self {
268 Self::CallProvider(_) => EffectKindTag::CallProvider,
269 Self::ExecuteTools(_) => EffectKindTag::ExecuteTools,
270 Self::RequestApproval(_) => EffectKindTag::RequestApproval,
271 Self::SpawnTasks(_) => EffectKindTag::SpawnTasks,
272 Self::PreemptTasks(_) => EffectKindTag::PreemptTasks,
273 Self::PersistMemory(_) => EffectKindTag::PersistMemory,
274 Self::QueryMemory(_) => EffectKindTag::QueryMemory,
275 Self::ArchivePageOut(_) => EffectKindTag::ArchivePageOut,
276 Self::LoadPayload(_) => EffectKindTag::LoadPayload,
277 Self::EvaluateMilestone(_) => EffectKindTag::EvaluateMilestone,
278 }
279 }
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum EffectKindTag {
291 CallProvider,
292 ExecuteTools,
293 RequestApproval,
294 SpawnTasks,
295 PreemptTasks,
296 PersistMemory,
297 QueryMemory,
298 ArchivePageOut,
299 LoadPayload,
300 EvaluateMilestone,
301}
302
303impl EffectKindTag {
304 pub const ALL: [Self; 10] = [
305 Self::CallProvider,
306 Self::ExecuteTools,
307 Self::RequestApproval,
308 Self::SpawnTasks,
309 Self::PreemptTasks,
310 Self::PersistMemory,
311 Self::QueryMemory,
312 Self::ArchivePageOut,
313 Self::LoadPayload,
314 Self::EvaluateMilestone,
315 ];
316
317 pub fn as_str(self) -> &'static str {
318 match self {
319 Self::CallProvider => "call_provider",
320 Self::ExecuteTools => "execute_tools",
321 Self::RequestApproval => "request_approval",
322 Self::SpawnTasks => "spawn_tasks",
323 Self::PreemptTasks => "preempt_tasks",
324 Self::PersistMemory => "persist_memory",
325 Self::QueryMemory => "query_memory",
326 Self::ArchivePageOut => "archive_page_out",
327 Self::LoadPayload => "load_payload",
328 Self::EvaluateMilestone => "evaluate_milestone",
329 }
330 }
331
332 pub fn expected_success(self) -> EffectSuccessTag {
334 match self {
335 Self::CallProvider => EffectSuccessTag::Provider,
336 Self::ExecuteTools => EffectSuccessTag::Tools,
337 Self::RequestApproval => EffectSuccessTag::Approval,
338 Self::SpawnTasks => EffectSuccessTag::TasksSpawned,
339 Self::PreemptTasks => EffectSuccessTag::TasksPreempted,
340 Self::PersistMemory => EffectSuccessTag::MemoryPersisted,
341 Self::QueryMemory => EffectSuccessTag::MemoryQueried,
342 Self::ArchivePageOut => EffectSuccessTag::PageOutArchived,
343 Self::LoadPayload => EffectSuccessTag::PayloadLoaded,
344 Self::EvaluateMilestone => EffectSuccessTag::MilestoneEvaluated,
345 }
346 }
347}
348
349impl fmt::Display for EffectKindTag {
350 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351 f.write_str(self.as_str())
352 }
353}
354
355#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
360#[serde(deny_unknown_fields)]
361pub struct CallProviderEffect {
362 pub context_candidate: Box<ContextCandidate>,
365 pub context: RenderedContext,
366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 pub tools: Vec<ToolSchema>,
368}
369
370#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
371#[serde(deny_unknown_fields)]
372pub struct ExecuteToolsEffect {
373 pub calls: Vec<ToolCall>,
374}
375
376#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
377#[serde(deny_unknown_fields)]
378pub struct RequestApprovalEffect {
379 pub requests: Vec<ApprovalRequest>,
380}
381
382#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
383#[serde(deny_unknown_fields)]
384pub struct SpawnTasksEffect {
385 pub tasks: Vec<TaskLaunch>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub budget: Option<WorkflowBudget>,
388}
389
390#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
391#[serde(deny_unknown_fields)]
392pub struct PreemptTasksEffect {
393 pub attempts: Vec<TaskAttemptRef>,
394 pub reason: String,
395}
396
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
398#[serde(deny_unknown_fields)]
399pub struct PersistMemoryEffect {
400 pub binding: MemoryAccessBinding,
401 pub memory: CanonicalMemoryWrite,
402}
403
404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
405#[serde(deny_unknown_fields)]
406pub struct QueryMemoryEffect {
407 pub binding: MemoryAccessBinding,
408 pub query: CanonicalMemoryQuery,
409 pub requested_k: u32,
411}
412
413#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
414#[serde(deny_unknown_fields)]
415pub struct ArchivePageOutEffect {
416 pub handle_id: HandleId,
417 pub payload: PageOutPayload,
418}
419
420#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
421#[serde(deny_unknown_fields)]
422pub struct LoadPayloadEffect {
423 pub handle_id: HandleId,
424 pub payload_ref: PayloadRef,
425}
426
427#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
428#[serde(deny_unknown_fields)]
429pub struct EvaluateMilestoneEffect {
430 pub request: MilestoneRequest,
431}
432
433#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
439#[serde(deny_unknown_fields)]
440pub struct RenderedContext {
441 #[serde(default, skip_serializing_if = "String::is_empty")]
442 pub system_stable: String,
443 #[serde(default, skip_serializing_if = "String::is_empty")]
444 pub system_knowledge: String,
445 #[serde(default, skip_serializing_if = "Vec::is_empty")]
446 pub turns: Vec<ProviderMessage>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub state_turn: Option<ProviderMessage>,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub frozen_prefix_len: Option<u32>,
453}
454
455#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
459#[serde(deny_unknown_fields)]
460pub struct ProviderMessage {
461 pub role: MessageRole,
462 pub content: String,
463 #[serde(default, skip_serializing_if = "Vec::is_empty")]
464 pub tool_calls: Vec<ToolCall>,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub tool_call_id: Option<CallId>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub tokens: Option<u32>,
469}
470
471#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
472#[serde(deny_unknown_fields)]
473pub struct ToolSchema {
474 pub name: String,
475 #[serde(default, skip_serializing_if = "String::is_empty")]
476 pub description: String,
477 #[serde(default, skip_serializing_if = "BoundedJson::is_null")]
478 pub parameters: BoundedJson,
479}
480
481#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
485#[serde(deny_unknown_fields)]
486pub struct ToolCall {
487 pub call_id: CallId,
488 pub name: String,
489 #[serde(default, skip_serializing_if = "BoundedJson::is_null")]
490 pub arguments: BoundedJson,
491}
492
493#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
494#[serde(deny_unknown_fields)]
495pub struct ApprovalRequest {
496 pub call_id: CallId,
497 pub tool_name: String,
498 #[serde(default, skip_serializing_if = "BoundedJson::is_null")]
499 pub arguments: BoundedJson,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub reason: Option<String>,
502}
503
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
510#[serde(deny_unknown_fields)]
511pub struct TaskLaunch {
512 pub task_id: TaskId,
513 pub attempt_id: AttemptId,
514 pub launch_token: LaunchToken,
515 pub node_id: NodeId,
516 pub spec: LogicalAgentSpec,
517}
518
519#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
520#[serde(deny_unknown_fields)]
521pub struct WorkflowBudget {
522 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub max_total_tokens: Option<WireU64>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub max_turns: Option<u32>,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub max_concurrency: Option<u32>,
528}
529
530#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
531#[serde(deny_unknown_fields)]
532pub struct TaskAttemptRef {
533 pub task_id: TaskId,
534 pub attempt_id: AttemptId,
535}
536
537#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
540#[serde(deny_unknown_fields)]
541pub struct MemoryAccessBinding {
542 pub binding_id: MemoryBindingId,
543 pub capabilities: MemoryCapabilities,
544}
545
546#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
547#[serde(deny_unknown_fields)]
548pub struct MemoryCapabilities {
549 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
550 pub read: bool,
551 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
552 pub write: bool,
553}
554
555#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
562#[serde(deny_unknown_fields)]
563pub struct CanonicalMemoryWrite {
564 pub name: String,
565 pub kind: MemoryKind,
566 pub content: String,
567 #[serde(default, skip_serializing_if = "String::is_empty")]
568 pub description: String,
569 #[serde(default, skip_serializing_if = "Vec::is_empty")]
570 pub evidence_refs: Vec<String>,
571 pub accepted_at_ms: WireU64,
572 pub causation: SyscallCausation,
573}
574
575#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
576#[serde(deny_unknown_fields)]
577pub struct CanonicalMemoryQuery {
578 #[serde(default, skip_serializing_if = "String::is_empty")]
579 pub text: String,
580 #[serde(default, skip_serializing_if = "Vec::is_empty")]
581 pub kinds: Vec<MemoryKind>,
582 pub accepted_at_ms: WireU64,
583 pub causation: SyscallCausation,
584}
585
586#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
589#[serde(deny_unknown_fields)]
590pub struct PageOutPayload {
591 pub content: String,
592 pub digest: Digest,
593 pub original_size: WireU64,
594 #[serde(default, skip_serializing_if = "String::is_empty")]
595 pub preview: String,
596}
597
598#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
606#[serde(deny_unknown_fields)]
607pub struct MilestoneRequest {
608 pub contract_id: String,
609 pub phase_id: String,
610}
611
612#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
622#[serde(tag = "status", rename_all = "snake_case")]
623pub enum EffectOutcome {
624 Succeeded(EffectSucceeded),
625 Failed(EffectFailed),
626}
627
628impl EffectOutcome {
629 pub fn success_tag(&self) -> Option<EffectSuccessTag> {
631 match self {
632 Self::Succeeded(success) => Some(success.result.tag()),
633 Self::Failed(_) => None,
634 }
635 }
636}
637
638#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
641#[serde(deny_unknown_fields)]
642pub struct EffectSucceeded {
643 pub result: EffectSuccess,
644}
645
646#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
648#[serde(deny_unknown_fields)]
649pub struct EffectFailed {
650 pub failure: HostEffectFailure,
651}
652
653#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
657#[serde(deny_unknown_fields)]
658pub struct HostEffectFailure {
659 pub kind: HostEffectFailureKind,
660 #[serde(default, skip_serializing_if = "String::is_empty")]
661 pub message: String,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub retryable: Option<bool>,
678}
679
680#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum HostEffectFailureKind {
701 TransportExhausted,
704 ProtocolError,
709 StorageUnavailable,
710 PermissionDenied,
711 ResourceExhausted,
712 Unknown,
713}
714
715impl HostEffectFailureKind {
716 pub const ALL: [Self; 6] = [
717 Self::TransportExhausted,
718 Self::ProtocolError,
719 Self::StorageUnavailable,
720 Self::PermissionDenied,
721 Self::ResourceExhausted,
722 Self::Unknown,
723 ];
724
725 pub fn as_str(self) -> &'static str {
726 match self {
727 Self::TransportExhausted => "transport_exhausted",
728 Self::ProtocolError => "protocol_error",
729 Self::StorageUnavailable => "storage_unavailable",
730 Self::PermissionDenied => "permission_denied",
731 Self::ResourceExhausted => "resource_exhausted",
732 Self::Unknown => "unknown",
733 }
734 }
735}
736
737#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
739#[serde(tag = "kind", rename_all = "snake_case")]
740pub enum EffectSuccess {
741 Provider(ProviderSuccess),
742 Tools(ToolsSuccess),
743 Approval(ApprovalSuccess),
744 TasksSpawned(TasksSpawnedSuccess),
745 TasksPreempted(TasksPreemptedSuccess),
746 MemoryPersisted(MemoryPersistedSuccess),
747 MemoryQueried(MemoryQueriedSuccess),
748 PageOutArchived(PageOutArchivedSuccess),
749 PayloadLoaded(PayloadLoadedSuccess),
750 MilestoneEvaluated(MilestoneEvaluatedSuccess),
751}
752
753impl EffectSuccess {
754 pub fn tag(&self) -> EffectSuccessTag {
755 match self {
756 Self::Provider(_) => EffectSuccessTag::Provider,
757 Self::Tools(_) => EffectSuccessTag::Tools,
758 Self::Approval(_) => EffectSuccessTag::Approval,
759 Self::TasksSpawned(_) => EffectSuccessTag::TasksSpawned,
760 Self::TasksPreempted(_) => EffectSuccessTag::TasksPreempted,
761 Self::MemoryPersisted(_) => EffectSuccessTag::MemoryPersisted,
762 Self::MemoryQueried(_) => EffectSuccessTag::MemoryQueried,
763 Self::PageOutArchived(_) => EffectSuccessTag::PageOutArchived,
764 Self::PayloadLoaded(_) => EffectSuccessTag::PayloadLoaded,
765 Self::MilestoneEvaluated(_) => EffectSuccessTag::MilestoneEvaluated,
766 }
767 }
768}
769
770#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
772#[serde(rename_all = "snake_case")]
773pub enum EffectSuccessTag {
774 Provider,
775 Tools,
776 Approval,
777 TasksSpawned,
778 TasksPreempted,
779 MemoryPersisted,
780 MemoryQueried,
781 PageOutArchived,
782 PayloadLoaded,
783 MilestoneEvaluated,
784}
785
786impl EffectSuccessTag {
787 pub const ALL: [Self; 10] = [
788 Self::Provider,
789 Self::Tools,
790 Self::Approval,
791 Self::TasksSpawned,
792 Self::TasksPreempted,
793 Self::MemoryPersisted,
794 Self::MemoryQueried,
795 Self::PageOutArchived,
796 Self::PayloadLoaded,
797 Self::MilestoneEvaluated,
798 ];
799
800 pub fn as_str(self) -> &'static str {
801 match self {
802 Self::Provider => "provider",
803 Self::Tools => "tools",
804 Self::Approval => "approval",
805 Self::TasksSpawned => "tasks_spawned",
806 Self::TasksPreempted => "tasks_preempted",
807 Self::MemoryPersisted => "memory_persisted",
808 Self::MemoryQueried => "memory_queried",
809 Self::PageOutArchived => "page_out_archived",
810 Self::PayloadLoaded => "payload_loaded",
811 Self::MilestoneEvaluated => "milestone_evaluated",
812 }
813 }
814
815 pub fn resolves(self) -> EffectKindTag {
818 match self {
819 Self::Provider => EffectKindTag::CallProvider,
820 Self::Tools => EffectKindTag::ExecuteTools,
821 Self::Approval => EffectKindTag::RequestApproval,
822 Self::TasksSpawned => EffectKindTag::SpawnTasks,
823 Self::TasksPreempted => EffectKindTag::PreemptTasks,
824 Self::MemoryPersisted => EffectKindTag::PersistMemory,
825 Self::MemoryQueried => EffectKindTag::QueryMemory,
826 Self::PageOutArchived => EffectKindTag::ArchivePageOut,
827 Self::PayloadLoaded => EffectKindTag::LoadPayload,
828 Self::MilestoneEvaluated => EffectKindTag::EvaluateMilestone,
829 }
830 }
831}
832
833impl fmt::Display for EffectSuccessTag {
834 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
835 f.write_str(self.as_str())
836 }
837}
838
839#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
844#[serde(deny_unknown_fields)]
845pub struct ProviderSuccess {
846 pub outcome: ProviderOutcome,
847}
848
849#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
854#[serde(tag = "kind", rename_all = "snake_case")]
855pub enum ProviderOutcome {
856 Completed(ProviderCompleted),
857 ContextOverflow(ProviderContextOverflow),
861}
862
863#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
864#[serde(deny_unknown_fields)]
865pub struct ProviderCompleted {
866 pub message: ProviderMessage,
867 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub observed_input_tokens: Option<u32>,
869 #[serde(default, skip_serializing_if = "Option::is_none")]
870 pub observed_output_tokens: Option<u32>,
871 #[serde(default, skip_serializing_if = "Option::is_none")]
872 pub stop_reason: Option<ProviderStopReason>,
873}
874
875#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
876#[serde(deny_unknown_fields)]
877pub struct ProviderContextOverflow {
878 #[serde(default, skip_serializing_if = "Option::is_none")]
879 pub observed_input_tokens: Option<u32>,
880}
881
882#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
891#[serde(rename_all = "snake_case")]
892pub enum ProviderStopReason {
893 EndTurn,
894 ToolUse,
895 MaxTokens,
896 StopSequence,
897 ContentFilter,
898 Other,
899}
900
901impl ProviderStopReason {
902 pub const ALL: [Self; 6] = [
903 Self::EndTurn,
904 Self::ToolUse,
905 Self::MaxTokens,
906 Self::StopSequence,
907 Self::ContentFilter,
908 Self::Other,
909 ];
910
911 pub fn as_str(self) -> &'static str {
912 match self {
913 Self::EndTurn => "end_turn",
914 Self::ToolUse => "tool_use",
915 Self::MaxTokens => "max_tokens",
916 Self::StopSequence => "stop_sequence",
917 Self::ContentFilter => "content_filter",
918 Self::Other => "other",
919 }
920 }
921}
922
923impl fmt::Display for ProviderStopReason {
924 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
925 f.write_str(self.as_str())
926 }
927}
928
929#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
930#[serde(deny_unknown_fields)]
931pub struct ToolsSuccess {
932 pub results: Vec<ToolResultPayload>,
933 #[serde(default, skip_serializing_if = "Vec::is_empty")]
936 pub measurements: Vec<ToolMeasurement>,
937}
938
939#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
940#[serde(deny_unknown_fields)]
941pub struct ApprovalSuccess {
942 #[serde(default, skip_serializing_if = "Vec::is_empty")]
943 pub approved_call_ids: Vec<CallId>,
944 #[serde(default, skip_serializing_if = "Vec::is_empty")]
945 pub denied_call_ids: Vec<CallId>,
946}
947
948#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
949#[serde(deny_unknown_fields)]
950pub struct TasksSpawnedSuccess {
951 pub attempts: Vec<TaskLaunchOutcome>,
952}
953
954#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
957#[serde(deny_unknown_fields)]
958pub struct TaskLaunchOutcome {
959 pub task_id: TaskId,
960 pub attempt_id: AttemptId,
961 pub outcome: TaskLaunchStatus,
962}
963
964#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
965#[serde(tag = "status", rename_all = "snake_case")]
966pub enum TaskLaunchStatus {
967 Started(TaskLaunchStarted),
968 Failed(TaskLaunchFailed),
969}
970
971#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
972#[serde(deny_unknown_fields)]
973pub struct TaskLaunchStarted {}
974
975#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
976#[serde(deny_unknown_fields)]
977pub struct TaskLaunchFailed {
978 pub failure: TaskLaunchFailure,
979}
980
981#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
984#[serde(deny_unknown_fields)]
985pub struct TaskLaunchFailure {
986 pub kind: HostEffectFailureKind,
987 #[serde(default, skip_serializing_if = "String::is_empty")]
988 pub message: String,
989}
990
991#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
992#[serde(deny_unknown_fields)]
993pub struct TasksPreemptedSuccess {
994 pub attempts: Vec<TaskPreemptOutcome>,
995}
996
997#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
998#[serde(deny_unknown_fields)]
999pub struct TaskPreemptOutcome {
1000 pub task_id: TaskId,
1001 pub attempt_id: AttemptId,
1002 pub outcome: TaskPreemptStatus,
1003}
1004
1005#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1006#[serde(tag = "status", rename_all = "snake_case")]
1007pub enum TaskPreemptStatus {
1008 Preempted(TaskPreempted),
1009 AlreadyFinished(TaskAlreadyFinished),
1012 Failed(TaskPreemptFailed),
1013}
1014
1015#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1016#[serde(deny_unknown_fields)]
1017pub struct TaskPreempted {}
1018
1019#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1020#[serde(deny_unknown_fields)]
1021pub struct TaskAlreadyFinished {}
1022
1023#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1024#[serde(deny_unknown_fields)]
1025pub struct TaskPreemptFailed {
1026 pub failure: TaskLaunchFailure,
1027}
1028
1029#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1030#[serde(deny_unknown_fields)]
1031pub struct MemoryPersistedSuccess {
1032 pub receipt: MemoryPersistReceipt,
1033}
1034
1035#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1036#[serde(deny_unknown_fields)]
1037pub struct MemoryPersistReceipt {
1038 pub binding_id: MemoryBindingId,
1039 pub record_ref: MemoryRecordRef,
1040 pub digest: Digest,
1041}
1042
1043#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1044#[serde(deny_unknown_fields)]
1045pub struct MemoryQueriedSuccess {
1046 pub recalls: Vec<MemoryRecall>,
1047}
1048
1049#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1050#[serde(deny_unknown_fields)]
1051pub struct MemoryRecall {
1052 pub record_ref: MemoryRecordRef,
1053 pub name: String,
1054 pub kind: MemoryKind,
1055 pub content: String,
1056 #[serde(default, skip_serializing_if = "Option::is_none")]
1059 pub score: Option<FiniteF64>,
1060}
1061
1062#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1063#[serde(deny_unknown_fields)]
1064pub struct PageOutArchivedSuccess {
1065 pub receipt: ArchiveReceipt,
1066}
1067
1068#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1069#[serde(deny_unknown_fields)]
1070pub struct ArchiveReceipt {
1071 pub handle_id: HandleId,
1072 pub payload_ref: PayloadRef,
1073 pub digest: Digest,
1074 pub original_size: WireU64,
1075}
1076
1077#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1078#[serde(deny_unknown_fields)]
1079pub struct PayloadLoadedSuccess {
1080 pub handle_id: HandleId,
1081 pub payload: InlinePayload,
1082}
1083
1084#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1087#[serde(deny_unknown_fields)]
1088pub struct InlinePayload {
1089 pub content: String,
1090 pub digest: Digest,
1091 pub original_size: WireU64,
1092}
1093
1094#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1095#[serde(deny_unknown_fields)]
1096pub struct MilestoneEvaluatedSuccess {
1097 pub result: MilestoneCheckResult,
1098}
1099
1100#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1107#[serde(deny_unknown_fields)]
1108pub struct MilestoneCheckResult {
1109 pub phase_id: String,
1110 pub passed: bool,
1111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1112 pub failed_criteria: Vec<String>,
1113 #[serde(default, skip_serializing_if = "Option::is_none")]
1114 pub score: Option<FiniteF64>,
1115 #[serde(default, skip_serializing_if = "String::is_empty")]
1116 pub notes: String,
1117}
1118
1119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1131#[serde(tag = "kind", rename_all = "snake_case")]
1132pub enum ToolResultPayload {
1133 Inline(InlineToolResult),
1134 External(ExternalToolResult),
1135}
1136
1137impl ToolResultPayload {
1138 pub fn call_id(&self) -> &CallId {
1139 match self {
1140 Self::Inline(inline) => &inline.call_id,
1141 Self::External(external) => &external.call_id,
1142 }
1143 }
1144
1145 pub fn disposition(&self) -> ToolResultDisposition {
1150 match self {
1151 Self::Inline(inline) => inline.result.disposition,
1152 Self::External(external) => external.disposition,
1153 }
1154 }
1155
1156 pub fn is_error(&self) -> bool {
1157 match self {
1158 Self::Inline(inline) => inline.result.is_error,
1159 Self::External(external) => external.is_error,
1160 }
1161 }
1162}
1163
1164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1165#[serde(deny_unknown_fields)]
1166pub struct InlineToolResult {
1167 pub call_id: CallId,
1168 pub result: ToolResult,
1169}
1170
1171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1179#[serde(deny_unknown_fields)]
1180pub struct ExternalToolResult {
1181 pub call_id: CallId,
1182 pub payload_ref: PayloadRef,
1184 pub digest: Digest,
1185 pub original_size: WireU64,
1186 pub preview: String,
1188 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1189 pub is_error: bool,
1190 pub disposition: ToolResultDisposition,
1192}
1193
1194#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1205#[serde(deny_unknown_fields)]
1206pub struct ToolResult {
1207 pub output: String,
1208 #[serde(default, skip_serializing_if = "Option::is_none")]
1209 pub durable_content: Option<DurableContent>,
1210 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1211 pub is_error: bool,
1212 pub disposition: ToolResultDisposition,
1215}
1216
1217#[derive(
1229 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1230)]
1231#[serde(rename_all = "snake_case")]
1232pub enum ToolResultDisposition {
1233 #[default]
1237 Recoverable,
1238 Fatal,
1242}
1243
1244impl ToolResultDisposition {
1245 pub const ALL: [Self; 2] = [Self::Recoverable, Self::Fatal];
1246
1247 pub fn as_str(self) -> &'static str {
1248 match self {
1249 Self::Recoverable => "recoverable",
1250 Self::Fatal => "fatal",
1251 }
1252 }
1253
1254 pub fn is_fatal(self) -> bool {
1255 matches!(self, Self::Fatal)
1256 }
1257}
1258
1259impl fmt::Display for ToolResultDisposition {
1260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1261 f.write_str(self.as_str())
1262 }
1263}
1264
1265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1272#[serde(tag = "kind", rename_all = "snake_case")]
1273pub enum PayloadResidency {
1274 Resident(ResidentPayload),
1275 External(ExternalResidency),
1276 PagedOut(PagedOutResidency),
1277 Collapsed(CollapsedPayload),
1279}
1280
1281#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1282#[serde(deny_unknown_fields)]
1283pub struct ResidentPayload {}
1284
1285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1286#[serde(deny_unknown_fields)]
1287pub struct ExternalResidency {
1288 pub payload_ref: PayloadRef,
1289 pub digest: Digest,
1290 pub original_size: WireU64,
1291}
1292
1293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1294#[serde(deny_unknown_fields)]
1295pub struct PagedOutResidency {
1296 pub payload_ref: PayloadRef,
1297 pub digest: Digest,
1298}
1299
1300#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1301#[serde(deny_unknown_fields)]
1302pub struct CollapsedPayload {}
1303
1304#[cfg(test)]
1305mod tests {
1306 use std::collections::BTreeSet;
1307 use std::fs;
1308 use std::path::PathBuf;
1309
1310 use serde_json::{Value, json};
1311
1312 use crate::context::measurement::ToolMeasurement;
1313
1314 use super::super::*;
1315
1316 fn fixture_dir() -> PathBuf {
1321 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/kernel-wire")
1322 }
1323
1324 fn fixtures_with_prefix(prefix: &str) -> Vec<(String, Value)> {
1325 let dir = fixture_dir();
1326 let mut names: Vec<String> = fs::read_dir(&dir)
1327 .unwrap_or_else(|e| panic!("failed to read {}: {e}", dir.display()))
1328 .map(|entry| {
1329 entry
1330 .expect("dir entry")
1331 .file_name()
1332 .to_string_lossy()
1333 .to_string()
1334 })
1335 .filter(|name| name.ends_with(".json") && name.starts_with(prefix))
1336 .collect();
1337 names.sort();
1338 assert!(!names.is_empty(), "no {prefix}*.json fixtures");
1339 names
1340 .into_iter()
1341 .map(|name| {
1342 let raw = fs::read_to_string(dir.join(&name)).unwrap();
1343 let value: Value = serde_json::from_str(&raw).unwrap();
1344 (name, value)
1345 })
1346 .collect()
1347 }
1348
1349 fn keys(value: &Value, out: &mut BTreeSet<String>) {
1350 match value {
1351 Value::Object(map) => {
1352 for (key, child) in map {
1353 out.insert(key.clone());
1354 keys(child, out);
1355 }
1356 }
1357 Value::Array(items) => items.iter().for_each(|item| keys(item, out)),
1358 _ => {}
1359 }
1360 }
1361
1362 fn effect_json(outcome: Value) -> String {
1363 serde_json::to_string(&json!({
1364 "operation_id": "op-1",
1365 "input_id": "in-1",
1366 "observed_at_ms": "1700000000000",
1367 "input": { "kind": "resolve_effect", "effect_id": "op-1:step:1:effect:0", "outcome": outcome },
1368 }))
1369 .unwrap()
1370 }
1371
1372 fn decode_effect(outcome: Value) -> Result<WireEnvelope, WireRejection> {
1373 decode_envelope_json(&effect_json(outcome), &KernelBootstrapLimits::default())
1374 }
1375
1376 fn call_id(id: &str) -> CallId {
1377 CallId::new(id).unwrap()
1378 }
1379
1380 fn digest() -> Digest {
1381 Digest::new("sha256:3b1f4a7c9e2d05186a4c7f0b9d3e8c25714f6a0b8c5d2e9f1a3b6c8d0e2f4a61")
1382 .unwrap()
1383 }
1384
1385 fn payload_ref() -> PayloadRef {
1386 PayloadRef::new("payload:01J8Y2QK7C4N0V").unwrap()
1387 }
1388
1389 fn binding() -> MemoryAccessBinding {
1390 MemoryAccessBinding {
1391 binding_id: MemoryBindingId::new("binding-a").unwrap(),
1392 capabilities: MemoryCapabilities {
1393 read: true,
1394 write: true,
1395 },
1396 }
1397 }
1398
1399 fn causation() -> SyscallCausation {
1400 SyscallCausation::ProviderTool(ProviderToolCausation {
1401 provider_effect_id: EffectId::new("op-1:step:1:effect:0").unwrap(),
1402 call_id: call_id("call-1"),
1403 task_id: TaskId::new("task-1").unwrap(),
1404 })
1405 }
1406
1407 fn effect_samples() -> Vec<EffectKind> {
1409 let context = RenderedContext::default();
1410 let tools = vec![ToolSchema {
1411 name: "read_file".to_string(),
1412 description: "read a file".to_string(),
1413 parameters: BoundedJson::null(),
1414 }];
1415 let (mut context_candidate, _) = crate::context::manager::ContextManager::new(100_000)
1416 .prepare_candidate(
1417 "op-1".into(),
1418 "op-1:step:1".into(),
1419 1,
1420 crate::evolution::ContentDigest::from_bytes(b"test-policy"),
1421 )
1422 .unwrap();
1423 context_candidate.rendered_snapshot = crate::evolution::ContentDigest::from_bytes(
1424 super::super::record::canonical_bytes(&(&context, &tools))
1425 .unwrap()
1426 .as_slice(),
1427 );
1428 vec![
1429 EffectKind::CallProvider(CallProviderEffect {
1430 context_candidate: Box::new(context_candidate),
1431 context,
1432 tools,
1433 }),
1434 EffectKind::ExecuteTools(ExecuteToolsEffect {
1435 calls: vec![ToolCall {
1436 call_id: call_id("call-1"),
1437 name: "read_file".to_string(),
1438 arguments: BoundedJson::null(),
1439 }],
1440 }),
1441 EffectKind::RequestApproval(RequestApprovalEffect {
1442 requests: vec![ApprovalRequest {
1443 call_id: call_id("call-1"),
1444 tool_name: "rm".to_string(),
1445 arguments: BoundedJson::null(),
1446 reason: Some("destructive".to_string()),
1447 }],
1448 }),
1449 EffectKind::SpawnTasks(SpawnTasksEffect {
1450 tasks: vec![TaskLaunch {
1451 task_id: TaskId::new("task-1").unwrap(),
1452 attempt_id: AttemptId::new("task-1:attempt:1").unwrap(),
1453 launch_token: LaunchToken::new("launch-1").unwrap(),
1454 node_id: NodeId::new("node-a").unwrap(),
1455 spec: LogicalAgentSpec::new("research"),
1456 }],
1457 budget: None,
1458 }),
1459 EffectKind::PreemptTasks(PreemptTasksEffect {
1460 attempts: vec![TaskAttemptRef {
1461 task_id: TaskId::new("task-1").unwrap(),
1462 attempt_id: AttemptId::new("task-1:attempt:1").unwrap(),
1463 }],
1464 reason: "budget exhausted".to_string(),
1465 }),
1466 EffectKind::PersistMemory(PersistMemoryEffect {
1467 binding: binding(),
1468 memory: CanonicalMemoryWrite {
1469 name: "release pipeline".to_string(),
1470 kind: MemoryKind::Project,
1471 content: "tag v* publishes".to_string(),
1472 description: String::new(),
1473 evidence_refs: Vec::new(),
1474 accepted_at_ms: WireU64::new(1_700_000_000_000),
1475 causation: causation(),
1476 },
1477 }),
1478 EffectKind::QueryMemory(QueryMemoryEffect {
1479 binding: binding(),
1480 query: CanonicalMemoryQuery {
1481 text: "release".to_string(),
1482 kinds: vec![MemoryKind::Project],
1483 accepted_at_ms: WireU64::new(1_700_000_000_000),
1484 causation: causation(),
1485 },
1486 requested_k: 5,
1487 }),
1488 EffectKind::ArchivePageOut(ArchivePageOutEffect {
1489 handle_id: HandleId::new("handle-9").unwrap(),
1490 payload: PageOutPayload {
1491 content: "the full tool output".to_string(),
1492 digest: digest(),
1493 original_size: WireU64::new(262_144),
1494 preview: "the full…".to_string(),
1495 },
1496 }),
1497 EffectKind::LoadPayload(LoadPayloadEffect {
1498 handle_id: HandleId::new("handle-9").unwrap(),
1499 payload_ref: payload_ref(),
1500 }),
1501 EffectKind::EvaluateMilestone(EvaluateMilestoneEffect {
1502 request: MilestoneRequest {
1503 contract_id: "brief-quality-primary".to_string(),
1504 phase_id: "phase-2".to_string(),
1505 },
1506 }),
1507 ]
1508 }
1509
1510 fn success_samples() -> Vec<EffectSuccess> {
1512 vec![
1513 EffectSuccess::Provider(ProviderSuccess {
1514 outcome: ProviderOutcome::Completed(ProviderCompleted {
1515 message: ProviderMessage {
1516 role: MessageRole::Assistant,
1517 content: "done".to_string(),
1518 tool_calls: Vec::new(),
1519 tool_call_id: None,
1520 tokens: None,
1521 },
1522 observed_input_tokens: Some(120),
1523 observed_output_tokens: Some(8),
1524 stop_reason: Some(ProviderStopReason::EndTurn),
1525 }),
1526 }),
1527 EffectSuccess::Tools(ToolsSuccess {
1528 results: vec![
1529 ToolResultPayload::Inline(InlineToolResult {
1530 call_id: call_id("call-1"),
1531 result: ToolResult {
1532 output: "ok".to_string(),
1533 durable_content: None,
1534 is_error: false,
1535 disposition: ToolResultDisposition::Recoverable,
1536 },
1537 }),
1538 ToolResultPayload::External(ExternalToolResult {
1539 call_id: call_id("call-2"),
1540 payload_ref: payload_ref(),
1541 digest: digest(),
1542 original_size: WireU64::new(1_048_576),
1543 preview: "total 42".to_string(),
1544 is_error: false,
1545 disposition: ToolResultDisposition::Recoverable,
1546 }),
1547 ],
1548 measurements: vec![ToolMeasurement::new("call-1", 2)],
1549 }),
1550 EffectSuccess::Approval(ApprovalSuccess {
1551 approved_call_ids: vec![call_id("call-1")],
1552 denied_call_ids: vec![call_id("call-2")],
1553 }),
1554 EffectSuccess::TasksSpawned(TasksSpawnedSuccess {
1555 attempts: vec![TaskLaunchOutcome {
1556 task_id: TaskId::new("task-1").unwrap(),
1557 attempt_id: AttemptId::new("task-1:attempt:1").unwrap(),
1558 outcome: TaskLaunchStatus::Started(TaskLaunchStarted {}),
1559 }],
1560 }),
1561 EffectSuccess::TasksPreempted(TasksPreemptedSuccess {
1562 attempts: vec![TaskPreemptOutcome {
1563 task_id: TaskId::new("task-1").unwrap(),
1564 attempt_id: AttemptId::new("task-1:attempt:1").unwrap(),
1565 outcome: TaskPreemptStatus::Preempted(TaskPreempted {}),
1566 }],
1567 }),
1568 EffectSuccess::MemoryPersisted(MemoryPersistedSuccess {
1569 receipt: MemoryPersistReceipt {
1570 binding_id: MemoryBindingId::new("binding-a").unwrap(),
1571 record_ref: MemoryRecordRef::new("memory:01J8Y2QK7C4N0W").unwrap(),
1572 digest: digest(),
1573 },
1574 }),
1575 EffectSuccess::MemoryQueried(MemoryQueriedSuccess {
1576 recalls: vec![MemoryRecall {
1577 record_ref: MemoryRecordRef::new("memory:01J8Y2QK7C4N0X").unwrap(),
1578 name: "release pipeline".to_string(),
1579 kind: MemoryKind::Project,
1580 content: "tag v* publishes".to_string(),
1581 score: Some(FiniteF64::new(0.82).unwrap()),
1582 }],
1583 }),
1584 EffectSuccess::PageOutArchived(PageOutArchivedSuccess {
1585 receipt: ArchiveReceipt {
1586 handle_id: HandleId::new("handle-9").unwrap(),
1587 payload_ref: payload_ref(),
1588 digest: digest(),
1589 original_size: WireU64::new(262_144),
1590 },
1591 }),
1592 EffectSuccess::PayloadLoaded(PayloadLoadedSuccess {
1593 handle_id: HandleId::new("handle-9").unwrap(),
1594 payload: InlinePayload {
1595 content: "the full tool output".to_string(),
1596 digest: digest(),
1597 original_size: WireU64::new(262_144),
1598 },
1599 }),
1600 EffectSuccess::MilestoneEvaluated(MilestoneEvaluatedSuccess {
1601 result: MilestoneCheckResult {
1602 phase_id: "phase-2".to_string(),
1603 passed: true,
1604 failed_criteria: Vec::new(),
1605 score: None,
1606 notes: String::new(),
1607 },
1608 }),
1609 ]
1610 }
1611
1612 fn kernel_effect(effect: EffectKind) -> KernelEffect {
1613 KernelEffect {
1614 effect_id: EffectId::new("op-1:step:1:effect:0").unwrap(),
1615 causation_input_id: InputId::new("in-1").unwrap(),
1616 effect,
1617 }
1618 }
1619
1620 #[test]
1625 fn the_effect_union_is_exactly_the_ten_host_executable_actions() {
1626 let tags: BTreeSet<&str> = EffectKindTag::ALL.iter().map(|tag| tag.as_str()).collect();
1627 assert_eq!(
1628 tags,
1629 BTreeSet::from([
1630 "call_provider",
1631 "execute_tools",
1632 "request_approval",
1633 "spawn_tasks",
1634 "preempt_tasks",
1635 "persist_memory",
1636 "query_memory",
1637 "archive_page_out",
1638 "load_payload",
1639 "evaluate_milestone",
1640 ])
1641 );
1642 assert_eq!(EffectKindTag::ALL.len(), 10);
1643
1644 let sampled: Vec<EffectKindTag> = effect_samples().iter().map(EffectKind::tag).collect();
1645 assert_eq!(
1646 sampled,
1647 EffectKindTag::ALL.to_vec(),
1648 "one sample per variant"
1649 );
1650 }
1651
1652 #[test]
1653 fn terminal_and_observation_shapes_are_not_effects() {
1654 for gone in [
1656 "done",
1657 "terminal",
1658 "compact",
1659 "sync_compact",
1660 "knowledge_sweep",
1661 "workflow_completed",
1662 "control_rejection",
1663 "signal_disposition",
1664 "budget_usage",
1665 "spool_large_result",
1666 ] {
1667 assert!(
1668 !EffectKindTag::ALL.iter().any(|tag| tag.as_str() == gone),
1669 "{gone} is a terminal or an observation, not an effect"
1670 );
1671 let raw = json!({ "kind": gone });
1672 assert!(serde_json::from_value::<EffectKind>(raw).is_err());
1673 }
1674 }
1675
1676 #[test]
1677 fn every_effect_carries_its_kernel_minted_id_and_causation() {
1678 for effect in effect_samples() {
1679 let value = serde_json::to_value(kernel_effect(effect)).unwrap();
1680 assert_eq!(value["effect_id"], json!("op-1:step:1:effect:0"));
1681 assert_eq!(value["causation_input_id"], json!("in-1"));
1682 }
1683 }
1684
1685 #[test]
1690 fn each_effect_kind_has_exactly_one_matching_success_kind() {
1691 let expected: Vec<EffectSuccessTag> = EffectKindTag::ALL
1692 .iter()
1693 .map(|tag| tag.expected_success())
1694 .collect();
1695 let distinct: BTreeSet<&str> = expected.iter().map(|tag| tag.as_str()).collect();
1696 assert_eq!(
1697 distinct.len(),
1698 EffectKindTag::ALL.len(),
1699 "the effect→success map must be a bijection"
1700 );
1701 assert_eq!(
1702 distinct,
1703 EffectSuccessTag::ALL
1704 .iter()
1705 .map(|tag| tag.as_str())
1706 .collect::<BTreeSet<&str>>()
1707 );
1708
1709 let sampled: Vec<EffectSuccessTag> =
1710 success_samples().iter().map(EffectSuccess::tag).collect();
1711 assert_eq!(
1712 sampled, expected,
1713 "samples must line up 1:1 with the effects"
1714 );
1715 }
1716
1717 #[test]
1718 fn a_resolution_of_the_wrong_kind_is_rejected_for_every_wrong_pair() {
1719 let effects = effect_samples();
1720 let successes = success_samples();
1721 for (i, effect) in effects.iter().enumerate() {
1722 let pending = kernel_effect(effect.clone());
1723 for (j, success) in successes.iter().enumerate() {
1724 let outcome = EffectOutcome::Succeeded(EffectSucceeded {
1725 result: success.clone(),
1726 });
1727 let verdict = pending.accept_outcome(&outcome);
1728 if i == j {
1729 assert!(
1730 verdict.is_ok(),
1731 "{:?} must accept its own success payload",
1732 effect.tag()
1733 );
1734 } else {
1735 let mismatch = verdict.expect_err("kind mismatch must be refused");
1736 assert_eq!(mismatch.effect_id, pending.effect_id);
1737 assert_eq!(mismatch.expected, effect.tag().expected_success());
1738 assert_eq!(mismatch.received, success.tag());
1739 }
1740 }
1741 }
1742 }
1743
1744 #[test]
1745 fn every_effect_accepts_the_same_host_failure_including_milestone() {
1746 let kinds = [
1747 HostEffectFailureKind::TransportExhausted,
1748 HostEffectFailureKind::ProtocolError,
1749 HostEffectFailureKind::StorageUnavailable,
1750 HostEffectFailureKind::PermissionDenied,
1751 HostEffectFailureKind::ResourceExhausted,
1752 HostEffectFailureKind::Unknown,
1753 ];
1754 assert_eq!(kinds.len(), 6, "§7.9 fixes six executor failure classes");
1755
1756 for effect in effect_samples() {
1757 let pending = kernel_effect(effect);
1758 for kind in kinds {
1759 let outcome = EffectOutcome::Failed(EffectFailed {
1760 failure: HostEffectFailure {
1761 kind,
1762 message: "boom".to_string(),
1763 retryable: None,
1764 },
1765 });
1766 assert!(
1767 pending.accept_outcome(&outcome).is_ok(),
1768 "{:?} must have the same failure path as every other effect",
1769 pending.effect.tag()
1770 );
1771 }
1772 }
1773 }
1774
1775 #[test]
1776 fn the_outcome_union_has_exactly_two_arms() {
1777 let arms: BTreeSet<String> = [
1778 EffectOutcome::Succeeded(EffectSucceeded {
1779 result: success_samples().remove(0),
1780 }),
1781 EffectOutcome::Failed(EffectFailed {
1782 failure: HostEffectFailure {
1783 kind: HostEffectFailureKind::Unknown,
1784 message: String::new(),
1785 retryable: None,
1786 },
1787 }),
1788 ]
1789 .iter()
1790 .map(|outcome| {
1791 serde_json::to_value(outcome).unwrap()["status"]
1792 .as_str()
1793 .unwrap()
1794 .to_string()
1795 })
1796 .collect();
1797 assert_eq!(
1798 arms,
1799 BTreeSet::from(["succeeded".to_string(), "failed".to_string()])
1800 );
1801
1802 for third in ["partial", "pending", "deferred", "succeeded_with_warnings"] {
1803 let raw = json!({ "status": third });
1804 assert!(
1805 serde_json::from_value::<EffectOutcome>(raw).is_err(),
1806 "{third} is not an outcome"
1807 );
1808 }
1809 }
1810
1811 #[test]
1812 fn the_kernel_never_retries_so_retryable_is_host_advice_only() {
1813 let with = json!({
1816 "status": "failed",
1817 "failure": { "kind": "transport_exhausted", "message": "429", "retryable": true },
1818 });
1819 let outcome: EffectOutcome = serde_json::from_value(with).unwrap();
1820 match outcome {
1821 EffectOutcome::Failed(failed) => {
1822 assert_eq!(failed.failure.retryable, Some(true));
1823 assert_eq!(
1824 failed.failure.kind,
1825 HostEffectFailureKind::TransportExhausted
1826 );
1827 }
1828 EffectOutcome::Succeeded(_) => panic!("failed outcome decoded as succeeded"),
1829 }
1830 }
1831
1832 #[test]
1842 fn every_provider_family_maps_onto_the_canonical_stop_reason_vocabulary() {
1843 let canonical: BTreeSet<&str> = ProviderStopReason::ALL
1844 .iter()
1845 .map(|reason| reason.as_str())
1846 .collect();
1847 assert_eq!(
1848 canonical,
1849 BTreeSet::from([
1850 "end_turn",
1851 "tool_use",
1852 "max_tokens",
1853 "stop_sequence",
1854 "content_filter",
1855 "other",
1856 ])
1857 );
1858 for reason in ProviderStopReason::ALL {
1859 let decoded: ProviderStopReason =
1860 serde_json::from_value(json!(reason.as_str())).unwrap();
1861 assert_eq!(decoded, reason);
1862 }
1863
1864 for vendor_word in [
1866 "stop", "length", "tool_calls", "function_call", "content_filter", "STOP", "MAX_TOKENS", "SAFETY", "FINISH_REASON_STOP", "eos", "eos_token", "sensitive", "insufficient_system_resource",
1879 ] {
1880 let decoded = serde_json::from_value::<ProviderStopReason>(json!(vendor_word));
1881 if vendor_word == "content_filter" {
1882 assert!(decoded.is_ok(), "content_filter *is* canonical");
1883 continue;
1884 }
1885 assert!(
1886 decoded.is_err(),
1887 "{vendor_word:?} is a vendor spelling; the host maps it, core never learns it"
1888 );
1889 }
1890
1891 let value = serde_json::to_value(ProviderStopReason::Other).unwrap();
1893 assert_eq!(value, json!("other"));
1894 assert!(
1895 serde_json::from_value::<ProviderStopReason>(
1896 json!({ "kind": "other", "raw": "insufficient_system_resource" })
1897 )
1898 .is_err(),
1899 "`other` is not a pass-through for vendor text"
1900 );
1901 }
1902
1903 #[test]
1910 fn no_vendor_failure_vocabulary_is_expressible_on_the_canonical_face() {
1911 assert_eq!(HostEffectFailureKind::ALL.len(), 6);
1912 let canonical: BTreeSet<&str> = HostEffectFailureKind::ALL
1913 .iter()
1914 .map(|kind| kind.as_str())
1915 .collect();
1916 assert_eq!(
1917 canonical,
1918 BTreeSet::from([
1919 "transport_exhausted",
1920 "protocol_error",
1921 "storage_unavailable",
1922 "permission_denied",
1923 "resource_exhausted",
1924 "unknown",
1925 ])
1926 );
1927
1928 for absent in [
1929 "rate_limited",
1930 "rate_limit_exceeded",
1931 "too_many_requests",
1932 "overloaded",
1933 "service_unavailable",
1934 "server_error",
1935 "timeout",
1936 "context_length_exceeded",
1937 "context_overflow",
1938 "cancelled",
1939 "canceled",
1940 "aborted",
1941 "user_interrupt",
1942 "interrupted",
1943 ] {
1944 assert!(
1945 serde_json::from_value::<HostEffectFailureKind>(json!(absent)).is_err(),
1946 "{absent:?} must not be an executor failure class"
1947 );
1948 assert!(
1949 !canonical.contains(absent),
1950 "{absent:?} leaked into the canonical vocabulary"
1951 );
1952 }
1953
1954 let spent: EffectOutcome = serde_json::from_value(json!({
1956 "status": "failed",
1957 "failure": {
1958 "kind": "transport_exhausted",
1959 "message": "5 attempts over 41s",
1960 "retryable": false,
1961 },
1962 }))
1963 .unwrap();
1964 let EffectOutcome::Failed(failed) = spent else {
1965 panic!("a spent ladder is a failure");
1966 };
1967 assert_eq!(
1968 failed.failure.kind,
1969 HostEffectFailureKind::TransportExhausted
1970 );
1971 }
1972
1973 #[test]
1978 fn retryable_is_uniformly_optional_advice_across_all_six_failure_kinds() {
1979 for kind in HostEffectFailureKind::ALL {
1980 for retryable in [None, Some(true), Some(false)] {
1981 let mut failure = json!({ "kind": kind.as_str(), "message": "boom" });
1982 if let Some(flag) = retryable {
1983 failure
1984 .as_object_mut()
1985 .unwrap()
1986 .insert("retryable".to_string(), json!(flag));
1987 }
1988 let outcome: EffectOutcome =
1989 serde_json::from_value(json!({ "status": "failed", "failure": failure }))
1990 .unwrap();
1991 let EffectOutcome::Failed(failed) = outcome else {
1992 panic!("failure decoded as success");
1993 };
1994 assert_eq!(failed.failure.kind, kind);
1995 assert_eq!(failed.failure.retryable, retryable);
1996 }
1997 }
1998 }
1999
2000 #[test]
2005 fn a_tool_result_must_state_whether_the_batch_can_continue() {
2006 assert_eq!(
2008 decode_effect(json!({
2009 "status": "succeeded",
2010 "result": { "kind": "tools", "results": [
2011 { "kind": "inline", "call_id": "c-1", "result": { "output": "ok" } }] },
2012 }))
2013 .unwrap_err()
2014 .kind,
2015 WireRejectionKind::MissingField
2016 );
2017
2018 assert_eq!(
2020 ToolResultDisposition::ALL
2021 .iter()
2022 .map(|d| d.as_str())
2023 .collect::<BTreeSet<&str>>(),
2024 BTreeSet::from(["recoverable", "fatal"])
2025 );
2026 for value in ["recoverable", "fatal"] {
2027 let decoded: ToolResultDisposition = serde_json::from_value(json!(value)).expect(value);
2028 assert_eq!(decoded.as_str(), value);
2029 assert_eq!(decoded.is_fatal(), value == "fatal");
2030 }
2031
2032 for gone in [
2036 "user_interrupt",
2037 "governance_denied",
2038 "provider_failure",
2039 "timeout",
2040 "cancelled",
2041 ] {
2042 assert!(
2043 serde_json::from_value::<ToolResultDisposition>(json!(gone)).is_err(),
2044 "{gone:?} is not a tool-result disposition"
2045 );
2046 }
2047 }
2048
2049 #[test]
2055 fn both_residency_arms_state_the_same_two_failure_facts() {
2056 let inline = ToolResultPayload::Inline(InlineToolResult {
2057 call_id: call_id("call-1"),
2058 result: ToolResult {
2059 output: "boom".to_string(),
2060 durable_content: None,
2061 is_error: true,
2062 disposition: ToolResultDisposition::Fatal,
2063 },
2064 });
2065 let external = ToolResultPayload::External(ExternalToolResult {
2066 call_id: call_id("call-2"),
2067 payload_ref: payload_ref(),
2068 digest: digest(),
2069 original_size: WireU64::new(1_048_576),
2070 preview: "Traceback (most recent call last):".to_string(),
2071 is_error: true,
2072 disposition: ToolResultDisposition::Fatal,
2073 });
2074 for payload in [&inline, &external] {
2075 assert!(payload.is_error(), "{payload:?}");
2076 assert_eq!(payload.disposition(), ToolResultDisposition::Fatal);
2077 assert!(payload.disposition().is_fatal());
2078 }
2079
2080 assert_eq!(
2083 decode_effect(json!({
2084 "status": "succeeded",
2085 "result": { "kind": "tools", "results": [
2086 { "kind": "external", "call_id": "c-1", "payload_ref": "p-1",
2087 "digest": "sha256:ab", "original_size": "1", "preview": "x" }] },
2088 }))
2089 .unwrap_err()
2090 .kind,
2091 WireRejectionKind::MissingField
2092 );
2093
2094 let ok: EffectOutcome = serde_json::from_value(json!({
2097 "status": "succeeded",
2098 "result": { "kind": "tools", "results": [
2099 { "kind": "external", "call_id": "c-1", "payload_ref": "p-1",
2100 "digest": "sha256:ab", "original_size": "1", "preview": "x",
2101 "disposition": "recoverable" }] },
2102 }))
2103 .unwrap();
2104 let EffectOutcome::Succeeded(success) = &ok else {
2105 panic!("not a success");
2106 };
2107 let EffectSuccess::Tools(tools) = &success.result else {
2108 panic!("not a tools success");
2109 };
2110 assert!(!tools.results[0].is_error());
2111 let value = serde_json::to_value(&tools.results[0]).unwrap();
2112 assert!(value.get("is_error").is_none(), "false stays off the wire");
2113 assert_eq!(value["disposition"], json!("recoverable"));
2114 }
2115
2116 #[test]
2118 fn a_milestone_request_is_exactly_the_contract_and_phase_pair() {
2119 let request = MilestoneRequest {
2120 contract_id: "brief-quality-primary".to_string(),
2121 phase_id: "collect".to_string(),
2122 };
2123 let value = serde_json::to_value(&request).unwrap();
2124 assert_eq!(
2125 value.as_object().unwrap().keys().collect::<Vec<_>>(),
2126 vec!["contract_id", "phase_id"],
2127 "a phase id is unique only inside its contract, so the pair is the whole key"
2128 );
2129
2130 for host_owned in ["criteria", "required_evidence", "verifier", "evidence"] {
2134 let mut with_extra = value.clone();
2135 with_extra
2136 .as_object_mut()
2137 .unwrap()
2138 .insert(host_owned.to_string(), json!([]));
2139 assert!(
2140 serde_json::from_value::<MilestoneRequest>(with_extra).is_err(),
2141 "{host_owned} must not decode"
2142 );
2143 }
2144 for missing in ["contract_id", "phase_id"] {
2145 let mut without = value.clone();
2146 without.as_object_mut().unwrap().remove(missing);
2147 assert!(
2148 serde_json::from_value::<MilestoneRequest>(without).is_err(),
2149 "{missing} is half the key and cannot be omitted"
2150 );
2151 }
2152 }
2153
2154 #[test]
2159 fn no_effect_outcome_payload_carries_a_host_wall_clock() {
2160 const BANNED: [&str; 8] = [
2161 "now_ms",
2162 "observed_at_ms",
2163 "timestamp",
2164 "timestamp_ms",
2165 "started_at_ms",
2166 "completed_at_ms",
2167 "wall_clock_ms",
2168 "received_at_ms",
2169 ];
2170
2171 let mut outcomes: Vec<EffectOutcome> = success_samples()
2172 .into_iter()
2173 .map(|result| EffectOutcome::Succeeded(EffectSucceeded { result }))
2174 .collect();
2175 outcomes.push(EffectOutcome::Failed(EffectFailed {
2176 failure: HostEffectFailure {
2177 kind: HostEffectFailureKind::TransportExhausted,
2178 message: "socket hang up".to_string(),
2179 retryable: Some(false),
2180 },
2181 }));
2182
2183 for outcome in outcomes {
2184 let value = serde_json::to_value(&outcome).unwrap();
2185 let mut all = BTreeSet::new();
2186 keys(&value, &mut all);
2187 for banned in BANNED {
2188 assert!(
2189 !all.contains(banned),
2190 "outcome payload must not carry {banned:?}: {value}"
2191 );
2192 }
2193 }
2194 }
2195
2196 #[test]
2201 fn an_external_tool_result_is_a_handle_with_a_digest_size_and_preview() {
2202 let external = ToolResultPayload::External(ExternalToolResult {
2203 call_id: call_id("call-2"),
2204 payload_ref: payload_ref(),
2205 digest: digest(),
2206 original_size: WireU64::new(1_048_576),
2207 preview: "total 42".to_string(),
2208 is_error: false,
2209 disposition: ToolResultDisposition::Recoverable,
2210 });
2211 let value = serde_json::to_value(&external).unwrap();
2212 assert_eq!(value["kind"], json!("external"));
2213 for required in ["payload_ref", "digest", "original_size", "preview"] {
2214 assert!(value.get(required).is_some(), "external needs {required}");
2215 }
2216 assert_eq!(
2217 value["original_size"],
2218 json!("1048576"),
2219 "sizes are decimal-string u64, not JS numbers"
2220 );
2221
2222 let mut all = BTreeSet::new();
2224 keys(&value, &mut all);
2225 for banned in ["path", "file_path", "spool_ref", "spool_dir", "archive_ref"] {
2226 assert!(!all.contains(banned), "payload ref must stay opaque");
2227 }
2228 }
2229
2230 #[test]
2231 fn payload_residency_distinguishes_generated_over_limit_from_pressure_archival() {
2232 let residencies = [
2233 PayloadResidency::Resident(ResidentPayload {}),
2234 PayloadResidency::External(ExternalResidency {
2235 payload_ref: payload_ref(),
2236 digest: digest(),
2237 original_size: WireU64::new(1_048_576),
2238 }),
2239 PayloadResidency::PagedOut(PagedOutResidency {
2240 payload_ref: payload_ref(),
2241 digest: digest(),
2242 }),
2243 PayloadResidency::Collapsed(CollapsedPayload {}),
2244 ];
2245 let tags: BTreeSet<String> = residencies
2246 .iter()
2247 .map(|residency| {
2248 serde_json::to_value(residency).unwrap()["kind"]
2249 .as_str()
2250 .unwrap()
2251 .to_string()
2252 })
2253 .collect();
2254 assert_eq!(
2255 tags,
2256 BTreeSet::from([
2257 "resident".to_string(),
2258 "external".to_string(),
2259 "paged_out".to_string(),
2260 "collapsed".to_string(),
2261 ]),
2262 "§7.10 keeps `external` (generated over limit) apart from `paged_out` (pressure)"
2263 );
2264
2265 let paged = serde_json::to_value(&residencies[2]).unwrap();
2267 assert!(paged.get("original_size").is_none());
2268 }
2269
2270 #[test]
2275 fn unknown_success_kinds_fields_and_variants_are_rejected() {
2276 assert_eq!(
2278 decode_effect(json!({ "status": "succeeded", "result": { "kind": "spooled" } }))
2279 .unwrap_err()
2280 .kind,
2281 WireRejectionKind::UnknownVariant
2282 );
2283 assert_eq!(
2285 decode_effect(json!({ "status": "failed", "failure": { "kind": "rate_limited" } }))
2286 .unwrap_err()
2287 .kind,
2288 WireRejectionKind::UnknownVariant
2289 );
2290 assert_eq!(
2292 decode_effect(json!({
2293 "status": "succeeded",
2294 "result": { "kind": "approval" },
2295 "now_ms": 1,
2296 }))
2297 .unwrap_err()
2298 .kind,
2299 WireRejectionKind::UnknownField
2300 );
2301 assert_eq!(
2303 decode_effect(json!({
2304 "status": "succeeded",
2305 "result": { "kind": "payload_loaded", "handle_id": "h-1",
2306 "payload": { "content": "x", "digest": "sha256:ab",
2307 "original_size": "1", "path": "/tmp/x" } },
2308 }))
2309 .unwrap_err()
2310 .kind,
2311 WireRejectionKind::UnknownField
2312 );
2313 assert_eq!(
2315 decode_effect(json!({
2316 "status": "succeeded",
2317 "result": { "kind": "tools", "results": [
2318 { "kind": "external", "call_id": "c-1", "payload_ref": "p-1",
2319 "original_size": "1", "preview": "x" }] },
2320 }))
2321 .unwrap_err()
2322 .kind,
2323 WireRejectionKind::MissingField
2324 );
2325 assert_eq!(
2327 decode_effect(json!({
2328 "status": "succeeded",
2329 "result": { "kind": "payload_loaded", "handle_id": "h-1",
2330 "payload": { "content": "x", "digest": "sha256:ab",
2331 "original_size": 1 } },
2332 }))
2333 .unwrap_err()
2334 .kind,
2335 WireRejectionKind::InvalidScalar
2336 );
2337 }
2338
2339 #[test]
2340 fn every_effect_and_success_payload_denies_unknown_fields() {
2341 for effect in effect_samples() {
2342 let mut value = serde_json::to_value(&effect).unwrap();
2343 value
2344 .as_object_mut()
2345 .unwrap()
2346 .insert("host_hint".to_string(), json!("x"));
2347 assert!(
2348 serde_json::from_value::<EffectKind>(value).is_err(),
2349 "{:?} must deny unknown fields",
2350 effect.tag()
2351 );
2352 }
2353 for success in success_samples() {
2354 let mut value = serde_json::to_value(&success).unwrap();
2355 value
2356 .as_object_mut()
2357 .unwrap()
2358 .insert("host_hint".to_string(), json!("x"));
2359 assert!(
2360 serde_json::from_value::<EffectSuccess>(value).is_err(),
2361 "{:?} must deny unknown fields",
2362 success.tag()
2363 );
2364 }
2365 }
2366
2367 #[test]
2372 fn resolve_effect_goldens_cover_every_success_kind_and_the_failure_path() {
2373 let mut success_kinds: BTreeSet<String> = BTreeSet::new();
2374 let mut failure_kinds: BTreeSet<String> = BTreeSet::new();
2375
2376 for (name, fixture) in fixtures_with_prefix("input_resolve_") {
2377 let text = serde_json::to_string(&fixture).unwrap();
2378 let envelope = decode_envelope_json(&text, &KernelBootstrapLimits::default())
2379 .unwrap_or_else(|e| panic!("{name}: {e}"));
2380 assert_eq!(
2381 serde_json::to_value(&envelope).unwrap(),
2382 fixture,
2383 "{name}: round-trip changed the document"
2384 );
2385
2386 let KernelInput::ResolveEffect(resolve) = &envelope.input else {
2387 panic!("{name} is not a resolve_effect golden");
2388 };
2389 match &resolve.outcome {
2390 EffectOutcome::Succeeded(ok) => {
2391 success_kinds.insert(ok.result.tag().as_str().to_string());
2392 }
2393 EffectOutcome::Failed(failed) => {
2394 failure_kinds.insert(failed.failure.kind.as_str().to_string());
2395 }
2396 }
2397 }
2398
2399 assert_eq!(
2400 success_kinds,
2401 EffectSuccessTag::ALL
2402 .iter()
2403 .map(|tag| tag.as_str().to_string())
2404 .collect::<BTreeSet<String>>(),
2405 "every effect kind needs at least one resolve golden"
2406 );
2407 assert!(
2408 !failure_kinds.is_empty(),
2409 "the unified failure path needs a golden too"
2410 );
2411 }
2412
2413 #[test]
2414 fn the_milestone_effect_has_a_failure_channel_like_every_other_effect() {
2415 let golden = fixtures_with_prefix("input_resolve_effect_milestone_failed");
2417 assert_eq!(golden.len(), 1);
2418 let text = serde_json::to_string(&golden[0].1).unwrap();
2419 let envelope = decode_envelope_json(&text, &KernelBootstrapLimits::default()).unwrap();
2420 let KernelInput::ResolveEffect(resolve) = &envelope.input else {
2421 panic!("not a resolve_effect golden");
2422 };
2423 let EffectOutcome::Failed(failed) = &resolve.outcome else {
2424 panic!("milestone failure golden must be a Failed outcome");
2425 };
2426 assert_eq!(
2427 failed.failure.kind,
2428 HostEffectFailureKind::StorageUnavailable
2429 );
2430
2431 let milestone = kernel_effect(EffectKind::EvaluateMilestone(EvaluateMilestoneEffect {
2432 request: MilestoneRequest {
2433 contract_id: "brief-quality-primary".to_string(),
2434 phase_id: "phase-2".to_string(),
2435 },
2436 }));
2437 assert!(milestone.accept_outcome(&resolve.outcome).is_ok());
2438 }
2439
2440 #[test]
2441 fn effect_rejection_goldens_cover_the_payload_failure_modes() {
2442 let mut expected: BTreeSet<String> = BTreeSet::new();
2443 for (name, fixture) in fixtures_with_prefix("reject_effect_") {
2444 let kind = fixture["expect"]
2445 .as_str()
2446 .unwrap_or_else(|| panic!("{name}: missing `expect`"));
2447 let text = serde_json::to_string(&fixture["envelope"]).unwrap();
2448 let rejection = decode_envelope_json(&text, &KernelBootstrapLimits::default())
2449 .map(|ok| panic!("{name}: expected rejection, decoded {ok:?}"))
2450 .unwrap_err();
2451 assert_eq!(
2452 rejection.kind.as_str(),
2453 kind,
2454 "{name}: {}",
2455 rejection.message
2456 );
2457 expected.insert(kind.to_string());
2458 }
2459 for required in [
2460 "unknown_field",
2461 "unknown_variant",
2462 "missing_field",
2463 "invalid_scalar",
2464 ] {
2465 assert!(
2466 expected.contains(required),
2467 "effect rejection goldens must cover {required}"
2468 );
2469 }
2470 }
2471}