Skip to content

Commit 2dee886

Browse files
committed
feat(acp): add stable error_class to turn_error observer events
Agent turn failures were emitted to the observer feed as undifferentiated turn_error events (only outcome + error string), so the UI could not persist a differentiated, actionable badge and collapsed everything to a transient "Turn error". This threads a machine-readable error_class onto every turn_error payload: - fatal process death (exited / idle_timeout / hard_timeout / cancel_drain_timeout) surfaces as its outcome label - transport/protocol errors -> "transport_error" - application errors (pipe intact) -> "application_error" - agent_panic events carry "panic" Additive only; existing outcome/error/code fields unchanged. Adds a focused regression test asserting error_class per outcome. Refs #1659 Signed-off-by: Bartok9 <danielrpike9@gmail.com>
1 parent 7e34bee commit 2dee886

1 file changed

Lines changed: 91 additions & 5 deletions

File tree

crates/buzz-acp/src/lib.rs

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)