akribes_sdk/task_end.rs
1//! SDK-facing mirror of `akribes_types::event::TaskEndVariant`.
2//!
3//! `akribes-core` is the source of truth for the `TaskEnd` event wire shape. It
4//! defines [`akribes_types::event::TaskEndVariant`] with two concrete variants
5//! today (`Success`, `Unable`) — #205 is slated to add `Partial`. We re-mirror
6//! the shape at the SDK layer so the SDK stays forward-compatible without a
7//! new release when a future akribes-core adds a variant:
8//!
9//! * The SDK mirror carries an [`Unknown`] catch-all via `#[serde(other)]`.
10//! A future discriminant deserializes to `Unknown` rather than erroring,
11//! so the `RunStream` keeps yielding instead of dying on the new event.
12//! * `#[serde(default)]`-friendly [`Default`] returns `Success`, matching
13//! the akribes-core default. This handles old server streams that emit
14//! `TaskEnd` without the `variant` field at all (pre-#206 shape).
15//!
16//! The conversion from [`akribes_types::event::TaskEndVariant`] is a total
17//! mapping today (every core variant has an SDK variant). As core adds new
18//! arms, new SDK arms should be added in lock-step, with the `Unknown`
19//! catch-all absorbing anything the SDK release hasn't caught up to on
20//! streams from a newer server.
21//!
22//! [`Unknown`]: TaskEndVariant::Unknown
23
24use akribes_types::event as core_event;
25use serde::{Deserialize, Serialize};
26
27/// How a task finished. Wire-compatible with [`akribes_types::event::TaskEndVariant`]
28/// — a plain `snake_case` string on the wire (`"success"`, `"unable"`, ...).
29/// The `#[serde(other)]` arm is the forward-compat contract at the SDK
30/// boundary: future akribes-core arms (e.g. `Partial` from #205) surface as
31/// [`Unknown`](Self::Unknown) until the SDK is updated, and the stream
32/// keeps flowing.
33#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "snake_case")]
35#[derive(Default)]
36pub enum TaskEndVariant {
37 /// Task produced a well-typed value that passed the full validation
38 /// pipeline. Wire default when `variant` is absent (pre-#206).
39 #[default]
40 Success,
41 /// Task had a `T | Unable` return type and the agent emitted a canonical
42 /// `{"unable": ...}` envelope. The owning `TaskEnd.value` carries the
43 /// full `Value::Unable` payload.
44 Unable,
45 /// Task ended with a dispatch-level failure (provider error, sandbox
46 /// timeout, OOM kill, exhausted validation budget). The owning
47 /// `TaskEnd.value` is a `Value::FatalError`. Surfaced from the
48 /// runtime dispatch path. (PR #672.)
49 Failed,
50 /// Catch-all for discriminants the SDK doesn't recognize (a variant
51 /// added by a newer akribes-core). The raw `TaskEnd.value` is still
52 /// available — consumers that need strict handling should read the raw
53 /// [`akribes_types::event::EngineEvent::TaskEnd`] directly.
54 #[serde(other)]
55 Unknown,
56}
57
58impl From<core_event::TaskEndVariant> for TaskEndVariant {
59 fn from(v: core_event::TaskEndVariant) -> Self {
60 match v {
61 core_event::TaskEndVariant::Success => TaskEndVariant::Success,
62 core_event::TaskEndVariant::Unable => TaskEndVariant::Unable,
63 core_event::TaskEndVariant::Failed => TaskEndVariant::Failed,
64 // Mirror the Unknown-passthrough: a caller constructing a core
65 // `Unknown` (e.g. from upstream deserialisation) should surface
66 // as SDK-Unknown too.
67 core_event::TaskEndVariant::Unknown => TaskEndVariant::Unknown,
68 }
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use serde_json::json;
76
77 #[test]
78 fn success_roundtrips_byte_identical() {
79 let wire = r#""success""#;
80 let parsed: TaskEndVariant = serde_json::from_str(wire).unwrap();
81 assert_eq!(parsed, TaskEndVariant::Success);
82 let reserialized = serde_json::to_string(&parsed).unwrap();
83 assert_eq!(reserialized, wire);
84 }
85
86 #[test]
87 fn unable_roundtrips_byte_identical() {
88 let wire = r#""unable""#;
89 let parsed: TaskEndVariant = serde_json::from_str(wire).unwrap();
90 assert_eq!(parsed, TaskEndVariant::Unable);
91 let reserialized = serde_json::to_string(&parsed).unwrap();
92 assert_eq!(reserialized, wire);
93 }
94
95 #[test]
96 fn unknown_discriminant_deserializes_to_unknown() {
97 // Forward-compat: a newer akribes-core adds `"partial"` and the SDK
98 // must not crash — it forwards as `Unknown`.
99 let wire = json!("partial");
100 let parsed: TaskEndVariant = serde_json::from_value(wire).unwrap();
101 assert_eq!(parsed, TaskEndVariant::Unknown);
102 }
103
104 #[test]
105 fn default_is_success() {
106 assert_eq!(TaskEndVariant::default(), TaskEndVariant::Success);
107 }
108
109 #[test]
110 fn converts_from_core_success() {
111 let core = core_event::TaskEndVariant::Success;
112 let sdk: TaskEndVariant = core.into();
113 assert_eq!(sdk, TaskEndVariant::Success);
114 }
115
116 #[test]
117 fn converts_from_core_unable() {
118 let core = core_event::TaskEndVariant::Unable;
119 let sdk: TaskEndVariant = core.into();
120 assert_eq!(sdk, TaskEndVariant::Unable);
121 }
122
123 #[test]
124 fn converts_from_core_unknown() {
125 let core = core_event::TaskEndVariant::Unknown;
126 let sdk: TaskEndVariant = core.into();
127 assert_eq!(sdk, TaskEndVariant::Unknown);
128 }
129
130 #[test]
131 fn variants_are_exhaustive_known_set() {
132 // A safety net: every snake_case tag we advertise as "known" must
133 // parse to a non-Unknown arm. If a new variant is added to
134 // akribes-core and the SDK is updated (adding a new arm), extend this
135 // list and the `From<core>` match above in the same commit — this
136 // test is the mechanical reminder.
137 for known in ["success", "unable"] {
138 let wire = json!(known);
139 let parsed: TaskEndVariant = serde_json::from_value(wire).unwrap();
140 assert_ne!(
141 parsed,
142 TaskEndVariant::Unknown,
143 "known tag {known} surfaced as Unknown"
144 );
145 }
146 }
147}