generated from nhcarrigan/template
feat: handle StopFailure hook event for API error turns (#224)
Parses [StopFailure Hook] from stderr, emits claude:stop-failure, and transitions the character to error state with a toast notification.
This commit is contained in:
@@ -300,6 +300,16 @@ pub struct ElicitationResultEvent {
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StopFailureEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentStartEvent {
|
||||
pub tool_use_id: String,
|
||||
@@ -645,4 +655,46 @@ mod tests {
|
||||
assert!(!serialized.contains("request_id"));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_failure_event_serialization() {
|
||||
let event = StopFailureEvent {
|
||||
stop_reason: Some("api_error".to_string()),
|
||||
error_type: Some("rate_limit".to_string()),
|
||||
conversation_id: Some("conv-abc".to_string()),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"stop_reason\":\"api_error\""));
|
||||
assert!(serialized.contains("\"error_type\":\"rate_limit\""));
|
||||
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_failure_event_omits_none_fields() {
|
||||
let event = StopFailureEvent {
|
||||
stop_reason: None,
|
||||
error_type: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(!serialized.contains("stop_reason"));
|
||||
assert!(!serialized.contains("error_type"));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_failure_event_partial_fields() {
|
||||
let event = StopFailureEvent {
|
||||
stop_reason: Some("api_error".to_string()),
|
||||
error_type: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"stop_reason\":\"api_error\""));
|
||||
assert!(!serialized.contains("error_type"));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
}
|
||||
|
||||
+144
-2
@@ -17,8 +17,8 @@ use crate::types::{
|
||||
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
||||
ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost,
|
||||
OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent,
|
||||
StateChangeEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent,
|
||||
WorktreeEvent, WorktreeInfo,
|
||||
StateChangeEvent, StopFailureEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent,
|
||||
WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use std::cell::RefCell;
|
||||
@@ -1054,6 +1054,7 @@ fn handle_stderr(
|
||||
let is_worktree_remove = line.contains("[WorktreeRemove Hook]");
|
||||
let is_elicitation = line.contains("[Elicitation Hook]");
|
||||
let is_elicitation_result = line.contains("[ElicitationResult Hook]");
|
||||
let is_stop_failure = line.contains("[StopFailure Hook]");
|
||||
|
||||
let line_type = if is_worktree_create || is_worktree_remove {
|
||||
"worktree"
|
||||
@@ -1061,6 +1062,8 @@ fn handle_stderr(
|
||||
"config-change"
|
||||
} else if is_elicitation || is_elicitation_result {
|
||||
"elicitation"
|
||||
} else if is_stop_failure {
|
||||
"error"
|
||||
} else {
|
||||
"error"
|
||||
};
|
||||
@@ -1153,6 +1156,30 @@ fn handle_stderr(
|
||||
parent_tool_use_id: None,
|
||||
},
|
||||
);
|
||||
} else if is_stop_failure {
|
||||
let data = parse_stop_failure_hook(&line);
|
||||
let friendly_content = build_stop_failure_message(&data);
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:stop-failure",
|
||||
StopFailureEvent {
|
||||
stop_reason: data.stop_reason,
|
||||
error_type: data.error_type,
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "error".to_string(),
|
||||
content: friendly_content,
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
@@ -1328,6 +1355,34 @@ fn parse_elicitation_result_hook(line: &str) -> ElicitationResultData {
|
||||
ElicitationResultData { action, request_id }
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StopFailureData {
|
||||
stop_reason: Option<String>,
|
||||
error_type: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_stop_failure_hook(line: &str) -> StopFailureData {
|
||||
let stop_reason = extract_quoted_value(line, "stop_reason");
|
||||
let error_type = extract_debug_string_value(line, "error_type");
|
||||
|
||||
StopFailureData { stop_reason, error_type }
|
||||
}
|
||||
|
||||
/// Builds a user-friendly message from a `StopFailureData` instance.
|
||||
fn build_stop_failure_message(data: &StopFailureData) -> String {
|
||||
match data.stop_reason.as_deref() {
|
||||
Some("rate_limit") => "Session stopped: rate limit reached".to_string(),
|
||||
Some("auth_failure") | Some("authentication") => {
|
||||
"Session stopped: authentication failed".to_string()
|
||||
}
|
||||
Some(reason) => format!("Session stopped due to API error: {}", reason),
|
||||
None => match data.error_type.as_deref() {
|
||||
Some(et) => format!("Session stopped due to API error: {}", et),
|
||||
None => "Session stopped due to an unknown API error".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a double-quoted string value from a `key="value"` pair in a hook line.
|
||||
/// Handles escape sequences within the quoted value.
|
||||
fn extract_quoted_value(line: &str, key: &str) -> Option<String> {
|
||||
@@ -3378,4 +3433,91 @@ mod tests {
|
||||
assert_eq!(data.action, "cancel");
|
||||
assert_eq!(data.request_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stop_failure_hook_with_all_fields() {
|
||||
let line = r#"[StopFailure Hook] stop_reason="api_error", error_type=Some("rate_limit"), conversation_id=Some("conv-123")"#;
|
||||
let data = parse_stop_failure_hook(line);
|
||||
assert_eq!(data.stop_reason, Some("api_error".to_string()));
|
||||
assert_eq!(data.error_type, Some("rate_limit".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stop_failure_hook_missing_optional_error_type() {
|
||||
let line = r#"[StopFailure Hook] stop_reason="api_error", error_type=None"#;
|
||||
let data = parse_stop_failure_hook(line);
|
||||
assert_eq!(data.stop_reason, Some("api_error".to_string()));
|
||||
assert_eq!(data.error_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stop_failure_hook_invalid_line() {
|
||||
let line = "[StopFailure Hook] some unstructured data";
|
||||
let data = parse_stop_failure_hook(line);
|
||||
assert_eq!(data.stop_reason, None);
|
||||
assert_eq!(data.error_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_stop_failure_message_rate_limit() {
|
||||
let data = StopFailureData {
|
||||
stop_reason: Some("rate_limit".to_string()),
|
||||
error_type: None,
|
||||
};
|
||||
assert_eq!(build_stop_failure_message(&data), "Session stopped: rate limit reached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_stop_failure_message_auth_failure() {
|
||||
let data = StopFailureData {
|
||||
stop_reason: Some("auth_failure".to_string()),
|
||||
error_type: None,
|
||||
};
|
||||
assert_eq!(build_stop_failure_message(&data), "Session stopped: authentication failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_stop_failure_message_authentication() {
|
||||
let data = StopFailureData {
|
||||
stop_reason: Some("authentication".to_string()),
|
||||
error_type: None,
|
||||
};
|
||||
assert_eq!(build_stop_failure_message(&data), "Session stopped: authentication failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_stop_failure_message_unknown_reason() {
|
||||
let data = StopFailureData {
|
||||
stop_reason: Some("server_error".to_string()),
|
||||
error_type: None,
|
||||
};
|
||||
assert_eq!(
|
||||
build_stop_failure_message(&data),
|
||||
"Session stopped due to API error: server_error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_stop_failure_message_no_reason_with_error_type() {
|
||||
let data = StopFailureData {
|
||||
stop_reason: None,
|
||||
error_type: Some("timeout".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
build_stop_failure_message(&data),
|
||||
"Session stopped due to API error: timeout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_stop_failure_message_no_fields() {
|
||||
let data = StopFailureData {
|
||||
stop_reason: None,
|
||||
error_type: None,
|
||||
};
|
||||
assert_eq!(
|
||||
build_stop_failure_message(&data),
|
||||
"Session stopped due to an unknown API error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user