1use super::children::ChildKind;
19use super::reactor::Runtime;
20use crate::state::now_ms;
21use crate::supervisor::tree::NodeId;
22use serde_json::{Value, json};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Phase {
27 Thinking,
29 Tool,
31 Waiting,
33}
34
35impl Phase {
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Phase::Thinking => "thinking",
39 Phase::Tool => "tool",
40 Phase::Waiting => "waiting",
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct Activity {
48 pub task: Option<String>,
50 pub ctx: Option<String>,
52 pub phase: Phase,
53 pub tool: Option<String>,
55 pub round: u32,
57 pub tokens_in: u64,
59 pub tokens_out: u64,
60 pub started_ms: u64,
62 pub updated_ms: u64,
63}
64
65impl Activity {
66 fn new(task: Option<String>, ctx: Option<String>) -> Activity {
67 let now = now_ms();
68 Activity {
69 task,
70 ctx,
71 phase: Phase::Thinking,
72 tool: None,
73 round: 0,
74 tokens_in: 0,
75 tokens_out: 0,
76 started_ms: now,
77 updated_ms: now,
78 }
79 }
80
81 pub fn apply(&mut self, event: &str, fields: &Value) -> bool {
85 let before = (self.phase, self.tool.clone(), self.round);
86 match event {
87 "turn.think" => {
88 self.phase = Phase::Thinking;
89 self.tool = None;
90 if let Some(r) = fields["round"].as_u64() {
91 self.round = r as u32;
92 }
93 }
94 "turn.round" => {
95 if let Some(r) = fields["round"].as_u64() {
96 self.round = r as u32;
97 }
98 self.tokens_in += fields["tokens_in"].as_u64().unwrap_or(0);
99 self.tokens_out += fields["tokens_out"].as_u64().unwrap_or(0);
100 self.phase = Phase::Thinking;
102 self.tool = None;
103 }
104 "turn.tool" => {
105 self.phase = Phase::Tool;
106 self.tool = fields["tool"].as_str().map(str::to_string);
107 }
108 _ => return false,
109 }
110 self.updated_ms = now_ms();
111 (self.phase, self.tool.clone(), self.round) != before
112 }
113
114 pub fn park(&mut self, what: &str) -> bool {
116 let changed = self.phase != Phase::Waiting || self.tool.as_deref() != Some(what);
117 self.phase = Phase::Waiting;
118 self.tool = Some(what.to_string());
119 self.updated_ms = now_ms();
120 changed
121 }
122
123 pub fn to_value(&self) -> Value {
124 json!({
125 "task": self.task,
126 "ctx": self.ctx,
127 "phase": self.phase.as_str(),
128 "tool": self.tool,
129 "round": self.round,
130 "tokens_in": self.tokens_in,
131 "tokens_out": self.tokens_out,
132 "started_ms": self.started_ms,
133 "updated_ms": self.updated_ms,
134 })
135 }
136}
137
138impl Runtime {
139 pub(crate) fn on_child_progress(&mut self, node: NodeId, event: &str, fields: &Value) {
142 let (task, ctx) = self.unit_of(node);
143 let entry = self
144 .activity
145 .entry(node.0)
146 .or_insert_with(|| Activity::new(task.clone(), ctx.clone()));
147 if entry.task.is_none() && task.is_some() {
149 entry.task = task;
150 }
151 if entry.apply(event, fields) {
152 let v = entry.to_value();
153 self.publish_activity(node, v);
154 }
155 }
156
157 pub(crate) fn activity_park(&mut self, node: NodeId, what: &str) {
159 let Some(entry) = self.activity.get_mut(&node.0) else {
160 return;
161 };
162 if entry.park(what) {
163 let v = entry.to_value();
164 self.publish_activity(node, v);
165 }
166 }
167
168 pub(crate) fn activity_end(&mut self, node: NodeId) {
170 if self.activity.remove(&node.0).is_some() {
171 #[cfg(feature = "a2a")]
172 self.feed_push(
173 "activity.removed",
174 crate::runtime::a2a_server::FeedVis::Operator,
175 json!({"id": node.0.to_string()}),
176 );
177 }
178 }
179
180 fn unit_of(&self, node: NodeId) -> (Option<String>, Option<String>) {
184 match self.children.get(node).map(|c| c.kind.clone()) {
185 Some(ChildKind::RootTurn { ctx, event, .. }) => {
186 #[cfg(feature = "a2a")]
187 let task = event.and_then(|e| self.event_to_task.get(&e).cloned());
188 #[cfg(not(feature = "a2a"))]
189 let task = {
190 let _ = event;
191 None
192 };
193 (task, Some(ctx))
194 }
195 Some(ChildKind::StepTurn { run, .. }) => {
196 let task = self.runs.get(&run).and_then(|r| r.task.clone());
197 (
198 task,
199 self.runs.get(&run).and_then(|r| r.conversation.clone()),
200 )
201 }
202 _ => (None, None),
203 }
204 }
205
206 #[allow(unused_mut, unused_variables)]
209 fn publish_activity(&self, node: NodeId, mut v: Value) {
210 #[cfg(feature = "a2a")]
211 {
212 v["id"] = json!(node.0.to_string());
213 let owner = v["task"]
215 .as_str()
216 .and_then(|t| self.tasks.get(t))
217 .and_then(|t| t.principal.clone());
218 let vis = match owner {
219 Some(p) => crate::runtime::a2a_server::FeedVis::Owner(Some(p)),
220 None => crate::runtime::a2a_server::FeedVis::Operator,
221 };
222 self.feed_push("activity", vis, v);
223 }
224 }
225
226 pub(crate) fn activity_value(&self) -> Value {
229 json!(
230 self.activity
231 .iter()
232 .map(|(node, a)| {
233 let mut v = a.to_value();
234 v["id"] = json!(node.to_string());
235 v
236 })
237 .collect::<Vec<_>>()
238 )
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn activity_folds_progress_and_reports_only_notable_changes() {
248 let mut a = Activity::new(Some("task-1".into()), Some("c1".into()));
249 assert_eq!(a.phase, Phase::Thinking);
250 assert!(a.apply("turn.think", &json!({"round": 1})));
252 assert_eq!(a.round, 1);
253 assert!(!a.apply("turn.think", &json!({"round": 1})));
255 assert!(!a.apply(
258 "turn.round",
259 &json!({"round": 1, "tokens_in": 100, "tokens_out": 20})
260 ));
261 assert_eq!((a.tokens_in, a.tokens_out), (100, 20));
262 assert!(a.apply("turn.tool", &json!({"tool": "read_file"})));
264 assert_eq!(a.phase, Phase::Tool);
265 assert_eq!(a.tool.as_deref(), Some("read_file"));
266 assert!(a.apply("turn.tool", &json!({"tool": "memory.set"})));
268 assert!(a.apply(
270 "turn.round",
271 &json!({"round": 2, "tokens_in": 50, "tokens_out": 10})
272 ));
273 assert_eq!(a.phase, Phase::Thinking);
274 assert_eq!(a.tool, None);
275 assert_eq!((a.tokens_in, a.tokens_out), (150, 30));
276 assert!(a.park("subagent"));
278 assert!(!a.park("subagent"));
279 assert_eq!(a.phase, Phase::Waiting);
280 assert!(!a.apply("something.else", &json!({})));
282 let v = a.to_value();
283 assert_eq!(v["phase"], "waiting");
284 assert_eq!(v["task"], "task-1");
285 assert!(v["started_ms"].as_u64().is_some_and(|t| t > 0));
286 }
287}