use anyhow::Result;
use serde_json::{Value, json};
use super::super::helpers::{normalize_name, optional_string, required_string};
use super::metadata::{
timeline_collapse_hint, timeline_lifecycle_info, timeline_metadata, timeline_render_kind,
timeline_stream_metadata,
};
use crate::bridge_protocol::{ThreadStatusInfo, ThreadSummary, ThreadTokenUsage};
pub(super) fn normalize_thread(
runtime_id: &str,
thread: &Value,
archived: bool,
) -> Result<ThreadSummary> {
let cwd = required_string(thread, "cwd")?.to_string();
let status_value = thread.get("status").unwrap_or(&Value::Null);
let status_info = normalize_thread_status_info(status_value);
let status = status_info.kind.clone();
let is_loaded = status != "notLoaded";
let is_active = status == "active";
let source = match thread.get("source") {
Some(Value::String(value)) => value.clone(),
Some(other) => other.to_string(),
None => "unknown".to_string(),
};
Ok(ThreadSummary {
id: required_string(thread, "id")?.to_string(),
runtime_id: runtime_id.to_string(),
name: normalize_name(optional_string(thread, "name")),
preview: optional_string(thread, "preview").unwrap_or_default(),
cwd,
status,
status_info,
token_usage: thread
.get("tokenUsage")
.or_else(|| thread.get("usage"))
.map(normalize_thread_token_usage),
model_provider: optional_string(thread, "modelProvider")
.unwrap_or_else(|| "unknown".to_string()),
source,
created_at: thread
.get("createdAt")
.and_then(Value::as_i64)
.unwrap_or_default(),
updated_at: thread
.get("updatedAt")
.and_then(Value::as_i64)
.unwrap_or_default(),
is_loaded,
is_active,
archived,
})
}
pub(super) fn normalize_delta_payload(
runtime_id: &str,
params: Value,
entry_type: &str,
title: Option<String>,
status: Option<String>,
raw_type: &str,
payload: Value,
summary_index: Option<i64>,
content_index: Option<i64>,
) -> Value {
let delta = params
.get("delta")
.and_then(Value::as_str)
.unwrap_or_default();
let lifecycle = timeline_lifecycle_info(false, status.as_deref(), !delta.trim().is_empty());
json!({
"runtimeId": runtime_id,
"threadId": params.get("threadId"),
"turnId": params.get("turnId"),
"itemId": params.get("itemId"),
"delta": params.get("delta"),
"entryType": entry_type,
"title": title,
"status": status,
"metadata": timeline_metadata(
"stream_event",
raw_type,
timeline_render_kind(entry_type),
timeline_collapse_hint(timeline_render_kind(entry_type), entry_type),
timeline_stream_metadata(true, false, None, summary_index, content_index),
Some(&lifecycle),
payload,
None,
None,
params,
),
"summaryIndex": summary_index,
"contentIndex": content_index,
})
}
pub(super) fn timeline_entry_id(
turn_id: Option<&str>,
item_id: Option<&str>,
raw_type: &str,
) -> String {
format!(
"{}:{}",
turn_id.unwrap_or("turn"),
item_id.unwrap_or(raw_type)
)
}
pub(super) fn canonical_timeline_entry_type(raw_type: &str, item: &Value) -> String {
match raw_type {
"message" => {
return match message_role(item).as_deref() {
Some("user") => "userMessage".to_string(),
Some("assistant") => "agentMessage".to_string(),
Some("system") | Some("developer") => "hookPrompt".to_string(),
_ => raw_type.to_string(),
};
}
"user_message" => return "userMessage".to_string(),
"agent_message" => return "agentMessage".to_string(),
"collabAgentToolCall" => return "collabToolCall".to_string(),
"command_output" => return "commandExecution".to_string(),
"file_change_output" => return "fileChange".to_string(),
_ => {}
}
raw_type.to_string()
}
fn message_role(item: &Value) -> Option<String> {
optional_string(item, "role").map(|role| role.trim().to_lowercase())
}
fn normalize_thread_status_info(status_value: &Value) -> ThreadStatusInfo {
ThreadStatusInfo {
kind: status_value
.get("type")
.and_then(Value::as_str)
.unwrap_or_else(|| status_value.as_str().unwrap_or("unknown"))
.to_string(),
reason: optional_string(status_value, "reason"),
raw: status_value.clone(),
}
}
fn normalize_thread_token_usage(value: &Value) -> ThreadTokenUsage {
ThreadTokenUsage {
input_tokens: timeline_first_i64(value, &["inputTokens", "input_tokens"]),
cached_input_tokens: timeline_first_i64(
value,
&["cachedInputTokens", "cached_input_tokens"],
),
output_tokens: timeline_first_i64(value, &["outputTokens", "output_tokens"]),
reasoning_tokens: timeline_first_i64(value, &["reasoningTokens", "reasoning_tokens"]),
total_tokens: timeline_first_i64(value, &["totalTokens", "total_tokens"]),
raw: value.clone(),
updated_at_ms: value
.get("updatedAtMs")
.and_then(Value::as_i64)
.unwrap_or_default(),
}
}
fn timeline_first_i64(value: &Value, keys: &[&str]) -> Option<i64> {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_i64))
}