use serde_json::Value;
use crate::app_server::AppServerStreamEvent;
pub(super) fn conversation_id(payload: &Value) -> Option<&str> {
payload
.get("conversation_id")
.or_else(|| {
payload
.get("init")
.and_then(|init| init.get("conversation_id"))
})
.or_else(|| {
payload
.get("step_update")
.and_then(|step| step.get("conversation_id"))
})
.or_else(|| {
payload
.get("result")
.and_then(|result| result.get("conversation_id"))
})
.and_then(Value::as_str)
.filter(|conversation_id| !conversation_id.trim().is_empty())
}
pub(super) fn step_update(payload: &Value) -> Option<&Value> {
(payload.get("event").and_then(Value::as_str) == Some("step_update"))
.then(|| payload.get("step_update"))
.flatten()
}
pub(super) fn result(payload: &Value) -> Option<&Value> {
(payload.get("event").and_then(Value::as_str) == Some("result"))
.then(|| payload.get("result"))
.flatten()
}
pub(super) fn result_response(result: &Value) -> Option<String> {
if let Some(structured_output) = result.get("structured_output")
&& !structured_output.is_null()
{
return Some(normalize_response_value(structured_output.clone()).to_string());
}
let response = result.get("response").and_then(value_text)?;
let Ok(response_value) = serde_json::from_str(&response) else {
return Some(response);
};
Some(normalize_response_value(response_value).to_string())
}
pub(super) fn result_succeeded(result: &Value) -> bool {
result
.get("status")
.and_then(Value::as_str)
.is_some_and(|status| status.eq_ignore_ascii_case("success"))
}
pub(super) fn result_error(result: &Value) -> Option<&str> {
result
.get("error")
.and_then(Value::as_str)
.filter(|error| !error.trim().is_empty())
}
pub(super) fn stream_event(step_update: &Value) -> Option<AppServerStreamEvent> {
let step_type = step_update.get("step_type").and_then(Value::as_str)?;
if step_type.eq_ignore_ascii_case("agent_response") {
let text_delta = step_update
.get("text_delta")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())?;
return Some(AppServerStreamEvent::AssistantMessage {
is_delta: true,
message: text_delta.to_string(),
phase: None,
});
}
if !step_update
.get("state")
.and_then(Value::as_str)
.is_some_and(|state| state.eq_ignore_ascii_case("active"))
{
return None;
}
let normalized_step_type = step_type.to_ascii_lowercase().replace('-', "_");
let progress = match normalized_step_type.as_str() {
"context_compaction" | "context_compression" => "Compacting context".to_string(),
"reasoning" | "thought" => "Reasoning".to_string(),
"tool" => step_update
.get("tool_name")
.and_then(Value::as_str)
.filter(|tool_name| !tool_name.trim().is_empty())
.map_or_else(
|| "Running tool".to_string(),
|tool_name| format!("Running {tool_name}"),
),
_ => return None,
};
Some(AppServerStreamEvent::ProgressUpdate(progress))
}
fn value_text(value: &Value) -> Option<String> {
if let Some(text) = value.as_str().filter(|text| !text.trim().is_empty()) {
return Some(text.to_string());
}
if value.is_object() || value.is_array() {
return Some(value.to_string());
}
None
}
fn normalize_response_value(mut response: Value) -> Value {
if let Some(summary) = response.get_mut("summary")
&& let Some(summary_text) = summary.as_str()
{
*summary = serde_json::json!({
"session": summary_text,
"turn": summary_text,
});
}
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn result_prefers_structured_output_and_conversation_id() {
let payload = serde_json::json!({
"event": "result",
"result": {
"conversation_id": "conversation-1",
"status": "SUCCESS",
"response": "legacy",
"structured_output": {"answer": "ready"},
},
});
let result = result(&payload).expect("result event should parse");
let response = result_response(result);
let conversation_id = conversation_id(&payload);
assert_eq!(response.as_deref(), Some("{\"answer\":\"ready\"}"));
assert_eq!(conversation_id, Some("conversation-1"));
assert!(result_succeeded(result));
}
#[test]
fn step_updates_map_assistant_and_compaction_events() {
let assistant = serde_json::json!({
"step_type": "agent_response",
"state": "ACTIVE",
"text_delta": "partial",
});
let compaction = serde_json::json!({
"step_type": "context-compaction",
"state": "ACTIVE",
});
let assistant_event = stream_event(&assistant);
let compaction_event = stream_event(&compaction);
assert_eq!(
assistant_event,
Some(AppServerStreamEvent::AssistantMessage {
is_delta: true,
message: "partial".to_string(),
phase: None,
})
);
assert_eq!(
compaction_event,
Some(AppServerStreamEvent::ProgressUpdate(
"Compacting context".to_string()
))
);
}
#[test]
fn conversation_id_supports_init_step_and_top_level_shapes() {
let top_level = serde_json::json!({"conversation_id": "top-level"});
let init = serde_json::json!({"init": {"conversation_id": "from-init"}});
let step = serde_json::json!({
"step_update": {"conversation_id": "from-step"},
});
let empty = serde_json::json!({"conversation_id": " "});
assert_eq!(conversation_id(&top_level), Some("top-level"));
assert_eq!(conversation_id(&init), Some("from-init"));
assert_eq!(conversation_id(&step), Some("from-step"));
assert_eq!(conversation_id(&empty), None);
}
#[test]
fn event_extractors_reject_unrelated_or_incomplete_payloads() {
let unrelated = serde_json::json!({"event": "init"});
let incomplete_step = serde_json::json!({"event": "step_update"});
let incomplete_result = serde_json::json!({"event": "result"});
assert_eq!(step_update(&unrelated), None);
assert_eq!(step_update(&incomplete_step), None);
assert_eq!(result(&unrelated), None);
assert_eq!(result(&incomplete_result), None);
}
#[test]
fn result_response_accepts_text_and_json_but_rejects_empty_scalars() {
let text = serde_json::json!({"response": "answer"});
let object = serde_json::json!({"response": {"answer": "ready"}});
let array = serde_json::json!({"response": ["ready"]});
let empty = serde_json::json!({"response": " "});
let number = serde_json::json!({"response": 42});
assert_eq!(result_response(&text).as_deref(), Some("answer"));
assert_eq!(
result_response(&object).as_deref(),
Some("{\"answer\":\"ready\"}")
);
assert_eq!(result_response(&array).as_deref(), Some("[\"ready\"]"));
assert_eq!(result_response(&empty), None);
assert_eq!(result_response(&number), None);
}
#[test]
fn result_response_normalizes_antigravity_string_summary() {
let structured = serde_json::json!({
"structured_output": {
"answer": "done",
"summary": "Completed the Antigravity turn.",
},
});
let serialized = serde_json::json!({
"response": "{\"answer\":\"done\",\"summary\":\"Completed the Antigravity turn.\"}",
});
let expected = serde_json::json!({
"answer": "done",
"summary": {
"session": "Completed the Antigravity turn.",
"turn": "Completed the Antigravity turn.",
},
})
.to_string();
let structured_response = result_response(&structured);
let serialized_response = result_response(&serialized);
assert_eq!(structured_response.as_deref(), Some(expected.as_str()));
assert_eq!(serialized_response.as_deref(), Some(expected.as_str()));
}
#[test]
fn result_status_and_error_are_case_insensitive_and_nonempty() {
let success = serde_json::json!({"status": "success"});
let failure = serde_json::json!({"status": "failed", "error": "quota"});
let blank_error = serde_json::json!({"error": " "});
assert!(result_succeeded(&success));
assert!(!result_succeeded(&failure));
assert_eq!(result_error(&failure), Some("quota"));
assert_eq!(result_error(&blank_error), None);
}
#[test]
fn active_reasoning_and_tool_steps_map_to_progress() {
let reasoning = serde_json::json!({"step_type": "thought", "state": "active"});
let named_tool = serde_json::json!({
"step_type": "tool",
"state": "ACTIVE",
"tool_name": "shell",
});
let unnamed_tool = serde_json::json!({"step_type": "tool", "state": "ACTIVE"});
let inactive = serde_json::json!({"step_type": "reasoning", "state": "DONE"});
let unknown = serde_json::json!({"step_type": "unknown", "state": "ACTIVE"});
assert_eq!(
stream_event(&reasoning),
Some(AppServerStreamEvent::ProgressUpdate(
"Reasoning".to_string()
))
);
assert_eq!(
stream_event(&named_tool),
Some(AppServerStreamEvent::ProgressUpdate(
"Running shell".to_string()
))
);
assert_eq!(
stream_event(&unnamed_tool),
Some(AppServerStreamEvent::ProgressUpdate(
"Running tool".to_string()
))
);
assert_eq!(stream_event(&inactive), None);
assert_eq!(stream_event(&unknown), None);
assert_eq!(stream_event(&serde_json::json!({})), None);
}
}