Skip to main content

claude_code_cli_acp/transcript/
events.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
5#[serde(rename_all = "snake_case")]
6pub enum TranscriptEventKind {
7    UserMessage,
8    AssistantMessage,
9    AssistantThought,
10    System,
11    ToolUse,
12    ToolResult,
13    Diagnostic,
14}
15
16#[derive(Clone, Serialize, Deserialize, PartialEq)]
17pub struct TranscriptEvent {
18    pub uuid: String,
19    pub session_id: Option<String>,
20    pub kind: TranscriptEventKind,
21    pub text: Option<String>,
22    pub id: Option<String>,
23    pub name: Option<String>,
24    pub model: Option<String>,
25    #[serde(default)]
26    pub is_error: bool,
27    #[serde(skip)]
28    pub raw_input: Option<Value>,
29    #[serde(skip)]
30    pub raw_output: Option<Value>,
31    pub redacted: RedactionSummary,
32}
33
34#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
35pub struct RedactionSummary {
36    pub text_redacted: bool,
37    pub char_count: usize,
38    pub line_count: usize,
39    pub value_kind: Option<String>,
40}
41
42impl std::fmt::Debug for TranscriptEvent {
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        formatter
45            .debug_struct("TranscriptEvent")
46            .field("uuid", &self.uuid)
47            .field("session_id", &self.session_id)
48            .field("kind", &self.kind)
49            .field("text", &self.text.as_ref().map(|_| "<redacted>"))
50            .field("id", &self.id)
51            .field("name", &self.name)
52            .field("model", &self.model)
53            .field("is_error", &self.is_error)
54            .field("raw_input", &self.raw_input.as_ref().map(|_| "<redacted>"))
55            .field(
56                "raw_output",
57                &self.raw_output.as_ref().map(|_| "<redacted>"),
58            )
59            .field("redacted", &self.redacted)
60            .finish()
61    }
62}
63
64impl TranscriptEvent {
65    pub fn session_id(&self) -> Option<&str> {
66        self.session_id.as_deref()
67    }
68}
69
70pub fn parse_transcript_line(line: &str) -> anyhow::Result<Vec<TranscriptEvent>> {
71    if line.trim().is_empty() {
72        return Ok(Vec::new());
73    }
74
75    let value: Value = serde_json::from_str(line)?;
76    Ok(parse_transcript_record(&value))
77}
78
79pub fn parse_transcript_record(value: &Value) -> Vec<TranscriptEvent> {
80    let session_id = extract_session_id(value);
81    let record_type = string_at(value, &["type"]).unwrap_or("unknown").to_string();
82
83    if record_type == "tool_use" {
84        return vec![tool_use_event(value, session_id, 0)];
85    }
86    if record_type == "tool_result" {
87        return vec![tool_result_event(value, session_id, 0)];
88    }
89
90    let message = value.get("message").unwrap_or(value);
91    let role = string_at(message, &["role"])
92        .or_else(|| string_at(value, &["role"]))
93        .unwrap_or(record_type.as_str());
94    let model = string_at(message, &["model"]).map(str::to_string);
95    let content = message.get("content").or_else(|| value.get("content"));
96
97    match content {
98        Some(Value::Array(items)) => {
99            let mut events = Vec::new();
100            for (index, item) in items.iter().enumerate() {
101                events.extend(parse_content_item(
102                    item,
103                    role,
104                    &record_type,
105                    session_id.clone(),
106                    model.clone(),
107                    index,
108                ));
109            }
110            events
111        }
112        Some(content) => text_events(
113            role,
114            &record_type,
115            session_id,
116            model,
117            text_from_value(content),
118            0,
119        ),
120        None => vec![diagnostic_event(
121            session_id,
122            record_type,
123            text_from_value(value),
124            0,
125        )],
126    }
127}
128
129fn parse_content_item(
130    item: &Value,
131    role: &str,
132    record_type: &str,
133    session_id: Option<String>,
134    model: Option<String>,
135    index: usize,
136) -> Vec<TranscriptEvent> {
137    match string_at(item, &["type"]) {
138        Some("text") => text_events(
139            role,
140            record_type,
141            session_id,
142            model,
143            text_from_value(item.get("text").unwrap_or(item)),
144            index,
145        ),
146        Some("tool_use") => vec![tool_use_event(item, session_id, index)],
147        Some("tool_result") => vec![tool_result_event(item, session_id, index)],
148        Some(kind) => vec![diagnostic_event(
149            session_id,
150            kind.to_string(),
151            text_from_value(item),
152            index,
153        )],
154        None => text_events(
155            role,
156            record_type,
157            session_id,
158            model,
159            text_from_value(item),
160            index,
161        ),
162    }
163}
164
165fn text_events(
166    role: &str,
167    record_type: &str,
168    session_id: Option<String>,
169    model: Option<String>,
170    text: String,
171    index: usize,
172) -> Vec<TranscriptEvent> {
173    let text = if role == "user" {
174        strip_local_command_metadata(&text)
175    } else {
176        text
177    };
178    if role == "user" && text.trim().is_empty() {
179        return Vec::new();
180    }
181    let kind = match role {
182        "user" => TranscriptEventKind::UserMessage,
183        "assistant" => TranscriptEventKind::AssistantMessage,
184        "system" => TranscriptEventKind::System,
185        _ => TranscriptEventKind::Diagnostic,
186    };
187    let uuid = event_uuid(session_id.as_deref(), record_type, index);
188    vec![TranscriptEvent {
189        uuid,
190        session_id,
191        kind,
192        redacted: summarize_text(&text),
193        text: Some(text),
194        id: None,
195        name: None,
196        model,
197        is_error: false,
198        raw_input: None,
199        raw_output: None,
200    }]
201}
202
203fn tool_use_event(value: &Value, session_id: Option<String>, index: usize) -> TranscriptEvent {
204    let id = string_at(value, &["id"]).map(str::to_string);
205    TranscriptEvent {
206        uuid: id
207            .clone()
208            .unwrap_or_else(|| event_uuid(session_id.as_deref(), "tool_use", index)),
209        session_id,
210        kind: TranscriptEventKind::ToolUse,
211        text: string_at(value, &["name"]).map(str::to_string),
212        id,
213        name: string_at(value, &["name"]).map(str::to_string),
214        model: None,
215        is_error: false,
216        raw_input: value.get("input").cloned(),
217        raw_output: None,
218        redacted: summarize_value(value.get("input").unwrap_or(&Value::Null)),
219    }
220}
221
222fn tool_result_event(value: &Value, session_id: Option<String>, index: usize) -> TranscriptEvent {
223    let raw_output = value.get("content").cloned();
224    let text = text_from_value(raw_output.as_ref().unwrap_or(value));
225    let id = string_at(value, &["tool_use_id"]).map(str::to_string);
226    TranscriptEvent {
227        uuid: id
228            .clone()
229            .unwrap_or_else(|| event_uuid(session_id.as_deref(), "tool_result", index)),
230        session_id,
231        kind: TranscriptEventKind::ToolResult,
232        redacted: summarize_text(&text),
233        text: Some(text),
234        id,
235        name: None,
236        model: None,
237        is_error: value
238            .get("is_error")
239            .and_then(Value::as_bool)
240            .unwrap_or(false),
241        raw_input: None,
242        raw_output,
243    }
244}
245
246fn diagnostic_event(
247    session_id: Option<String>,
248    record_type: String,
249    text: String,
250    index: usize,
251) -> TranscriptEvent {
252    TranscriptEvent {
253        uuid: event_uuid(session_id.as_deref(), &record_type, index),
254        session_id,
255        kind: TranscriptEventKind::Diagnostic,
256        redacted: summarize_text(&text),
257        text: Some(text),
258        id: None,
259        name: Some(record_type),
260        model: None,
261        is_error: false,
262        raw_input: None,
263        raw_output: None,
264    }
265}
266
267fn extract_session_id(value: &Value) -> Option<String> {
268    string_at(value, &["sessionId"])
269        .or_else(|| string_at(value, &["session_id"]))
270        .or_else(|| string_at(value, &["sessionID"]))
271        .map(str::to_string)
272}
273
274fn string_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
275    let mut current = value;
276    for part in path {
277        current = current.get(part)?;
278    }
279    current.as_str()
280}
281
282fn text_from_value(value: &Value) -> String {
283    match value {
284        Value::String(text) => text.clone(),
285        Value::Array(items) => items
286            .iter()
287            .filter_map(|item| {
288                item.as_str()
289                    .or_else(|| item.get("text").and_then(Value::as_str))
290            })
291            .collect::<Vec<_>>()
292            .join("\n"),
293        _ => value.to_string(),
294    }
295}
296
297pub fn strip_local_command_metadata(text: &str) -> String {
298    let mut stripped = text.to_string();
299    for tag in [
300        "command-name",
301        "command-message",
302        "command-args",
303        "local-command-stdout",
304        "local-command-stderr",
305    ] {
306        let start_tag = format!("<{tag}>");
307        let end_tag = format!("</{tag}>");
308        while let Some(start) = stripped.find(&start_tag) {
309            let body_start = start + start_tag.len();
310            let Some(relative_end) = stripped[body_start..].find(&end_tag) else {
311                break;
312            };
313            let end = body_start + relative_end + end_tag.len();
314            stripped.replace_range(start..end, "");
315        }
316    }
317    stripped.trim().to_string()
318}
319
320fn summarize_text(text: &str) -> RedactionSummary {
321    RedactionSummary {
322        text_redacted: true,
323        char_count: text.chars().count(),
324        line_count: text.lines().count().max(usize::from(!text.is_empty())),
325        value_kind: None,
326    }
327}
328
329fn summarize_value(value: &Value) -> RedactionSummary {
330    RedactionSummary {
331        text_redacted: true,
332        char_count: 0,
333        line_count: 0,
334        value_kind: Some(
335            match value {
336                Value::Null => "null",
337                Value::Bool(_) => "bool",
338                Value::Number(_) => "number",
339                Value::String(_) => "string",
340                Value::Array(_) => "array",
341                Value::Object(_) => "object",
342            }
343            .to_string(),
344        ),
345    }
346}
347
348fn event_uuid(session_id: Option<&str>, record_type: &str, index: usize) -> String {
349    format!(
350        "{}:{record_type}:{index}",
351        session_id.unwrap_or("transcript")
352    )
353}