Skip to main content

jules_core/
activity.rs

1//! Activity module.
2
3use serde::{Deserialize, Serialize};
4use std::error::Error;
5use std::fmt;
6
7/// An error that can occur when building an [`Activity`].
8#[derive(Debug)]
9pub struct ActivityBuildError(String);
10
11impl fmt::Display for ActivityBuildError {
12    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13        write!(f, "Activity build error: {}", self.0)
14    }
15}
16
17impl Error for ActivityBuildError {}
18
19/// A single step within a generated [`Plan`].
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
21#[serde(rename_all = "camelCase")]
22pub struct PlanStep {
23    #[serde(skip_serializing_if = "Option::is_none")]
24    id: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    title: Option<String>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    description: Option<String>,
29}
30
31impl PlanStep {
32    /// Returns the step's id, if configured.
33    #[must_use]
34    pub fn id(&self) -> Option<&str> {
35        self.id.as_deref()
36    }
37
38    /// Returns the step's title, if configured.
39    #[must_use]
40    pub fn title(&self) -> Option<&str> {
41        self.title.as_deref()
42    }
43
44    /// Returns the step's description, if configured.
45    #[must_use]
46    pub fn description(&self) -> Option<&str> {
47        self.description.as_deref()
48    }
49}
50
51/// A plan generated by the Jules agent, consisting of an ordered list of steps.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
53#[serde(rename_all = "camelCase")]
54pub struct Plan {
55    #[serde(skip_serializing_if = "Option::is_none")]
56    id: Option<String>,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    steps: Vec<PlanStep>,
59}
60
61impl Plan {
62    /// Returns the plan's id, if configured.
63    #[must_use]
64    pub fn id(&self) -> Option<&str> {
65        self.id.as_deref()
66    }
67
68    /// Returns the plan's steps.
69    #[must_use]
70    pub fn steps(&self) -> &[PlanStep] {
71        &self.steps
72    }
73}
74
75/// The `planGenerated` activity detail: emitted when the agent has produced a [`Plan`].
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
77#[serde(rename_all = "camelCase")]
78pub struct PlanGenerated {
79    #[serde(skip_serializing_if = "Option::is_none")]
80    plan: Option<Plan>,
81}
82
83impl PlanGenerated {
84    /// Returns the generated plan, if present.
85    #[must_use]
86    pub fn plan(&self) -> Option<&Plan> {
87        self.plan.as_ref()
88    }
89}
90
91/// Represents an activity within a session (e.g. a plan being generated, a message, or a
92/// progress update).
93///
94/// Field shape matches the real `v1alpha` Jules API `Activity` resource (verified against the
95/// live API's `GET /v1alpha/{session}/activities` response on 2026-08-08). Only the
96/// `planGenerated` activity kind was observed live; other kinds (e.g. messages, progress
97/// updates, completion events) are UNVERIFIED and preserved via `extra` rather than dropped, so
98/// no activity data is silently lost for kinds this model doesn't explicitly know about yet.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
100#[serde(rename_all = "camelCase")]
101pub struct Activity {
102    #[serde(skip_serializing_if = "Option::is_none")]
103    id: Option<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    name: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    create_time: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    originator: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    plan_generated: Option<PlanGenerated>,
112    /// Any activity fields not explicitly modeled above (e.g. other activity kinds), preserved
113    /// verbatim rather than dropped.
114    #[serde(flatten)]
115    extra: serde_json::Map<String, serde_json::Value>,
116}
117
118impl Activity {
119    /// Creates a new [`ActivityBuilder`] to construct an [`Activity`].
120    #[must_use]
121    pub fn builder() -> ActivityBuilder {
122        ActivityBuilder::default()
123    }
124
125    /// Returns the id of the activity, if configured.
126    #[must_use]
127    pub fn id(&self) -> Option<&str> {
128        self.id.as_deref()
129    }
130
131    /// Returns the name of the activity, if configured.
132    #[must_use]
133    pub fn name(&self) -> Option<&str> {
134        self.name.as_deref()
135    }
136
137    /// Returns the activity's creation timestamp (RFC 3339), if configured.
138    #[must_use]
139    pub fn create_time(&self) -> Option<&str> {
140        self.create_time.as_deref()
141    }
142
143    /// Returns who/what originated the activity (e.g. `AGENT`, `USER`), if configured.
144    #[must_use]
145    pub fn originator(&self) -> Option<&str> {
146        self.originator.as_deref()
147    }
148
149    /// Returns the `planGenerated` detail, if this activity is a plan-generation event.
150    #[must_use]
151    pub fn plan_generated(&self) -> Option<&PlanGenerated> {
152        self.plan_generated.as_ref()
153    }
154
155    /// Returns any activity fields not explicitly modeled (other activity kinds), keyed by
156    /// their raw JSON field name.
157    #[must_use]
158    pub fn extra(&self) -> &serde_json::Map<String, serde_json::Value> {
159        &self.extra
160    }
161}
162
163/// A builder for constructing an [`Activity`].
164#[derive(Debug, Default)]
165pub struct ActivityBuilder {
166    id: Option<String>,
167    name: Option<String>,
168    create_time: Option<String>,
169    originator: Option<String>,
170    plan_generated: Option<PlanGenerated>,
171}
172
173impl ActivityBuilder {
174    /// Sets the id for the activity.
175    #[must_use]
176    pub fn id(mut self, id: impl Into<String>) -> Self {
177        self.id = Some(id.into());
178        self
179    }
180
181    /// Sets the name for the activity.
182    #[must_use]
183    pub fn name(mut self, name: impl Into<String>) -> Self {
184        self.name = Some(name.into());
185        self
186    }
187
188    /// Sets the creation timestamp for the activity.
189    #[must_use]
190    pub fn create_time(mut self, create_time: impl Into<String>) -> Self {
191        self.create_time = Some(create_time.into());
192        self
193    }
194
195    /// Sets the originator for the activity.
196    #[must_use]
197    pub fn originator(mut self, originator: impl Into<String>) -> Self {
198        self.originator = Some(originator.into());
199        self
200    }
201
202    /// Builds the [`Activity`] from the provided configuration.
203    ///
204    /// # Errors
205    ///
206    /// Returns an [`ActivityBuildError`] if the activity cannot be built from the provided configuration.
207    pub fn build(self) -> Result<Activity, ActivityBuildError> {
208        Ok(Activity {
209            id: self.id,
210            name: self.name,
211            create_time: self.create_time,
212            originator: self.originator,
213            plan_generated: self.plan_generated,
214            extra: serde_json::Map::new(),
215        })
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_activity_builder_with_fields() {
225        let activity = Activity::builder()
226            .id("act-1")
227            .name("My Activity")
228            .build()
229            .unwrap();
230        assert_eq!(activity.id(), Some("act-1"));
231        assert_eq!(activity.name(), Some("My Activity"));
232    }
233
234    #[test]
235    fn test_activity_builder_without_fields() {
236        let activity = Activity::builder().build().unwrap();
237        assert_eq!(activity.id(), None);
238        assert_eq!(activity.name(), None);
239    }
240
241    /// Deserializes a payload shaped like the real `v1alpha` API response, proving the
242    /// `camelCase` wire format round-trips correctly into this `snake_case` model.
243    #[test]
244    fn test_activity_deserializes_real_api_shape() {
245        let json = r#"{
246            "name": "sessions/000/activities/001",
247            "createTime": "2026-08-08T12:42:12Z",
248            "originator": "AGENT",
249            "planGenerated": {
250                "plan": {
251                    "id": "plan-1",
252                    "steps": [
253                        {"id": "step-1", "title": "Investigate", "description": "Look around"}
254                    ]
255                }
256            },
257            "id": "001"
258        }"#;
259
260        let activity: Activity = serde_json::from_str(json).unwrap();
261        assert_eq!(activity.originator(), Some("AGENT"));
262        let plan = activity
263            .plan_generated()
264            .and_then(PlanGenerated::plan)
265            .unwrap();
266        assert_eq!(plan.steps().len(), 1);
267        assert_eq!(plan.steps()[0].title(), Some("Investigate"));
268    }
269
270    /// An activity kind not explicitly modeled must still round-trip via `extra`, proving
271    /// unknown activity kinds aren't silently dropped.
272    #[test]
273    fn test_activity_preserves_unknown_kind_via_extra() {
274        let json = r#"{
275            "name": "sessions/000/activities/002",
276            "createTime": "2026-08-08T12:42:12Z",
277            "originator": "USER",
278            "id": "002",
279            "userMessaged": {
280                "message": "hello"
281            }
282        }"#;
283
284        let activity: Activity = serde_json::from_str(json).unwrap();
285        assert!(activity.plan_generated().is_none());
286        assert!(activity.extra().contains_key("userMessaged"));
287        assert_eq!(
288            activity.extra()["userMessaged"]["message"],
289            serde_json::Value::String("hello".to_string())
290        );
291    }
292}