@@ -92,6 +92,60 @@ fn compact_task_output(output: &str) -> (String, bool) {
9292 )
9393}
9494
95+ /// Translate selected child-loop events into a `SubagentProgress` milestone
96+ /// for the parent broadcast. Returns `None` for events that aren't worth
97+ /// surfacing as progress (text deltas, tool starts, subagent events from
98+ /// nested delegation, etc.).
99+ ///
100+ /// Currently emits progress for:
101+ /// - `ToolEnd` → `status = "tool_completed"`,
102+ /// `metadata = { tool, exit_code, output_bytes, error_kind? }`
103+ /// - `TurnEnd` → `status = "turn_completed"`,
104+ /// `metadata = { turn, total_tokens, prompt_tokens, completion_tokens }`
105+ fn synthesize_subagent_progress (
106+ event : & AgentEvent ,
107+ task_id : & str ,
108+ session_id : & str ,
109+ ) -> Option < AgentEvent > {
110+ match event {
111+ AgentEvent :: ToolEnd {
112+ name,
113+ output,
114+ exit_code,
115+ error_kind,
116+ ..
117+ } => {
118+ let mut metadata = serde_json:: json!( {
119+ "tool" : name,
120+ "exit_code" : exit_code,
121+ "output_bytes" : output. len( ) ,
122+ } ) ;
123+ if let Some ( kind) = error_kind {
124+ metadata[ "error_kind" ] =
125+ serde_json:: to_value ( kind) . unwrap_or ( serde_json:: Value :: Null ) ;
126+ }
127+ Some ( AgentEvent :: SubagentProgress {
128+ task_id : task_id. to_string ( ) ,
129+ session_id : session_id. to_string ( ) ,
130+ status : "tool_completed" . to_string ( ) ,
131+ metadata,
132+ } )
133+ }
134+ AgentEvent :: TurnEnd { turn, usage } => Some ( AgentEvent :: SubagentProgress {
135+ task_id : task_id. to_string ( ) ,
136+ session_id : session_id. to_string ( ) ,
137+ status : "turn_completed" . to_string ( ) ,
138+ metadata : serde_json:: json!( {
139+ "turn" : turn,
140+ "total_tokens" : usage. total_tokens,
141+ "prompt_tokens" : usage. prompt_tokens,
142+ "completion_tokens" : usage. completion_tokens,
143+ } ) ,
144+ } ) ,
145+ _ => None ,
146+ }
147+ }
148+
95149fn task_artifact_id ( result : & TaskResult ) -> String {
96150 format ! ( "task-output:{}" , result. task_id)
97151}
@@ -300,14 +354,25 @@ impl TaskExecutor {
300354 child_config,
301355 ) ;
302356
303- // Create an mpsc channel for the child agent and forward events to broadcast
357+ // Create an mpsc channel for the child agent and forward events to broadcast.
358+ // Selected child events (ToolEnd, TurnEnd) are also surfaced to the parent
359+ // broadcast as synthetic `SubagentProgress` events so dashboards can observe
360+ // mid-task milestones without subscribing to the raw event stream.
304361 let child_event_tx = if let Some ( ref broadcast_tx) = event_tx {
305362 let ( mpsc_tx, mut mpsc_rx) = tokio:: sync:: mpsc:: channel ( 100 ) ;
306363 let broadcast_tx_clone = broadcast_tx. clone ( ) ;
364+ let progress_task_id = task_id. clone ( ) ;
365+ let progress_session_id = session_id. clone ( ) ;
307366
308- // Spawn a task to forward events from mpsc to broadcast
309367 tokio:: spawn ( async move {
310368 while let Some ( event) = mpsc_rx. recv ( ) . await {
369+ if let Some ( progress) = synthesize_subagent_progress (
370+ & event,
371+ & progress_task_id,
372+ & progress_session_id,
373+ ) {
374+ let _ = broadcast_tx_clone. send ( progress) ;
375+ }
311376 let _ = broadcast_tx_clone. send ( event) ;
312377 }
313378 } ) ;
@@ -1962,10 +2027,12 @@ mod tests {
19622027
19632028 let mut starts = Vec :: new ( ) ;
19642029 let mut ends = Vec :: new ( ) ;
2030+ let mut progress_statuses: Vec < String > = Vec :: new ( ) ;
19652031 while let Ok ( event) = rx. try_recv ( ) {
19662032 match event {
19672033 AgentEvent :: SubagentStart { description, .. } => starts. push ( description) ,
19682034 AgentEvent :: SubagentEnd { agent, success, .. } => ends. push ( ( agent, success) ) ,
2035+ AgentEvent :: SubagentProgress { status, .. } => progress_statuses. push ( status) ,
19692036 _ => { }
19702037 }
19712038 }
@@ -1976,6 +2043,17 @@ mod tests {
19762043 assert ! ( ends
19772044 . iter( )
19782045 . all( |( agent, success) | agent == "worker" && * success) ) ;
2046+ // Each child loop emits at least one TurnEnd, so we expect at least
2047+ // two synthesized turn_completed progress events across the run.
2048+ assert ! (
2049+ progress_statuses
2050+ . iter( )
2051+ . filter( |s| s == & "turn_completed" )
2052+ . count( )
2053+ >= 2 ,
2054+ "expected at least two turn_completed progress events, got {:?}" ,
2055+ progress_statuses
2056+ ) ;
19792057 }
19802058
19812059 #[ tokio:: test]
@@ -2082,4 +2160,115 @@ mod tests {
20822160 ) ;
20832161 }
20842162 }
2163+
2164+ #[ test]
2165+ fn synthesize_progress_emits_tool_completed_for_tool_end ( ) {
2166+ let event = AgentEvent :: ToolEnd {
2167+ id : "call-1" . to_string ( ) ,
2168+ name : "bash" . to_string ( ) ,
2169+ output : "hello" . to_string ( ) ,
2170+ exit_code : 0 ,
2171+ metadata : None ,
2172+ error_kind : None ,
2173+ } ;
2174+ let progress =
2175+ synthesize_subagent_progress ( & event, "task-1" , "task-run-task-1" ) . expect ( "some" ) ;
2176+ match progress {
2177+ AgentEvent :: SubagentProgress {
2178+ task_id,
2179+ session_id,
2180+ status,
2181+ metadata,
2182+ } => {
2183+ assert_eq ! ( task_id, "task-1" ) ;
2184+ assert_eq ! ( session_id, "task-run-task-1" ) ;
2185+ assert_eq ! ( status, "tool_completed" ) ;
2186+ assert_eq ! ( metadata[ "tool" ] , "bash" ) ;
2187+ assert_eq ! ( metadata[ "exit_code" ] , 0 ) ;
2188+ assert_eq ! ( metadata[ "output_bytes" ] , 5 ) ;
2189+ assert ! ( metadata. get( "error_kind" ) . is_none( ) ) ;
2190+ }
2191+ other => panic ! ( "expected SubagentProgress, got {:?}" , other) ,
2192+ }
2193+ }
2194+
2195+ #[ test]
2196+ fn synthesize_progress_includes_error_kind_when_present ( ) {
2197+ let event = AgentEvent :: ToolEnd {
2198+ id : "call-2" . to_string ( ) ,
2199+ name : "edit" . to_string ( ) ,
2200+ output : "boom" . to_string ( ) ,
2201+ exit_code : 1 ,
2202+ metadata : None ,
2203+ error_kind : Some ( crate :: tools:: ToolErrorKind :: NotFound {
2204+ path : "missing.txt" . to_string ( ) ,
2205+ } ) ,
2206+ } ;
2207+ let progress =
2208+ synthesize_subagent_progress ( & event, "task-x" , "task-run-task-x" ) . expect ( "some" ) ;
2209+ if let AgentEvent :: SubagentProgress { metadata, .. } = progress {
2210+ assert ! (
2211+ metadata. get( "error_kind" ) . is_some( ) ,
2212+ "error_kind should propagate into metadata"
2213+ ) ;
2214+ } else {
2215+ panic ! ( "expected SubagentProgress" ) ;
2216+ }
2217+ }
2218+
2219+ #[ test]
2220+ fn synthesize_progress_emits_turn_completed_for_turn_end ( ) {
2221+ let event = AgentEvent :: TurnEnd {
2222+ turn : 3 ,
2223+ usage : crate :: llm:: TokenUsage {
2224+ prompt_tokens : 100 ,
2225+ completion_tokens : 25 ,
2226+ total_tokens : 125 ,
2227+ cache_read_tokens : None ,
2228+ cache_write_tokens : None ,
2229+ } ,
2230+ } ;
2231+ let progress =
2232+ synthesize_subagent_progress ( & event, "task-1" , "task-run-task-1" ) . expect ( "some" ) ;
2233+ if let AgentEvent :: SubagentProgress {
2234+ status, metadata, ..
2235+ } = progress
2236+ {
2237+ assert_eq ! ( status, "turn_completed" ) ;
2238+ assert_eq ! ( metadata[ "turn" ] , 3 ) ;
2239+ assert_eq ! ( metadata[ "total_tokens" ] , 125 ) ;
2240+ assert_eq ! ( metadata[ "prompt_tokens" ] , 100 ) ;
2241+ assert_eq ! ( metadata[ "completion_tokens" ] , 25 ) ;
2242+ } else {
2243+ panic ! ( "expected SubagentProgress" ) ;
2244+ }
2245+ }
2246+
2247+ #[ test]
2248+ fn synthesize_progress_ignores_unrelated_events ( ) {
2249+ let ignored = [
2250+ AgentEvent :: TextDelta {
2251+ text : "hi" . to_string ( ) ,
2252+ } ,
2253+ AgentEvent :: ToolStart {
2254+ id : "x" . to_string ( ) ,
2255+ name : "bash" . to_string ( ) ,
2256+ } ,
2257+ AgentEvent :: TurnStart { turn : 1 } ,
2258+ AgentEvent :: SubagentStart {
2259+ task_id : "nested" . to_string ( ) ,
2260+ session_id : "nested-run" . to_string ( ) ,
2261+ parent_session_id : "parent" . to_string ( ) ,
2262+ agent : "explore" . to_string ( ) ,
2263+ description : "nested" . to_string ( ) ,
2264+ } ,
2265+ ] ;
2266+ for event in & ignored {
2267+ assert ! (
2268+ synthesize_subagent_progress( event, "task" , "session" ) . is_none( ) ,
2269+ "{:?} should not emit progress" ,
2270+ event
2271+ ) ;
2272+ }
2273+ }
20852274}
0 commit comments