use std::collections::HashMap;
use serde::Deserialize;
use crate::model::session::SessionStats;
pub struct ParsedResponse {
pub content: String,
pub stats: SessionStats,
}
#[derive(Deserialize)]
struct ClaudeResponse {
result: Option<String>,
usage: Option<ClaudeUsage>,
}
#[derive(Deserialize)]
struct ClaudeUsage {
input_tokens: Option<i64>,
output_tokens: Option<i64>,
}
#[derive(Deserialize)]
struct GeminiResponse {
response: Option<String>,
stats: Option<GeminiStats>,
}
#[derive(Deserialize)]
struct GeminiStreamEvent {
#[serde(rename = "type")]
event_type: Option<String>,
stats: Option<GeminiStreamResultStats>,
}
#[derive(Deserialize)]
struct GeminiStreamResultStats {
input_tokens: Option<i64>,
output_tokens: Option<i64>,
}
#[derive(Deserialize)]
struct GeminiStats {
models: Option<HashMap<String, GeminiModelStats>>,
}
#[derive(Deserialize)]
struct GeminiModelStats {
tokens: Option<GeminiTokens>,
}
#[derive(Deserialize)]
struct GeminiTokens {
candidates: Option<i64>,
input: Option<i64>,
}
#[derive(Deserialize)]
struct CodexEvent {
#[serde(rename = "type")]
event_type: Option<String>,
item: Option<CodexItem>,
usage: Option<CodexUsage>,
}
#[derive(Deserialize)]
struct CodexUsage {
input_tokens: Option<i64>,
output_tokens: Option<i64>,
}
#[derive(Deserialize)]
struct CodexItem {
#[serde(rename = "type")]
item_type: Option<String>,
text: Option<String>,
delta: Option<String>,
}
pub(super) fn parse_claude_response_with_fallback(stdout: &str, stderr: &str) -> ParsedResponse {
parse_claude_response(stdout).unwrap_or_else(|| ParsedResponse {
content: fallback_response(stdout, stderr),
stats: SessionStats::default(),
})
}
pub(super) fn parse_gemini_response_with_fallback(stdout: &str, stderr: &str) -> ParsedResponse {
parse_gemini_response(stdout).unwrap_or_else(|| ParsedResponse {
content: fallback_response(stdout, stderr),
stats: SessionStats::default(),
})
}
pub(super) fn parse_codex_response_with_fallback(stdout: &str, stderr: &str) -> ParsedResponse {
parse_codex_response(stdout).unwrap_or_else(|| ParsedResponse {
content: fallback_response(stdout, stderr),
stats: SessionStats::default(),
})
}
pub(super) fn parse_antigravity_response_with_fallback(
stdout: &str,
stderr: &str,
) -> ParsedResponse {
ParsedResponse {
content: fallback_response(stdout, stderr),
stats: SessionStats::default(),
}
}
pub(super) fn parse_antigravity_stream_output_line(_stdout_line: &str) -> Option<(String, bool)> {
None
}
fn parse_claude_response(stdout: &str) -> Option<ParsedResponse> {
let trimmed_stdout = stdout.trim();
if trimmed_stdout.is_empty() {
return None;
}
let mut latest_result_stats: Option<SessionStats> = None;
let mut latest_non_empty_result: Option<ParsedResponse> = None;
let mut latest_stream_message_content: Option<String> = None;
record_claude_complete_payload(
trimmed_stdout,
&mut latest_result_stats,
&mut latest_non_empty_result,
);
for line in stdout.lines() {
let trimmed_line = line.trim();
let Some(stream_event) = record_claude_line_payload(
trimmed_line,
&mut latest_result_stats,
&mut latest_non_empty_result,
) else {
continue;
};
if let Some(stats) = extract_claude_usage_stats(&stream_event) {
latest_result_stats = Some(stats);
}
if let Some(content) = extract_claude_stream_assistant_content(&stream_event) {
latest_stream_message_content = Some(content);
}
}
if let Some(parsed_response) = latest_non_empty_result {
return Some(parsed_response);
}
let content = latest_stream_message_content?;
if content.trim().is_empty() {
return None;
}
let stats = latest_result_stats.unwrap_or_default();
Some(ParsedResponse { content, stats })
}
fn record_claude_complete_payload(
payload: &str,
latest_result_stats: &mut Option<SessionStats>,
latest_non_empty_result: &mut Option<ParsedResponse>,
) {
if let Some(parsed_response) = parse_claude_response_payload(payload) {
record_claude_parsed_response(
parsed_response,
latest_result_stats,
latest_non_empty_result,
);
}
if let Some(parsed_response) = parse_claude_response_array_payload(payload) {
record_claude_parsed_response(
parsed_response,
latest_result_stats,
latest_non_empty_result,
);
}
}
fn record_claude_line_payload(
trimmed_line: &str,
latest_result_stats: &mut Option<SessionStats>,
latest_non_empty_result: &mut Option<ParsedResponse>,
) -> Option<serde_json::Value> {
if trimmed_line.is_empty() {
return None;
}
if let Some(parsed_response) = parse_claude_response_payload(trimmed_line) {
record_claude_parsed_response(
parsed_response,
latest_result_stats,
latest_non_empty_result,
);
return None;
}
if let Some(parsed_response) = parse_claude_response_array_payload(trimmed_line) {
record_claude_parsed_response(
parsed_response,
latest_result_stats,
latest_non_empty_result,
);
return None;
}
serde_json::from_str::<serde_json::Value>(trimmed_line).ok()
}
fn record_claude_parsed_response(
parsed_response: ParsedResponse,
latest_result_stats: &mut Option<SessionStats>,
latest_non_empty_result: &mut Option<ParsedResponse>,
) {
latest_result_stats.replace(parsed_response.stats.clone());
if !parsed_response.content.trim().is_empty() {
latest_non_empty_result.replace(parsed_response);
}
}
pub(super) fn parse_claude_stream_output_line(stdout_line: &str) -> Option<(String, bool)> {
let trimmed_line = stdout_line.trim();
if trimmed_line.is_empty() {
return None;
}
let stream_event = serde_json::from_str::<serde_json::Value>(trimmed_line).ok()?;
if let Some(content) = extract_claude_stream_assistant_content(&stream_event) {
return Some((content, true));
}
let progress_message = compact_progress_message_from_json(&stream_event)?;
Some((progress_message, false))
}
fn parse_gemini_response(stdout: &str) -> Option<ParsedResponse> {
let trimmed_stdout = stdout.trim();
if let Some(parsed_response) = parse_gemini_response_payload(trimmed_stdout) {
return Some(parsed_response);
}
let mut latest_legacy_response: Option<ParsedResponse> = None;
let mut stream_response = String::new();
let mut stream_stats: Option<SessionStats> = None;
for line in stdout.lines() {
let trimmed_line = line.trim();
if trimmed_line.is_empty() {
continue;
}
if let Some(parsed_response) = parse_gemini_response_payload(trimmed_line) {
latest_legacy_response = Some(parsed_response);
continue;
}
if let Some(stream_chunk) = extract_gemini_stream_response(trimmed_line) {
stream_response.push_str(&stream_chunk);
continue;
}
if let Some(parsed_stream_stats) = extract_gemini_stream_stats(trimmed_line) {
stream_stats = Some(parsed_stream_stats);
}
}
if let Some(parsed_response) = latest_legacy_response {
return Some(parsed_response);
}
if stream_response.is_empty() && stream_stats.is_none() {
return None;
}
let stats = stream_stats.unwrap_or_default();
Some(ParsedResponse {
content: stream_response,
stats,
})
}
pub(super) fn parse_gemini_stream_output_line(stdout_line: &str) -> Option<(String, bool)> {
let trimmed_line = stdout_line.trim();
if trimmed_line.is_empty() {
return None;
}
let stream_event = serde_json::from_str::<serde_json::Value>(trimmed_line).ok()?;
if let Some(content) = extract_gemini_stream_response_from_json(&stream_event) {
return Some((content, true));
}
let progress_message = compact_progress_message_from_json(&stream_event)?;
Some((progress_message, false))
}
fn parse_codex_response(stdout: &str) -> Option<ParsedResponse> {
let mut last_agent_message: Option<String> = None;
let mut last_reasoning_message: Option<String> = None;
let mut total_input_tokens: u64 = 0;
let mut total_output_tokens: u64 = 0;
for line in stdout.lines() {
let trimmed_line = line.trim();
if trimmed_line.is_empty() {
continue;
}
let Ok(event) = serde_json::from_str::<CodexEvent>(trimmed_line) else {
continue;
};
record_codex_turn_usage(&event, &mut total_input_tokens, &mut total_output_tokens);
if event.event_type.as_deref() != Some("item.completed") {
continue;
}
let Some(item) = event.item else {
continue;
};
record_codex_completed_item(item, &mut last_agent_message, &mut last_reasoning_message);
}
let stats = SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: total_input_tokens,
output_tokens: total_output_tokens,
};
last_agent_message
.or(last_reasoning_message)
.map(|content| ParsedResponse { content, stats })
}
fn record_codex_turn_usage(
event: &CodexEvent,
total_input_tokens: &mut u64,
total_output_tokens: &mut u64,
) {
if event.event_type.as_deref() != Some("turn.completed") {
return;
}
let Some(usage) = event.usage.as_ref() else {
return;
};
*total_input_tokens += usage.input_tokens.unwrap_or(0).cast_unsigned();
*total_output_tokens += usage.output_tokens.unwrap_or(0).cast_unsigned();
}
fn record_codex_completed_item(
item: CodexItem,
last_agent_message: &mut Option<String>,
last_reasoning_message: &mut Option<String>,
) {
let Some(text) = item.text.or(item.delta) else {
return;
};
if is_codex_completion_status_message(&text) {
return;
}
let item_type = item.item_type.as_deref().unwrap_or_default();
if item_type == "agent_message" || item_type == "agentmessage" {
last_agent_message.replace(text);
return;
}
if item_type == "reasoning" || item_type == "thought" {
last_reasoning_message.replace(text);
}
}
pub(super) fn parse_codex_stream_output_line(stdout_line: &str) -> Option<(String, bool)> {
let trimmed_line = stdout_line.trim();
if trimmed_line.is_empty() {
return None;
}
let event = serde_json::from_str::<CodexEvent>(trimmed_line).ok()?;
let event_type = event.event_type.as_deref();
if event_type == Some("item.started") {
let item = event.item.as_ref()?;
let item_type = item.item_type.as_deref()?;
let progress_message = compact_codex_progress_message(item_type)?;
return Some((progress_message, false));
}
if event_type == Some("item.updated") {
let item = event.item.as_ref()?;
let item_type = item.item_type.as_deref()?;
if item_type == "agent_message"
|| item_type == "agentmessage"
|| item_type == "reasoning"
|| item_type == "thought"
{
let delta = item.delta.as_ref().or(item.text.as_ref())?.clone();
let is_response_content = item_type == "agent_message" || item_type == "agentmessage";
return Some((delta, is_response_content));
}
}
if event_type != Some("item.completed") {
return None;
}
let item = event.item?;
let item_type = item.item_type.as_deref()?;
if !(item_type == "agent_message"
|| item_type == "agentmessage"
|| item_type == "reasoning"
|| item_type == "thought")
{
return None;
}
let text = item.text?;
if is_codex_completion_status_message(&text) {
return None;
}
let is_response_content = item_type == "agent_message" || item_type == "agentmessage";
Some((text, is_response_content))
}
fn parse_claude_response_payload(stdout: &str) -> Option<ParsedResponse> {
let response = serde_json::from_str::<ClaudeResponse>(stdout).ok()?;
let content = response.result?;
let stats = SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: response
.usage
.as_ref()
.and_then(|usage| usage.input_tokens)
.unwrap_or(0)
.cast_unsigned(),
output_tokens: response
.usage
.as_ref()
.and_then(|usage| usage.output_tokens)
.unwrap_or(0)
.cast_unsigned(),
};
Some(ParsedResponse { content, stats })
}
fn parse_gemini_response_payload(stdout: &str) -> Option<ParsedResponse> {
let response = serde_json::from_str::<GeminiResponse>(stdout).ok()?;
let content = response.response?;
let stats = extract_gemini_stats(response.stats.as_ref());
Some(ParsedResponse { content, stats })
}
fn extract_gemini_stats(stats: Option<&GeminiStats>) -> SessionStats {
let Some(tokens) = stats
.and_then(|stats| stats.models.as_ref())
.and_then(|models| models.values().next())
.and_then(|model| model.tokens.as_ref())
else {
return SessionStats::default();
};
SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: tokens.input.unwrap_or(0).cast_unsigned(),
output_tokens: tokens.candidates.unwrap_or(0).cast_unsigned(),
}
}
fn extract_gemini_stream_response(stdout_line: &str) -> Option<String> {
let stream_event = serde_json::from_str::<serde_json::Value>(stdout_line).ok()?;
extract_gemini_stream_response_from_json(&stream_event)
}
fn extract_gemini_stream_response_from_json(stream_event: &serde_json::Value) -> Option<String> {
if let Some(response) = stream_event
.get("response")
.and_then(serde_json::Value::as_str)
&& !response.is_empty()
{
return Some(response.to_string());
}
let event_type = stream_event
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if !matches!(event_type, "content" | "delta" | "message" | "response") {
return None;
}
stream_event
.get("text")
.or_else(|| stream_event.get("delta"))
.or_else(|| stream_event.get("content"))
.and_then(serde_json::Value::as_str)
.filter(|text| !text.is_empty())
.map(ToString::to_string)
}
fn extract_gemini_stream_stats(stdout_line: &str) -> Option<SessionStats> {
let stream_event = serde_json::from_str::<GeminiStreamEvent>(stdout_line).ok()?;
if stream_event.event_type.as_deref() != Some("result") {
return None;
}
let stats = stream_event.stats?;
Some(SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: stats.input_tokens.unwrap_or(0).cast_unsigned(),
output_tokens: stats.output_tokens.unwrap_or(0).cast_unsigned(),
})
}
fn parse_claude_response_array_payload(stdout: &str) -> Option<ParsedResponse> {
let events = serde_json::from_str::<serde_json::Value>(stdout).ok()?;
let events = events.as_array()?;
let mut latest_result_content: Option<String> = None;
let mut latest_assistant_content: Option<String> = None;
let mut latest_stats: Option<SessionStats> = None;
for event in events {
if let Some(stats) = extract_claude_usage_stats(event) {
latest_stats = Some(stats);
}
if let Some(result_content) = event.get("result").and_then(serde_json::Value::as_str)
&& !result_content.trim().is_empty()
{
latest_result_content = Some(result_content.to_string());
}
if let Some(assistant_content) = extract_claude_stream_assistant_content(event) {
latest_assistant_content = Some(assistant_content);
}
}
let content = latest_result_content.or(latest_assistant_content)?;
let stats = latest_stats.unwrap_or_default();
Some(ParsedResponse { content, stats })
}
fn extract_claude_usage_stats(stream_event: &serde_json::Value) -> Option<SessionStats> {
let usage = stream_event.get("usage")?;
Some(SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: usage
.get("input_tokens")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0)
.cast_unsigned(),
output_tokens: usage
.get("output_tokens")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0)
.cast_unsigned(),
})
}
fn extract_claude_stream_assistant_content(stream_event: &serde_json::Value) -> Option<String> {
let message = extract_claude_stream_assistant_message(stream_event)?;
if message.get("role").and_then(serde_json::Value::as_str) != Some("assistant") {
return None;
}
if let Some(content) = message.get("content").and_then(serde_json::Value::as_str) {
let trimmed_content = content.trim();
if !trimmed_content.is_empty() {
return Some(trimmed_content.to_string());
}
}
let content_blocks = message
.get("content")
.and_then(serde_json::Value::as_array)?;
let text = content_blocks
.iter()
.filter_map(extract_claude_stream_content_block_text)
.collect::<Vec<_>>()
.join("");
if text.is_empty() {
return None;
}
Some(text)
}
fn extract_claude_stream_assistant_message(
stream_event: &serde_json::Value,
) -> Option<&serde_json::Value> {
let event_type = stream_event
.get("type")
.and_then(serde_json::Value::as_str)?;
match event_type {
"assistant" => stream_event.get("message"),
"message" => Some(stream_event),
_ => None,
}
}
fn extract_claude_stream_content_block_text(content_block: &serde_json::Value) -> Option<&str> {
let block_type = content_block
.get("type")
.and_then(serde_json::Value::as_str)?;
if block_type != "text" && block_type != "text_delta" {
return None;
}
if let Some(text) = content_block
.get("text")
.and_then(serde_json::Value::as_str)
{
return Some(text);
}
content_block
.get("delta")
.and_then(|delta| delta.get("text"))
.and_then(serde_json::Value::as_str)
}
fn compact_progress_message_from_json(stream_event: &serde_json::Value) -> Option<String> {
let assistant_content_type = stream_event
.get("message")
.and_then(|message| message.get("content"))
.and_then(serde_json::Value::as_array)
.and_then(|content| {
content.iter().find_map(|content_block| {
content_block
.get("type")
.and_then(serde_json::Value::as_str)
})
});
if let Some(progress_message) =
assistant_content_type.and_then(compact_progress_message_from_stream_label)
{
return Some(progress_message);
}
let item_type = stream_event
.get("item")
.and_then(|item| item.get("type"))
.and_then(serde_json::Value::as_str);
if let Some(progress_message) = item_type.and_then(compact_progress_message_from_stream_label) {
return Some(progress_message);
}
let tool_name = stream_event
.get("tool_name")
.and_then(serde_json::Value::as_str);
if let Some(progress_message) = tool_name.and_then(compact_progress_message_from_stream_label) {
return Some(progress_message);
}
let name = stream_event.get("name").and_then(serde_json::Value::as_str);
if let Some(progress_message) = name.and_then(compact_progress_message_from_stream_label) {
return Some(progress_message);
}
let tool_name = stream_event
.get("tool")
.and_then(|tool| tool.get("name"))
.and_then(serde_json::Value::as_str);
if let Some(progress_message) = tool_name.and_then(compact_progress_message_from_stream_label) {
return Some(progress_message);
}
let event = stream_event
.get("event")
.and_then(serde_json::Value::as_str);
if let Some(progress_message) = event.and_then(compact_progress_message_from_stream_label) {
return Some(progress_message);
}
let subtype = stream_event
.get("subtype")
.and_then(serde_json::Value::as_str);
if let Some(progress_message) = subtype.and_then(compact_progress_message_from_stream_label) {
return Some(progress_message);
}
let event_type = stream_event.get("type").and_then(serde_json::Value::as_str);
if let Some(progress_message) = event_type.and_then(compact_progress_message_from_stream_label)
{
return Some(progress_message);
}
None
}
fn compact_progress_message_from_stream_label(label: &str) -> Option<String> {
let normalized_label = label.to_ascii_lowercase().replace('-', "_");
if normalized_label.contains("search") {
return Some("Searching the web".to_string());
}
if normalized_label.contains("compact") || normalized_label.contains("compress") {
return Some("Compacting context".to_string());
}
if normalized_label.contains("reasoning")
|| normalized_label.contains("thinking")
|| normalized_label.contains("thought")
{
return Some("Thinking".to_string());
}
if normalized_label.contains("command")
|| normalized_label.contains("bash")
|| normalized_label.contains("terminal")
|| normalized_label.contains("shell")
|| normalized_label.contains("tool_use")
|| normalized_label.contains("tool_call")
|| normalized_label.contains("toolcall")
|| normalized_label.contains("execute")
{
return Some("Running a command".to_string());
}
None
}
pub fn compact_codex_progress_message(item_type: &str) -> Option<String> {
match item_type {
"agent_message" => None,
"command_execution" => Some("Running a command".to_string()),
"context_compaction" => Some("Compacting context".to_string()),
"reasoning" | "thought" => Some("Thinking".to_string()),
"web_search" => Some("Searching the web".to_string()),
other => Some(format!("Working: {}", other.replace('_', " "))),
}
}
pub fn is_codex_completion_status_message(message: &str) -> bool {
let normalized_message = message.trim().trim_end_matches('.').to_ascii_lowercase();
matches!(
normalized_message.as_str(),
"command completed"
| "command execution completed"
| "thinking completed"
| "reasoning completed"
| "search completed"
| "web search completed"
)
}
fn fallback_response(stdout: &str, stderr: &str) -> String {
let trimmed_stdout = stdout.trim();
if trimmed_stdout.is_empty() {
return stderr.trim().to_string();
}
trimmed_stdout.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_claude_parse_response_reads_result_payload() {
let stdout =
r#"{"result":"Planned response","usage":{"input_tokens":11,"output_tokens":7}}"#;
let parsed = parse_claude_response_with_fallback(stdout, "");
assert_eq!(parsed.content, "Planned response");
assert_eq!(parsed.stats.input_tokens, 11);
assert_eq!(parsed.stats.output_tokens, 7);
}
#[test]
fn test_antigravity_parse_response_reads_plain_stdout() {
let stdout = r#"{"answer":"Antigravity ok","questions":[],"summary":null}"#;
let stderr = "diagnostics";
let parsed = parse_antigravity_response_with_fallback(stdout, stderr);
assert_eq!(parsed.content, stdout);
assert_eq!(parsed.stats, SessionStats::default());
}
#[test]
fn test_antigravity_stream_output_line_is_not_incremental() {
let stdout_line = "partial output";
let parsed_line = parse_antigravity_stream_output_line(stdout_line);
assert_eq!(parsed_line, None);
}
#[test]
fn test_claude_parse_response_uses_stream_message_when_result_is_empty() {
let stdout = concat!(
r#"{"type":"message","role":"assistant","content":"Recovered response payload"}"#,
"\n",
r#"{"type":"result","subtype":"success","result":"","usage":{"input_tokens":11,"output_tokens":7}}"#
);
let parsed = parse_claude_response_with_fallback(stdout, "");
assert_eq!(parsed.content, "Recovered response payload");
assert_eq!(parsed.stats.input_tokens, 11);
assert_eq!(parsed.stats.output_tokens, 7);
}
#[test]
fn test_claude_parse_response_reads_usage_when_result_is_missing() {
let stdout = concat!(
r#"{"type":"message","role":"assistant","content":"Recovered response payload"}"#,
"\n",
r#"{"type":"result","subtype":"success","usage":{"input_tokens":13,"output_tokens":5}}"#
);
let parsed = parse_claude_response_with_fallback(stdout, "");
assert_eq!(parsed.content, "Recovered response payload");
assert_eq!(parsed.stats.input_tokens, 13);
assert_eq!(parsed.stats.output_tokens, 5);
}
#[test]
fn test_claude_parse_response_reads_result_from_json_array_payload() {
let stdout = r#"[{"type":"system","subtype":"init"},{"type":"result","subtype":"success","result":"{\"answer\":\"Recovered payload\",\"questions\":[],\"summary\":null}","usage":{"input_tokens":5,"output_tokens":3}}]"#;
let parsed = parse_claude_response_with_fallback(stdout, "");
assert_eq!(
parsed.content,
r#"{"answer":"Recovered payload","questions":[],"summary":null}"#
);
assert_eq!(parsed.stats.input_tokens, 5);
assert_eq!(parsed.stats.output_tokens, 3);
}
#[test]
fn test_parse_claude_stream_output_line_reads_assistant_message_content_blocks() {
let stdout_line = concat!(
r#"{"type":"assistant","message":{"role":"assistant","content":["#,
r#"{"type":"text","text":"Partial "},{"type":"text","text":"answer"}]}}"#
);
let parsed_line = parse_claude_stream_output_line(stdout_line);
assert_eq!(parsed_line, Some(("Partial answer".to_string(), true)));
}
#[test]
fn test_parse_claude_stream_output_line_reads_message_event_content() {
let stdout_line = r#"{"type":"message","role":"assistant","content":"Partial answer"}"#;
let parsed_line = parse_claude_stream_output_line(stdout_line);
assert_eq!(parsed_line, Some(("Partial answer".to_string(), true)));
}
#[test]
fn test_parse_claude_stream_output_line_ignores_result_events_for_delta_streaming() {
let stdout_line = r#"{"type":"result","subtype":"success","result":"Final answer"}"#;
let parsed_line = parse_claude_stream_output_line(stdout_line);
assert_eq!(parsed_line, None);
}
#[test]
fn test_parse_claude_stream_output_line_maps_tool_use_blocks_to_progress() {
let stdout_line = concat!(
r#"{"type":"assistant","message":{"role":"assistant","content":["#,
r#"{"type":"tool_use","name":"Bash"}]}}"#
);
let parsed_line = parse_claude_stream_output_line(stdout_line);
assert_eq!(parsed_line, Some(("Running a command".to_string(), false)));
}
#[test]
fn test_compact_progress_message_from_stream_label_maps_compaction_labels() {
let compaction_label = "context_compaction";
let compression_label = "context-compression";
let compaction_progress = compact_progress_message_from_stream_label(compaction_label);
let compression_progress = compact_progress_message_from_stream_label(compression_label);
assert_eq!(compaction_progress, Some("Compacting context".to_string()));
assert_eq!(compression_progress, Some("Compacting context".to_string()));
}
#[test]
fn test_parse_response_codex_ignores_trailing_completion_status_message() {
let stdout = concat!(
r#"{"type":"item.completed","item":{"type":"agent_message","text":"Planned final answer"}}"#,
"\n",
r#"{"type":"turn.completed","usage":{"input_tokens":21,"output_tokens":8}}"#,
"\n",
r#"{"type":"item.completed","item":{"type":"agent_message","text":"Command completed"}}"#,
);
let parsed = parse_codex_response_with_fallback(stdout, "");
assert_eq!(parsed.content, "Planned final answer");
assert_eq!(parsed.stats.input_tokens, 21);
assert_eq!(parsed.stats.output_tokens, 8);
}
#[test]
fn test_parse_response_codex_prefers_agent_message_over_trailing_reasoning_payload() {
let stdout = concat!(
r#"{"type":"item.completed","item":{"type":"agent_message","text":"Focused review summary"}}"#,
"\n",
r#"{"type":"item.completed","item":{"type":"reasoning","text":"{\"title\":\"Inject clock dependency to enforce time boundaries\"}"}}"#,
"\n",
r#"{"type":"turn.completed","usage":{"input_tokens":13,"output_tokens":9}}"#,
);
let parsed = parse_codex_response_with_fallback(stdout, "");
assert_eq!(parsed.content, "Focused review summary");
assert_eq!(parsed.stats.input_tokens, 13);
assert_eq!(parsed.stats.output_tokens, 9);
}
#[test]
fn test_parse_response_codex_falls_back_to_reasoning_without_agent_message() {
let stdout = concat!(
r#"{"type":"item.completed","item":{"type":"reasoning","text":"Fallback reasoning output"}}"#,
"\n",
r#"{"type":"turn.completed","usage":{"input_tokens":5,"output_tokens":3}}"#,
);
let parsed = parse_codex_response_with_fallback(stdout, "");
assert_eq!(parsed.content, "Fallback reasoning output");
assert_eq!(parsed.stats.input_tokens, 5);
assert_eq!(parsed.stats.output_tokens, 3);
}
#[test]
fn test_parse_response_codex_ignores_completion_status_as_final_message() {
let stdout = concat!(
r#"{"type":"item.completed","item":{"type":"agent_message","text":"Final answer"}}"#,
"\n",
r#"{"type":"item.completed","item":{"type":"agent_message","text":"Command completed"}}"#,
"\n",
r#"{"type":"turn.completed","usage":{"input_tokens":13,"output_tokens":9}}"#,
);
let parsed = parse_codex_response_with_fallback(stdout, "");
assert_eq!(parsed.content, "Final answer");
assert_eq!(parsed.stats.input_tokens, 13);
assert_eq!(parsed.stats.output_tokens, 9);
}
#[test]
fn test_parse_stream_output_line_codex_ignores_completion_status_messages() {
let completion_status_lines = [
r#"{"type":"item.completed","item":{"type":"agent_message","text":"Command completed"}}"#,
r#"{"type":"item.completed","item":{"type":"agent_message","text":"Thinking completed"}}"#,
];
for completion_status_line in completion_status_lines {
let parsed_line = parse_codex_stream_output_line(completion_status_line);
assert_eq!(parsed_line, None);
}
}
#[test]
fn test_parse_stream_output_line_codex_keeps_assistant_message_content() {
let assistant_line =
r#"{"type":"item.completed","item":{"type":"agent_message","text":"Final answer"}}"#;
let parsed_line = parse_codex_stream_output_line(assistant_line);
assert_eq!(parsed_line, Some(("Final answer".to_string(), true)));
}
#[test]
fn test_parse_stream_output_line_codex_marks_reasoning_as_loader_text() {
let reasoning_line =
r#"{"type":"item.updated","item":{"type":"reasoning","delta":"Inspecting files"}}"#;
let parsed_line = parse_codex_stream_output_line(reasoning_line);
assert_eq!(parsed_line, Some(("Inspecting files".to_string(), false)));
}
#[test]
fn compact_codex_progress_message_returns_compacting_for_context_compaction() {
let progress = compact_codex_progress_message("context_compaction");
assert_eq!(progress, Some("Compacting context".to_string()));
}
}