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
61/// Reserved `display_payload` key carrying an image content block produced by
62/// `view_image` and desktop observation tools. Providers forward the inline
63/// image to the model and ACP clients receive an image content block. Kept out
64/// of the scalar allow-list because it is a large data URL, not a display field.
65pub const VIEW_IMAGE_DISPLAY_KEY: &str = "__view_image";
66
67/// A bounded inline image carried by a tool result. Never fetch external URLs
68/// while replaying a tool result or forwarding it to an ACP client.
69pub fn tool_result_image(payload: Option<&Value>) -> Option<(&str, &str)> {
70    use base64::Engine;
71    let url = payload?
72        .get(VIEW_IMAGE_DISPLAY_KEY)?
73        .get("image_url")?
74        .as_str()?;
75    let (mime, data) = url.strip_prefix("data:")?.split_once(";base64,")?;
76    if !matches!(
77        mime,
78        "image/png" | "image/jpeg" | "image/webp" | "image/gif"
79    ) || data.is_empty()
80        || data.len() > 12 * 1024 * 1024
81    {
82        return None;
83    }
84    base64::engine::general_purpose::STANDARD
85        .decode(data)
86        .ok()?;
87    Some((mime, data))
88}
89
90pub fn tool_display_payload(
91    tool_name: Option<&str>,
92    arguments: Option<&Value>,
93    data: Option<&Value>,
94) -> Option<Value> {
95    let mut payload = Map::new();
96    merge_display_fields(&mut payload, arguments);
97    if tool_name.is_some_and(|name| name.starts_with("cua_"))
98        && let Some(Value::Object(arguments)) = arguments
99        && serde_json::to_vec(arguments).is_ok_and(|bytes| bytes.len() <= 32 * 1024)
100    {
101        payload.extend(arguments.clone());
102    }
103    merge_display_fields(&mut payload, data);
104    if let Some(Value::Object(source)) = data
105        && let Some(image) = source.get(VIEW_IMAGE_DISPLAY_KEY)
106    {
107        payload.insert(VIEW_IMAGE_DISPLAY_KEY.to_string(), image.clone());
108    }
109    (!payload.is_empty()).then_some(Value::Object(payload))
110}
111
112fn merge_display_fields(payload: &mut Map<String, Value>, source: Option<&Value>) {
113    let Some(Value::Object(source)) = source else {
114        return;
115    };
116    for key in [
117        "path",
118        "dir",
119        "directory",
120        "file",
121        "action",
122        "query",
123        "url",
124        "pattern",
125        "regex",
126        "glob",
127        "command",
128        "cmd",
129        "shell_command",
130        "name",
131        "displayName",
132        "skill",
133        "shown",
134        "total_lines",
135        "next_offset",
136        "truncated",
137        "engine",
138        "candidate_files",
139        "verified_files",
140        "elapsed_ms",
141        "index_bytes",
142        "index_build_time_ms",
143    ] {
144        let Some(value) = source.get(key).and_then(display_value) else {
145            continue;
146        };
147        payload.insert(key.to_string(), value);
148    }
149}
150
151fn display_value(value: &Value) -> Option<Value> {
152    match value {
153        Value::String(text) if !text.is_empty() && text.len() <= 500 => Some(value.clone()),
154        Value::Number(_) | Value::Bool(_) => Some(value.clone()),
155        _ => None,
156    }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
160pub struct FileChangeRecord {
161    pub path: String,
162    pub change_type: String,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
166pub struct ContextCompactionRecord {
167    pub summary: String,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171pub struct ErrorRecord {
172    pub message: String,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176pub enum TranscriptItem {
177    UserMessage(UserMessage),
178    AssistantMessage(AssistantMessage),
179    ReasoningSummary(ReasoningSummary),
180    ToolCall(ToolCallRecord),
181    ToolResult(ToolResultRecord),
182    FileChange(FileChangeRecord),
183    ContextCompaction(ContextCompactionRecord),
184    Error(ErrorRecord),
185    ProviderMetadata(serde_json::Value),
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use serde_json::json;
192
193    #[test]
194    fn tool_display_payload_keeps_only_small_whitelisted_fields() {
195        let payload = tool_display_payload(
196            Some("write_file"),
197            Some(&json!({
198                "path": "src/lib.rs",
199                "command": "cargo test",
200                "content": "do not persist me",
201                "query": "needle",
202                "api_key": "secret"
203            })),
204            Some(&json!({
205                "path": "src/main.rs",
206                "shown": 4,
207                "truncated": false,
208                "engine": "indexed",
209                "candidate_files": 2,
210                "elapsed_ms": 5,
211                "hunks": [{ "path": "src/main.rs" }]
212            })),
213        )
214        .expect("display payload");
215
216        assert_eq!(payload["path"], "src/main.rs");
217        assert_eq!(payload["command"], "cargo test");
218        assert_eq!(payload["query"], "needle");
219        assert_eq!(payload["shown"], 4);
220        assert_eq!(payload["truncated"], false);
221        assert_eq!(payload["engine"], "indexed");
222        assert_eq!(payload["candidate_files"], 2);
223        assert_eq!(payload["elapsed_ms"], 5);
224        assert!(payload.get("content").is_none());
225        assert!(payload.get("api_key").is_none());
226        assert!(payload.get("hunks").is_none());
227    }
228}