169 lines
4.6 KiB
Rust

use crate::ai_agents::AiAgentStreamEvent;
const LOCALIZED_ERROR_PREFIX: &str = "tolaria:i18n-error:";
const PI_EMPTY_OUTPUT_KEY: &str = "ai.error.pi.emptyOutput";
const PI_EMPTY_OUTPUT_WITH_DIAGNOSTIC_KEY: &str = "ai.error.pi.emptyOutputWithDiagnostic";
#[cfg(test)]
pub(crate) fn parse_line<F>(
line: Result<String, std::io::Error>,
emit: &mut F,
) -> Option<serde_json::Value>
where
F: FnMut(AiAgentStreamEvent),
{
crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit)
}
pub(crate) fn dispatch_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
if json["type"].as_str() == Some("session") {
emit_session_event(json, emit);
}
match json["type"].as_str().unwrap_or_default() {
"message_update" => emit_message_update(json, emit),
"tool_execution_start" => emit_tool_start(json, emit),
"tool_execution_end" => emit_tool_done(json, emit),
"error" => emit_error_event(json, emit),
_ => {}
}
}
pub(crate) fn session_id(json: &serde_json::Value) -> Option<&str> {
json["id"]
.as_str()
.or_else(|| json["session_id"].as_str())
.or_else(|| json["session"]["id"].as_str())
}
pub(crate) fn format_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
if is_auth_error(&lower) {
return "Pi CLI is not authenticated. Run `pi /login` in your terminal or configure a provider API key, then retry.".into();
}
if stderr_output.trim().is_empty() {
format!("pi exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
pub(crate) fn format_empty_success(diagnostic_output: &str) -> String {
if diagnostic_output.is_empty() {
return localized_error(PI_EMPTY_OUTPUT_KEY, serde_json::json!({}));
}
localized_error(
PI_EMPTY_OUTPUT_WITH_DIAGNOSTIC_KEY,
serde_json::json!({ "diagnostic_output": diagnostic_output }),
)
}
fn localized_error(key: &str, values: serde_json::Value) -> String {
let payload = serde_json::json!({
"key": key,
"values": values,
});
format!("{LOCALIZED_ERROR_PREFIX}{payload}")
}
fn emit_session_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
if let Some(session_id) = session_id(json) {
emit(AiAgentStreamEvent::Init {
session_id: session_id.to_string(),
});
}
}
fn emit_message_update<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
let event = &json["assistantMessageEvent"];
match event["type"].as_str().unwrap_or_default() {
"text_delta" => emit_delta(event, emit, |text| AiAgentStreamEvent::TextDelta { text }),
"thinking_delta" => emit_delta(event, emit, |text| AiAgentStreamEvent::ThinkingDelta {
text,
}),
_ => {}
}
}
fn emit_delta<F>(
json: &serde_json::Value,
emit: &mut F,
build: impl FnOnce(String) -> AiAgentStreamEvent,
) where
F: FnMut(AiAgentStreamEvent),
{
if let Some(delta) = json["delta"].as_str() {
emit(build(delta.to_string()));
}
}
fn emit_tool_start<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
emit(AiAgentStreamEvent::ToolStart {
tool_name: tool_name(json),
tool_id: tool_id(json),
input: json.get("args").map(|args| args.to_string()),
});
}
fn emit_tool_done<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
emit(AiAgentStreamEvent::ToolDone {
tool_id: tool_id(json),
output: json.get("result").map(|result| result.to_string()),
});
}
fn emit_error_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
if let Some(message) = message_value(json) {
emit(AiAgentStreamEvent::Error {
message: message.to_string(),
});
}
}
fn tool_name(json: &serde_json::Value) -> String {
json["toolName"].as_str().unwrap_or("tool").to_string()
}
fn tool_id(json: &serde_json::Value) -> String {
json["toolCallId"].as_str().unwrap_or("tool").to_string()
}
fn message_value(json: &serde_json::Value) -> Option<&str> {
json["message"]
.as_str()
.or_else(|| json["error"].as_str())
.or_else(|| json["text"].as_str())
}
fn is_auth_error(lower: &str) -> bool {
[
"auth", "login", "sign in", "api key", "api.key", "provider", "401",
]
.iter()
.any(|pattern| lower.contains(pattern))
}
#[cfg(test)]
#[path = "pi_events_tests.rs"]
mod tests;