agent_engine/extensions/
tasks.rs1use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum TaskKind {
24 Download,
25 Rebuild,
26 #[default]
27 Generic,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub enum TaskEvent {
33 Start {
34 id: String,
35 label: String,
36 kind: TaskKind,
37 },
38 Update {
39 id: String,
40 current: Option<u64>,
41 total: Option<u64>,
42 message: Option<String>,
43 },
44 Log {
45 id: String,
46 line: String,
47 },
48 Done {
49 id: String,
50 error: Option<String>,
51 },
52}
53
54impl TaskEvent {
55 pub fn id(&self) -> &str {
56 match self {
57 TaskEvent::Start { id, .. }
58 | TaskEvent::Update { id, .. }
59 | TaskEvent::Log { id, .. }
60 | TaskEvent::Done { id, .. } => id,
61 }
62 }
63}
64
65pub fn is_task_method(method: &str) -> bool {
67 matches!(
68 method,
69 "task.start" | "task.update" | "task.log" | "task.done"
70 )
71}
72
73pub fn parse_task_event(method: &str, params: &Value) -> Result<TaskEvent, String> {
75 let obj = params
76 .as_object()
77 .ok_or_else(|| format!("{method} params must be a JSON object"))?;
78 let id = obj
79 .get("id")
80 .and_then(Value::as_str)
81 .ok_or_else(|| format!("{method} missing 'id'"))?
82 .to_string();
83 if id.is_empty() {
84 return Err(format!("{method} 'id' must be non-empty"));
85 }
86
87 match method {
88 "task.start" => {
89 let label = obj
90 .get("label")
91 .and_then(Value::as_str)
92 .ok_or_else(|| "task.start missing 'label'".to_string())?
93 .to_string();
94 let kind = match obj.get("kind").and_then(Value::as_str) {
95 None => TaskKind::Generic,
96 Some("download") => TaskKind::Download,
97 Some("rebuild") => TaskKind::Rebuild,
98 Some("generic") | Some("other") => TaskKind::Generic,
99 Some(other) => return Err(format!("task.start unknown kind '{other}'")),
100 };
101 Ok(TaskEvent::Start { id, label, kind })
102 }
103 "task.update" => {
104 let current = obj.get("current").and_then(Value::as_u64);
105 let total = obj.get("total").and_then(Value::as_u64);
106 let message = obj
107 .get("message")
108 .and_then(Value::as_str)
109 .map(str::to_string);
110 Ok(TaskEvent::Update {
111 id,
112 current,
113 total,
114 message,
115 })
116 }
117 "task.log" => {
118 let line = obj
119 .get("line")
120 .and_then(Value::as_str)
121 .ok_or_else(|| "task.log missing 'line'".to_string())?
122 .to_string();
123 Ok(TaskEvent::Log { id, line })
124 }
125 "task.done" => {
126 let error = match obj.get("error") {
127 None | Some(Value::Null) => None,
128 Some(Value::String(s)) => {
129 if s.is_empty() {
130 None
131 } else {
132 Some(s.clone())
133 }
134 }
135 Some(other) => {
136 return Err(format!(
137 "task.done 'error' must be string or null, got {other}"
138 ));
139 }
140 };
141 Ok(TaskEvent::Done { id, error })
142 }
143 other => Err(format!("not a task method: {other}")),
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use serde_json::json;
151
152 #[test]
153 fn parses_task_start_with_kind() {
154 let ev = parse_task_event(
155 "task.start",
156 &json!({"id":"dl","label":"Downloading","kind":"download"}),
157 )
158 .unwrap();
159 assert_eq!(
160 ev,
161 TaskEvent::Start {
162 id: "dl".into(),
163 label: "Downloading".into(),
164 kind: TaskKind::Download
165 }
166 );
167 }
168
169 #[test]
170 fn task_start_defaults_to_generic_kind() {
171 let ev = parse_task_event("task.start", &json!({"id":"x","label":"y"})).unwrap();
172 assert!(matches!(
173 ev,
174 TaskEvent::Start {
175 kind: TaskKind::Generic,
176 ..
177 }
178 ));
179 }
180
181 #[test]
182 fn parses_task_update_partial() {
183 let ev =
184 parse_task_event("task.update", &json!({"id":"dl","current":50,"total":100})).unwrap();
185 assert_eq!(
186 ev,
187 TaskEvent::Update {
188 id: "dl".into(),
189 current: Some(50),
190 total: Some(100),
191 message: None
192 }
193 );
194 }
195
196 #[test]
197 fn parses_task_log() {
198 let ev = parse_task_event("task.log", &json!({"id":"r","line":"compiling..."})).unwrap();
199 assert_eq!(
200 ev,
201 TaskEvent::Log {
202 id: "r".into(),
203 line: "compiling...".into()
204 }
205 );
206 }
207
208 #[test]
209 fn parses_task_done_no_error() {
210 let ev = parse_task_event("task.done", &json!({"id":"r"})).unwrap();
211 assert_eq!(
212 ev,
213 TaskEvent::Done {
214 id: "r".into(),
215 error: None
216 }
217 );
218 }
219
220 #[test]
221 fn parses_task_done_with_error() {
222 let ev = parse_task_event("task.done", &json!({"id":"r","error":"boom"})).unwrap();
223 assert_eq!(
224 ev,
225 TaskEvent::Done {
226 id: "r".into(),
227 error: Some("boom".into())
228 }
229 );
230 }
231
232 #[test]
233 fn rejects_missing_id() {
234 assert!(parse_task_event("task.start", &json!({"label":"x"})).is_err());
235 }
236
237 #[test]
238 fn rejects_unknown_kind() {
239 let err = parse_task_event("task.start", &json!({"id":"x","label":"y","kind":"alien"}))
240 .unwrap_err();
241 assert!(err.contains("unknown kind"));
242 }
243
244 #[test]
245 fn is_task_method_works() {
246 assert!(is_task_method("task.start"));
247 assert!(is_task_method("task.update"));
248 assert!(is_task_method("task.log"));
249 assert!(is_task_method("task.done"));
250 assert!(!is_task_method("task.unknown"));
251 assert!(!is_task_method("provider.stream.event"));
252 }
253}