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 const VIEW_IMAGE_DISPLAY_KEY: &str = "__view_image";
66
67pub 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}