use serde_json::Value;
use std::collections::HashMap;
fn floor_char_boundary(s: &str, i: usize) -> usize {
if i >= s.len() {
return s.len();
}
let mut pos = i;
while pos > 0 && !s.is_char_boundary(pos) {
pos -= 1;
}
pos
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterAction {
Keep,
Progress,
Drop,
}
pub fn classify_record(json: &Value) -> FilterAction {
let record_type = json.get("type").and_then(|v| v.as_str()).unwrap_or("");
match record_type {
"user" | "assistant" => FilterAction::Keep,
"system" => {
let subtype = json.get("subtype").and_then(|v| v.as_str()).unwrap_or("");
if subtype == "turn_duration" {
FilterAction::Drop
} else {
FilterAction::Keep
}
}
"progress" => FilterAction::Progress,
"file-history-snapshot" | "queue-operation" => FilterAction::Keep,
_ => FilterAction::Drop,
}
}
pub fn update_progress_last(progress_map: &mut HashMap<String, Value>, record: &Value) {
let tool_use_id = record
.get("toolUseID")
.or_else(|| record.get("tool_use_id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if tool_use_id.is_empty() {
return;
}
let max_output_chars: usize = std::env::var("EDDA_PROGRESS_OUTPUT_CHARS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(600);
let max_tools: usize = std::env::var("EDDA_PROGRESS_MAX_TOOLS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(200);
let mut record = record.clone();
if let Some(data) = record.get_mut("data") {
let needs_truncate = data
.get("output")
.and_then(|v| v.as_str())
.map(|s| s.len() > max_output_chars)
.unwrap_or(false);
if needs_truncate {
if let Some(output) = data["output"].as_str() {
let end = floor_char_boundary(output, max_output_chars);
let truncated = output[..end].to_string();
if let Some(obj) = data.as_object_mut() {
obj.insert("output".into(), Value::String(truncated));
}
}
}
}
progress_map.insert(tool_use_id, record);
while progress_map.len() > max_tools {
if let Some(key) = progress_map.keys().next().cloned() {
progress_map.remove(&key);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_user_keep() {
let v = serde_json::json!({"type": "user", "message": "hello"});
assert_eq!(classify_record(&v), FilterAction::Keep);
}
#[test]
fn classify_assistant_keep() {
let v = serde_json::json!({"type": "assistant"});
assert_eq!(classify_record(&v), FilterAction::Keep);
}
#[test]
fn classify_system_keep() {
let v = serde_json::json!({"type": "system", "subtype": "init"});
assert_eq!(classify_record(&v), FilterAction::Keep);
}
#[test]
fn classify_system_turn_duration_drop() {
let v = serde_json::json!({"type": "system", "subtype": "turn_duration"});
assert_eq!(classify_record(&v), FilterAction::Drop);
}
#[test]
fn classify_progress() {
let v = serde_json::json!({"type": "progress"});
assert_eq!(classify_record(&v), FilterAction::Progress);
}
#[test]
fn classify_unknown_drop() {
let v = serde_json::json!({"type": "unknown_type"});
assert_eq!(classify_record(&v), FilterAction::Drop);
}
#[test]
fn truncate_respects_char_boundary() {
let mut map = HashMap::new();
let prefix = "x".repeat(598); let output = format!("{prefix}後後後 tail"); assert!(!output.is_char_boundary(600));
let r = serde_json::json!({
"toolUseID": "t_utf8",
"data": { "output": output }
});
update_progress_last(&mut map, &r);
let stored = map["t_utf8"]["data"]["output"].as_str().unwrap();
assert_eq!(stored.len(), 598);
assert!(stored.chars().all(|c| c == 'x'));
}
#[test]
fn floor_char_boundary_basic() {
assert_eq!(super::floor_char_boundary("hello", 3), 3);
assert_eq!(super::floor_char_boundary("hello", 100), 5);
let s = "ab後cd"; assert_eq!(super::floor_char_boundary(s, 3), 2); assert_eq!(super::floor_char_boundary(s, 4), 2); assert_eq!(super::floor_char_boundary(s, 5), 5); }
#[test]
fn progress_last_keeps_latest() {
let mut map = HashMap::new();
let r1 = serde_json::json!({"toolUseID": "t1", "data": {"output": "old"}});
let r2 = serde_json::json!({"toolUseID": "t1", "data": {"output": "new"}});
update_progress_last(&mut map, &r1);
update_progress_last(&mut map, &r2);
assert_eq!(map.len(), 1);
assert_eq!(map["t1"]["data"]["output"], "new");
}
}