use serde_json::Value;
use crate::model::agent::AgentKind;
pub(crate) fn format_agent_cli_exit_error(
agent_kind: AgentKind,
command_label: &str,
exit_code: Option<i32>,
stdout: &str,
stderr: &str,
) -> String {
if let Some(guidance) = known_agent_cli_exit_guidance(agent_kind, command_label, stdout, stderr)
{
return guidance;
}
let exit_code = exit_code.map_or_else(|| "unknown".to_string(), |code| code.to_string());
let output_detail = agent_cli_output_detail(stdout, stderr);
format!("{command_label} failed with exit code {exit_code}.\n\n{output_detail}")
}
fn known_agent_cli_exit_guidance(
agent_kind: AgentKind,
command_label: &str,
stdout: &str,
stderr: &str,
) -> Option<String> {
match agent_kind {
AgentKind::Antigravity if is_antigravity_authentication_error(stdout, stderr) => {
Some(antigravity_authentication_error_message(command_label))
}
AgentKind::Claude if is_claude_authentication_error(stdout, stderr) => {
Some(claude_authentication_error_message(command_label))
}
AgentKind::Antigravity | AgentKind::Claude | AgentKind::Codex | AgentKind::Gemini => None,
}
}
fn antigravity_authentication_error_message(command_label: &str) -> String {
format!(
"{command_label} failed because Antigravity authentication is missing.\nRun `agy` in a \
terminal to complete Google sign-in, then retry."
)
}
fn claude_authentication_error_message(command_label: &str) -> String {
format!(
"{command_label} failed because Claude authentication expired or is missing.\nRun `claude \
auth login` to refresh your Anthropic session, verify with `claude auth status`, then \
retry."
)
}
fn is_antigravity_authentication_error(stdout: &str, stderr: &str) -> bool {
let combined_output = format!("{stdout}\n{stderr}").to_ascii_lowercase();
combined_output.contains("you are not logged into antigravity")
|| combined_output.contains("failed to get oauth token")
|| combined_output.contains("error getting token source")
}
fn is_claude_authentication_error(stdout: &str, stderr: &str) -> bool {
let combined_output = format!("{stdout}\n{stderr}").to_ascii_lowercase();
combined_output.contains("oauth token has expired")
|| combined_output.contains("failed to authenticate")
|| combined_output.contains("authentication_error")
}
fn agent_cli_output_detail(stdout: &str, stderr: &str) -> String {
let trimmed_stdout = stdout.trim();
let trimmed_stderr = stderr.trim();
match (trimmed_stdout.is_empty(), trimmed_stderr.is_empty()) {
(false, false) => format_cli_stream_detail(
&[
cli_stream_section("stdout", trimmed_stdout),
cli_stream_section("stderr", trimmed_stderr),
]
.join("\n\n"),
),
(false, true) => format_cli_stream_detail(&cli_stream_section("stdout", trimmed_stdout)),
(true, false) => format_cli_stream_detail(&cli_stream_section("stderr", trimmed_stderr)),
(true, true) => "no output".to_string(),
}
}
fn cli_stream_section(label: &str, output: &str) -> String {
let formatted_output = format_cli_stream_output(output);
format!("{label}:\n```text\n{formatted_output}\n```")
}
fn format_cli_stream_output(output: &str) -> String {
format_json_line_stream(output).unwrap_or_else(|| output.to_string())
}
fn format_json_line_stream(output: &str) -> Option<String> {
let mut has_json_object = false;
let formatted_lines = output
.lines()
.map(|line| {
let trimmed_line = line.trim();
if trimmed_line.is_empty() {
return line.to_string();
}
match serde_json::from_str::<Value>(trimmed_line) {
Ok(value @ Value::Object(_)) => {
has_json_object = true;
format_json_event_summary(&value)
}
Ok(_) | Err(_) => line.to_string(),
}
})
.collect::<Vec<_>>();
if !has_json_object {
return None;
}
Some(formatted_lines.join("\n"))
}
fn format_json_event_summary(value: &Value) -> String {
match json_string(value, "type").as_deref() {
Some("assistant") => format_assistant_event_summary(value),
Some("result") => format_result_event_summary(value),
Some("system") => format_system_event_summary(value),
Some(event_type) => format_generic_event_summary(value, event_type),
None => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()),
}
}
fn format_assistant_event_summary(value: &Value) -> String {
let message = value
.get("message")
.and_then(assistant_message_text)
.or_else(|| assistant_message_text(value));
message.map_or_else(
|| format_generic_event_summary(value, "assistant"),
|message| format!("assistant: {message}"),
)
}
fn format_result_event_summary(value: &Value) -> String {
let mut lines = Vec::new();
let is_error = value
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false);
let result_label = json_string(value, "error").map_or_else(
|| "result".to_string(),
|error| format!("result error: {error}"),
);
lines.push(result_label);
if let Some(result) = json_string(value, "result")
&& !result.is_empty()
{
lines.push(format!("message: {result}"));
}
if let Some(api_error_status) = json_scalar(value, "api_error_status") {
lines.push(format!("api error status: {api_error_status}"));
}
if let Some(request_id) = json_string(value, "request_id") {
lines.push(format!("request id: {request_id}"));
}
if let Some(duration_ms) = value.get("duration_ms").and_then(Value::as_u64) {
lines.push(format!("duration: {duration_ms}ms"));
}
if is_error && lines.len() == 1 {
lines[0].push_str(" (failed)");
}
lines.join("\n")
}
fn format_system_event_summary(value: &Value) -> String {
let mut parts = vec!["system".to_string()];
if let Some(subtype) = json_string(value, "subtype") {
parts.push(subtype);
}
if let Some(cwd) = json_string(value, "cwd") {
parts.push(format!("cwd: {cwd}"));
}
if let Some(session_id) = json_string(value, "session_id") {
parts.push(format!("session: {session_id}"));
}
parts.join(" | ")
}
fn format_generic_event_summary(value: &Value, event_type: &str) -> String {
let mut parts = vec![event_type.to_string()];
for field in [
"subtype",
"status",
"reason",
"error",
"api_error_status",
"request_id",
"session_id",
] {
if let Some(field_value) = json_scalar(value, field) {
parts.push(format!("{field}: {field_value}"));
}
}
if let Some(rate_limit_info) = value.get("rate_limit_info").and_then(Value::as_object) {
if let Some(status) = rate_limit_info.get("status").and_then(Value::as_str) {
parts.push(format!("rate_limit_status: {status}"));
}
if let Some(reason) = rate_limit_info.get("reason").and_then(Value::as_str) {
parts.push(format!("rate_limit_reason: {reason}"));
}
}
parts.join(" | ")
}
fn assistant_message_text(value: &Value) -> Option<String> {
if let Some(content) = json_string(value, "content") {
return Some(content);
}
let text = value
.get("content")?
.as_array()?
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n");
if text.is_empty() {
return None;
}
Some(text)
}
fn json_string(value: &Value, field: &str) -> Option<String> {
value
.get(field)
.and_then(Value::as_str)
.map(ToString::to_string)
}
fn json_scalar(value: &Value, field: &str) -> Option<String> {
match value.get(field)? {
Value::Bool(bool_value) => Some(bool_value.to_string()),
Value::Number(number) => Some(number.to_string()),
Value::String(string) => Some(string.clone()),
Value::Null | Value::Array(_) | Value::Object(_) => None,
}
}
fn format_cli_stream_detail(detail: &str) -> String {
detail.trim_end().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_generic_error_with_exit_code_and_stderr() {
let result = format_agent_cli_exit_error(
AgentKind::Claude,
"claude code",
Some(1),
"",
"something broke",
);
assert!(result.contains("claude code failed with exit code 1"));
assert!(result.contains("stderr:\n```text\nsomething broke\n```"));
}
#[test]
fn test_format_generic_error_with_unknown_exit_code() {
let result =
format_agent_cli_exit_error(AgentKind::Antigravity, "agy", None, "", "crash output");
assert!(result.contains("exit code unknown"));
}
#[test]
fn test_format_generic_error_with_both_stdout_and_stderr() {
let result =
format_agent_cli_exit_error(AgentKind::Codex, "codex", Some(2), "out text", "err text");
assert!(result.contains("stdout:\n```text\nout text\n```"));
assert!(result.contains("stderr:\n```text\nerr text\n```"));
}
#[test]
fn test_format_generic_error_summarizes_claude_jsonl_rate_limit() {
let stdout = [
r#"{"type":"system","subtype":"init","cwd":"/private/tmp/test-agentty/wt/d4ab835d","session_id":"729a8ede-900d-4d2f-97e2-fef9b8a3593a"}"#,
r#"{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","reason":"out_of_credits","isUsingOverage":false}}"#,
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}"#,
r#"{"type":"result","subtype":"success","is_error":true,"api_error_status":429,"duration_ms":283,"duration_api_ms":0,"num_turns":1,"result":"You've hit your session limit - resets 12:10am (America/Los_Angeles)","session_id":"729a8ede-900d-4d2f-97e2-fef9b8a3593a","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0},"error":"rate_limit","request_id":"req_011Cbfc7AF16gbH"}"#,
]
.join("\n");
let result =
format_agent_cli_exit_error(AgentKind::Claude, "Agent command", Some(1), &stdout, "");
assert!(result.contains("Agent command failed with exit code 1."));
assert!(result.contains("system | init | cwd: /private/tmp/test-agentty/wt/d4ab835d"));
assert!(result.contains("rate_limit_event | rate_limit_status: rejected"));
assert!(result.contains("assistant: hi"));
assert!(result.contains("result error: rate_limit"));
assert!(result.contains(
"message: You've hit your session limit - resets 12:10am (America/Los_Angeles)"
));
assert!(result.contains("api error status: 429"));
assert!(result.contains("request id: req_011Cbfc7AF16gbH"));
}
#[test]
fn test_format_generic_error_with_no_output() {
let result = format_agent_cli_exit_error(AgentKind::Claude, "claude", Some(1), "", "");
assert!(result.contains("no output"));
}
#[test]
fn test_claude_auth_error_from_expired_oauth_token_in_stderr() {
let result = format_agent_cli_exit_error(
AgentKind::Claude,
"claude code",
Some(1),
"",
"OAuth token has expired",
);
assert!(result.contains("authentication expired or is missing"));
assert!(result.contains("claude auth login"));
}
#[test]
fn test_antigravity_auth_error_from_not_logged_in_message() {
let result = format_agent_cli_exit_error(
AgentKind::Antigravity,
"agy",
Some(1),
"",
"You are not logged into Antigravity.",
);
assert!(result.contains("Antigravity authentication is missing"));
assert!(result.contains("Run `agy`"));
}
#[test]
fn test_claude_auth_error_from_failed_to_authenticate_in_stdout() {
let result = format_agent_cli_exit_error(
AgentKind::Claude,
"claude code",
Some(1),
"Failed to authenticate user",
"",
);
assert!(result.contains("authentication expired or is missing"));
}
#[test]
fn test_claude_auth_error_from_authentication_error_keyword() {
let result = format_agent_cli_exit_error(
AgentKind::Claude,
"claude code",
Some(1),
"",
"authentication_error: invalid key",
);
assert!(result.contains("claude auth login"));
}
#[test]
fn test_claude_auth_detection_is_case_insensitive() {
let result = format_agent_cli_exit_error(
AgentKind::Claude,
"claude",
Some(1),
"OAUTH TOKEN HAS EXPIRED",
"",
);
assert!(result.contains("authentication expired or is missing"));
}
#[test]
fn test_non_claude_provider_ignores_auth_keywords() {
let result = format_agent_cli_exit_error(
AgentKind::Antigravity,
"agy",
Some(1),
"",
"OAuth token has expired",
);
assert!(!result.contains("claude auth login"));
assert!(result.contains("exit code 1"));
}
#[test]
fn test_output_detail_with_only_stdout() {
let detail = agent_cli_output_detail("output text", "");
assert_eq!(detail, "stdout:\n```text\noutput text\n```");
}
#[test]
fn test_output_detail_with_only_stderr() {
let detail = agent_cli_output_detail("", "error text");
assert_eq!(detail, "stderr:\n```text\nerror text\n```");
}
#[test]
fn test_output_detail_with_both_streams() {
let detail = agent_cli_output_detail("out", "err");
assert_eq!(
detail,
"stdout:\n```text\nout\n```\n\nstderr:\n```text\nerr\n```"
);
}
#[test]
fn test_output_detail_with_empty_streams() {
let detail = agent_cli_output_detail("", "");
assert_eq!(detail, "no output");
}
#[test]
fn test_output_detail_trims_whitespace() {
let detail = agent_cli_output_detail(" out ", " err ");
assert_eq!(
detail,
"stdout:\n```text\nout\n```\n\nstderr:\n```text\nerr\n```"
);
}
#[test]
fn test_format_cli_stream_output_preserves_non_json_text() {
let output = format_cli_stream_output("plain text\nwith detail");
assert_eq!(output, "plain text\nwith detail");
}
#[test]
fn test_format_cli_stream_output_summarizes_json_lines_with_plain_text() {
let input = [
"proxy warning: retrying",
r#"{"type":"result","is_error":true,"error":"rate_limit","result":"session limit","request_id":"req_123"}"#,
"plain tail",
]
.join("\n");
let expected = [
"proxy warning: retrying",
"result error: rate_limit",
"message: session limit",
"request id: req_123",
"plain tail",
]
.join("\n");
let output = format_cli_stream_output(&input);
assert_eq!(output, expected);
}
#[test]
fn test_is_claude_auth_error_returns_false_for_unrelated_output() {
assert!(!is_claude_authentication_error(
"normal output",
"normal error"
));
}
#[test]
fn test_is_claude_auth_error_returns_true_for_mixed_case_in_stderr() {
assert!(is_claude_authentication_error("", "FAILED TO AUTHENTICATE"));
}
#[test]
fn test_known_guidance_returns_none_for_non_auth_claude_error() {
let result =
known_agent_cli_exit_guidance(AgentKind::Claude, "claude", "normal out", "normal err");
assert!(result.is_none());
}
#[test]
fn test_known_guidance_returns_none_for_codex() {
let result =
known_agent_cli_exit_guidance(AgentKind::Codex, "codex", "OAuth token has expired", "");
assert!(result.is_none());
}
}