Skip to main content

roder_api/
transcript.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5pub struct InputImage {
6    pub image_url: String,
7}
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct UserMessage {
11    pub text: String,
12    #[serde(default, skip_serializing_if = "Vec::is_empty")]
13    pub images: Vec<InputImage>,
14}
15
16impl UserMessage {
17    pub fn text(text: impl Into<String>) -> Self {
18        Self {
19            text: text.into(),
20            images: Vec::new(),
21        }
22    }
23
24    pub fn with_images(text: impl Into<String>, images: Vec<InputImage>) -> Self {
25        Self {
26            text: text.into(),
27            images,
28        }
29    }
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct AssistantMessage {
34    pub text: String,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub phase: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct ReasoningSummary {
41    pub text: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub struct ToolCallRecord {
46    pub id: String,
47    pub name: String,
48    pub arguments: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct ToolResultRecord {
53    pub id: String,
54    pub name: Option<String>,
55    pub result: String,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub display_payload: Option<serde_json::Value>,
58    pub is_error: bool,
59}
60
61pub fn tool_display_payload(
62    _tool_name: Option<&str>,
63    arguments: Option<&Value>,
64    data: Option<&Value>,
65) -> Option<Value> {
66    let mut payload = Map::new();
67    merge_display_fields(&mut payload, arguments);
68    merge_display_fields(&mut payload, data);
69    (!payload.is_empty()).then_some(Value::Object(payload))
70}
71
72fn merge_display_fields(payload: &mut Map<String, Value>, source: Option<&Value>) {
73    let Some(Value::Object(source)) = source else {
74        return;
75    };
76    for key in [
77        "path",
78        "dir",
79        "directory",
80        "file",
81        "action",
82        "query",
83        "url",
84        "pattern",
85        "regex",
86        "glob",
87        "command",
88        "cmd",
89        "shell_command",
90        "name",
91        "displayName",
92        "skill",
93        "shown",
94        "total_lines",
95        "next_offset",
96        "truncated",
97        "engine",
98        "candidate_files",
99        "verified_files",
100        "elapsed_ms",
101        "index_bytes",
102        "index_build_time_ms",
103    ] {
104        let Some(value) = source.get(key).and_then(display_value) else {
105            continue;
106        };
107        payload.insert(key.to_string(), value);
108    }
109}
110
111fn display_value(value: &Value) -> Option<Value> {
112    match value {
113        Value::String(text) if !text.is_empty() && text.len() <= 500 => Some(value.clone()),
114        Value::Number(_) | Value::Bool(_) => Some(value.clone()),
115        _ => None,
116    }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
120pub struct FileChangeRecord {
121    pub path: String,
122    pub change_type: String,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
126pub struct ContextCompactionRecord {
127    pub summary: String,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
131pub struct ErrorRecord {
132    pub message: String,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub enum TranscriptItem {
137    UserMessage(UserMessage),
138    AssistantMessage(AssistantMessage),
139    ReasoningSummary(ReasoningSummary),
140    ToolCall(ToolCallRecord),
141    ToolResult(ToolResultRecord),
142    FileChange(FileChangeRecord),
143    ContextCompaction(ContextCompactionRecord),
144    Error(ErrorRecord),
145    ProviderMetadata(serde_json::Value),
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use serde_json::json;
152
153    #[test]
154    fn tool_display_payload_keeps_only_small_whitelisted_fields() {
155        let payload = tool_display_payload(
156            Some("write_file"),
157            Some(&json!({
158                "path": "src/lib.rs",
159                "command": "cargo test",
160                "content": "do not persist me",
161                "query": "needle",
162                "api_key": "secret"
163            })),
164            Some(&json!({
165                "path": "src/main.rs",
166                "shown": 4,
167                "truncated": false,
168                "engine": "indexed",
169                "candidate_files": 2,
170                "elapsed_ms": 5,
171                "hunks": [{ "path": "src/main.rs" }]
172            })),
173        )
174        .expect("display payload");
175
176        assert_eq!(payload["path"], "src/main.rs");
177        assert_eq!(payload["command"], "cargo test");
178        assert_eq!(payload["query"], "needle");
179        assert_eq!(payload["shown"], 4);
180        assert_eq!(payload["truncated"], false);
181        assert_eq!(payload["engine"], "indexed");
182        assert_eq!(payload["candidate_files"], 2);
183        assert_eq!(payload["elapsed_ms"], 5);
184        assert!(payload.get("content").is_none());
185        assert!(payload.get("api_key").is_none());
186        assert!(payload.get("hunks").is_none());
187    }
188}