@@ -3005,10 +3005,15 @@ fn handle_prompt_result(
30053005 PromptSource :: Heartbeat => None ,
30063006 } ;
30073007 let turn_id = result. turn_id . clone ( ) ;
3008- let emit_turn_error = |error_msg : & str , error_code : Option < i64 > | {
3008+ // `error_class` is a stable, machine-readable failure taxonomy the UI can
3009+ // branch on (persist a badge, offer a retry, surface provider issues).
3010+ // It defaults to `outcome_label` so every emit path carries a class; the
3011+ // application/transport error branches refine it further below (#1659).
3012+ let emit_turn_error = |error_msg : & str , error_code : Option < i64 > , error_class : & str | {
30093013 if let Some ( ref observer) = observer {
30103014 let mut payload = serde_json:: json!( {
30113015 "outcome" : outcome_label,
3016+ "error_class" : error_class,
30123017 "error" : error_msg,
30133018 } ) ;
30143019 if let Some ( code) = error_code {
@@ -3056,7 +3061,7 @@ fn handle_prompt_result(
30563061 }
30573062 _ => "Agent session timed out due to inactivity" . to_string ( ) ,
30583063 } ;
3059- emit_turn_error ( & death_message, None ) ;
3064+ emit_turn_error ( & death_message, None , outcome_label ) ;
30603065
30613066 let index = result. agent . index ;
30623067 let slot_history = & mut crash_history[ index] ;
@@ -3096,7 +3101,7 @@ fn handle_prompt_result(
30963101 let death_message = format ! (
30973102 "Agent did not stop within {grace:?} after cancellation; the agent process is being replaced."
30983103 ) ;
3099- emit_turn_error ( & death_message, None ) ;
3104+ emit_turn_error ( & death_message, None , outcome_label ) ;
31003105
31013106 let index = result. agent . index ;
31023107 let slot_history = & mut crash_history[ index] ;
@@ -3161,7 +3166,7 @@ fn handle_prompt_result(
31613166 error = %e,
31623167 "transport/protocol error — respawning agent"
31633168 ) ;
3164- emit_turn_error ( & e. to_string ( ) , error_code) ;
3169+ emit_turn_error ( & e. to_string ( ) , error_code, "transport_error" ) ;
31653170
31663171 let index = result. agent . index ;
31673172 let slot_history = & mut crash_history[ index] ;
@@ -3187,7 +3192,7 @@ fn handle_prompt_result(
31873192 error = %e,
31883193 "agent_returned (application error — pipe intact)"
31893194 ) ;
3190- emit_turn_error ( & e. to_string ( ) , error_code) ;
3195+ emit_turn_error ( & e. to_string ( ) , error_code, "application_error" ) ;
31913196 pool. return_agent ( result. agent ) ;
31923197 }
31933198 }
@@ -3249,6 +3254,7 @@ fn recover_panicked_agent(
32493254 & observer:: context_for ( meta. channel_id , None , Some ( meta. turn_id ) ) ,
32503255 serde_json:: json!( {
32513256 "outcome" : "panic" ,
3257+ "error_class" : "panic" ,
32523258 "error" : format!( "Agent task panicked: {join_error}" ) ,
32533259 } ) ,
32543260 ) ;
@@ -4675,11 +4681,91 @@ mod error_outcome_emission_tests {
46754681 turn_errors. len ( )
46764682 }
46774683
4684+ /// Drive one error outcome through `handle_prompt_result` and return the
4685+ /// `error_class` string carried on the emitted `turn_error` payload (#1659).
4686+ async fn error_class_emitted_for ( outcome : PromptOutcome ) -> Option < String > {
4687+ let agent = dummy_agent ( 0 ) . await ;
4688+ let mut pool = AgentPool :: from_slots ( vec ! [ None ] ) ;
4689+ let task_id = pool. join_set . spawn ( async { } ) . id ( ) ;
4690+ pool. task_map_mut ( ) . insert (
4691+ task_id,
4692+ crate :: pool:: TaskMeta {
4693+ agent_index : 0 ,
4694+ channel_id : None ,
4695+ turn_id : "test-turn-id" . to_string ( ) ,
4696+ recoverable_batch : None ,
4697+ control_tx : None ,
4698+ steer_tx : None ,
4699+ } ,
4700+ ) ;
4701+ let mut queue = EventQueue :: new ( config:: DedupMode :: Queue ) ;
4702+ let config = test_config ( ) ;
4703+ let mut heartbeat_in_flight = false ;
4704+ let removed_channels = HashSet :: new ( ) ;
4705+ let mut crash_history = vec ! [ SlotCircuit {
4706+ crash_times: Vec :: new( ) ,
4707+ open_until: None ,
4708+ respawn_in_flight: false ,
4709+ } ] ;
4710+ let ( respawn_tx, _respawn_rx) = mpsc:: channel ( 8 ) ;
4711+ let mut respawn_tasks = tokio:: task:: JoinSet :: new ( ) ;
4712+ let observer = ObserverHandle :: in_process ( ) ;
4713+ let result = PromptResult {
4714+ agent,
4715+ source : PromptSource :: Channel ( Uuid :: new_v4 ( ) ) ,
4716+ turn_id : "test-turn-id" . to_string ( ) ,
4717+ outcome,
4718+ batch : None ,
4719+ } ;
4720+ handle_prompt_result (
4721+ & mut pool,
4722+ & mut queue,
4723+ & config,
4724+ result,
4725+ & mut heartbeat_in_flight,
4726+ & removed_channels,
4727+ & mut crash_history,
4728+ & respawn_tx,
4729+ & mut respawn_tasks,
4730+ Some ( observer. clone ( ) ) ,
4731+ None ,
4732+ ) ;
4733+ observer
4734+ . snapshot ( )
4735+ . into_iter ( )
4736+ . find ( |e| e. kind == "turn_error" )
4737+ . and_then ( |e| {
4738+ e. payload
4739+ . get ( "error_class" )
4740+ . and_then ( |v| v. as_str ( ) )
4741+ . map ( str:: to_string)
4742+ } )
4743+ }
4744+
46784745 #[ tokio:: test]
46794746 async fn agent_exited_emits_exactly_one_feed_event ( ) {
46804747 assert_eq ! ( turn_errors_emitted_for( PromptOutcome :: AgentExited ) . await , 1 ) ;
46814748 }
46824749
4750+ #[ tokio:: test]
4751+ async fn turn_error_carries_stable_error_class ( ) {
4752+ // Fatal process death classes surface as their outcome label so the UI
4753+ // can persist a differentiated, actionable badge instead of a generic
4754+ // transient "Turn error" (#1659).
4755+ assert_eq ! (
4756+ error_class_emitted_for( PromptOutcome :: AgentExited )
4757+ . await
4758+ . as_deref( ) ,
4759+ Some ( "exited" )
4760+ ) ;
4761+ assert_eq ! (
4762+ error_class_emitted_for( PromptOutcome :: Timeout ( TimeoutKind :: Idle ) )
4763+ . await
4764+ . as_deref( ) ,
4765+ Some ( "idle_timeout" )
4766+ ) ;
4767+ }
4768+
46834769 #[ tokio:: test]
46844770 async fn panic_event_retains_task_turn_id ( ) {
46854771 let mut pool = AgentPool :: from_slots ( vec ! [ ] ) ;
0 commit comments