Skip to main content

agent_engine/extensions/
tasks.rs

1//! Plugin long-running task notification types and parser.
2//!
3//! Phase B Phase 3 contract — see
4//! `docs/plans/2026-05-03-extension-contracts-for-rich-plugins.md`.
5//!
6//! Plugins push spontaneous JSON-RPC notifications for long-running tasks
7//! (downloads, rebuilds, indexing) outside the slash-command request/response
8//! cycle. Method names: `task.start`, `task.update`, `task.log`, `task.done`.
9//!
10//! Wire shapes (params per method):
11//!
12//! - `task.start`:  `{ id, label, kind: "download"|"rebuild"|"generic" }`
13//! - `task.update`: `{ id, current?: u64, total?: u64, message?: string }`
14//! - `task.log`:    `{ id, line }`
15//! - `task.done`:   `{ id, error?: string|null }`
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20/// Coarse classification of a task; affects how it's rendered.
21#[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/// Parsed task notification.
31#[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
65/// Returns true if `method` is one of the recognised task notifications.
66pub fn is_task_method(method: &str) -> bool {
67    matches!(method, "task.start" | "task.update" | "task.log" | "task.done")
68}
69
70/// Parse a `task.*` notification given the JSON-RPC method and params.
71pub fn parse_task_event(method: &str, params: &Value) -> Result<TaskEvent, String> {
72    let obj = params
73        .as_object()
74        .ok_or_else(|| format!("{method} params must be a JSON object"))?;
75    let id = obj
76        .get("id")
77        .and_then(Value::as_str)
78        .ok_or_else(|| format!("{method} missing 'id'"))?
79        .to_string();
80    if id.is_empty() {
81        return Err(format!("{method} 'id' must be non-empty"));
82    }
83
84    match method {
85        "task.start" => {
86            let label = obj
87                .get("label")
88                .and_then(Value::as_str)
89                .ok_or_else(|| "task.start missing 'label'".to_string())?
90                .to_string();
91            let kind = match obj.get("kind").and_then(Value::as_str) {
92                None => TaskKind::Generic,
93                Some("download") => TaskKind::Download,
94                Some("rebuild") => TaskKind::Rebuild,
95                Some("generic") | Some("other") => TaskKind::Generic,
96                Some(other) => return Err(format!("task.start unknown kind '{other}'")),
97            };
98            Ok(TaskEvent::Start { id, label, kind })
99        }
100        "task.update" => {
101            let current = obj.get("current").and_then(Value::as_u64);
102            let total = obj.get("total").and_then(Value::as_u64);
103            let message = obj
104                .get("message")
105                .and_then(Value::as_str)
106                .map(str::to_string);
107            Ok(TaskEvent::Update {
108                id,
109                current,
110                total,
111                message,
112            })
113        }
114        "task.log" => {
115            let line = obj
116                .get("line")
117                .and_then(Value::as_str)
118                .ok_or_else(|| "task.log missing 'line'".to_string())?
119                .to_string();
120            Ok(TaskEvent::Log { id, line })
121        }
122        "task.done" => {
123            let error = match obj.get("error") {
124                None | Some(Value::Null) => None,
125                Some(Value::String(s)) => {
126                    if s.is_empty() {
127                        None
128                    } else {
129                        Some(s.clone())
130                    }
131                }
132                Some(other) => {
133                    return Err(format!("task.done 'error' must be string or null, got {other}"));
134                }
135            };
136            Ok(TaskEvent::Done { id, error })
137        }
138        other => Err(format!("not a task method: {other}")),
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use serde_json::json;
146
147    #[test]
148    fn parses_task_start_with_kind() {
149        let ev = parse_task_event(
150            "task.start",
151            &json!({"id":"dl","label":"Downloading","kind":"download"}),
152        )
153        .unwrap();
154        assert_eq!(
155            ev,
156            TaskEvent::Start {
157                id: "dl".into(),
158                label: "Downloading".into(),
159                kind: TaskKind::Download
160            }
161        );
162    }
163
164    #[test]
165    fn task_start_defaults_to_generic_kind() {
166        let ev = parse_task_event("task.start", &json!({"id":"x","label":"y"})).unwrap();
167        assert!(matches!(
168            ev,
169            TaskEvent::Start { kind: TaskKind::Generic, .. }
170        ));
171    }
172
173    #[test]
174    fn parses_task_update_partial() {
175        let ev = parse_task_event(
176            "task.update",
177            &json!({"id":"dl","current":50,"total":100}),
178        )
179        .unwrap();
180        assert_eq!(
181            ev,
182            TaskEvent::Update {
183                id: "dl".into(),
184                current: Some(50),
185                total: Some(100),
186                message: None
187            }
188        );
189    }
190
191    #[test]
192    fn parses_task_log() {
193        let ev = parse_task_event("task.log", &json!({"id":"r","line":"compiling..."})).unwrap();
194        assert_eq!(
195            ev,
196            TaskEvent::Log { id: "r".into(), line: "compiling...".into() }
197        );
198    }
199
200    #[test]
201    fn parses_task_done_no_error() {
202        let ev = parse_task_event("task.done", &json!({"id":"r"})).unwrap();
203        assert_eq!(ev, TaskEvent::Done { id: "r".into(), error: None });
204    }
205
206    #[test]
207    fn parses_task_done_with_error() {
208        let ev = parse_task_event("task.done", &json!({"id":"r","error":"boom"})).unwrap();
209        assert_eq!(
210            ev,
211            TaskEvent::Done {
212                id: "r".into(),
213                error: Some("boom".into())
214            }
215        );
216    }
217
218    #[test]
219    fn rejects_missing_id() {
220        assert!(parse_task_event("task.start", &json!({"label":"x"})).is_err());
221    }
222
223    #[test]
224    fn rejects_unknown_kind() {
225        let err = parse_task_event(
226            "task.start",
227            &json!({"id":"x","label":"y","kind":"alien"}),
228        )
229        .unwrap_err();
230        assert!(err.contains("unknown kind"));
231    }
232
233    #[test]
234    fn is_task_method_works() {
235        assert!(is_task_method("task.start"));
236        assert!(is_task_method("task.update"));
237        assert!(is_task_method("task.log"));
238        assert!(is_task_method("task.done"));
239        assert!(!is_task_method("task.unknown"));
240        assert!(!is_task_method("provider.stream.event"));
241    }
242}