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";
67
68pub fn tool_display_payload(
69 _tool_name: Option<&str>,
70 arguments: Option<&Value>,
71 data: Option<&Value>,
72) -> Option<Value> {
73 let mut payload = Map::new();
74 merge_display_fields(&mut payload, arguments);
75 merge_display_fields(&mut payload, data);
76 if let Some(Value::Object(source)) = data
77 && let Some(image) = source.get(VIEW_IMAGE_DISPLAY_KEY)
78 {
79 payload.insert(VIEW_IMAGE_DISPLAY_KEY.to_string(), image.clone());
80 }
81 (!payload.is_empty()).then_some(Value::Object(payload))
82}
83
84fn merge_display_fields(payload: &mut Map<String, Value>, source: Option<&Value>) {
85 let Some(Value::Object(source)) = source else {
86 return;
87 };
88 for key in [
89 "path",
90 "dir",
91 "directory",
92 "file",
93 "action",
94 "query",
95 "url",
96 "pattern",
97 "regex",
98 "glob",
99 "command",
100 "cmd",
101 "shell_command",
102 "name",
103 "displayName",
104 "skill",
105 "shown",
106 "total_lines",
107 "next_offset",
108 "truncated",
109 "engine",
110 "candidate_files",
111 "verified_files",
112 "elapsed_ms",
113 "index_bytes",
114 "index_build_time_ms",
115 ] {
116 let Some(value) = source.get(key).and_then(display_value) else {
117 continue;
118 };
119 payload.insert(key.to_string(), value);
120 }
121}
122
123fn display_value(value: &Value) -> Option<Value> {
124 match value {
125 Value::String(text) if !text.is_empty() && text.len() <= 500 => Some(value.clone()),
126 Value::Number(_) | Value::Bool(_) => Some(value.clone()),
127 _ => None,
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
132pub struct FileChangeRecord {
133 pub path: String,
134 pub change_type: String,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138pub struct ContextCompactionRecord {
139 pub summary: String,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
143pub struct ErrorRecord {
144 pub message: String,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148pub enum TranscriptItem {
149 UserMessage(UserMessage),
150 AssistantMessage(AssistantMessage),
151 ReasoningSummary(ReasoningSummary),
152 ToolCall(ToolCallRecord),
153 ToolResult(ToolResultRecord),
154 FileChange(FileChangeRecord),
155 ContextCompaction(ContextCompactionRecord),
156 Error(ErrorRecord),
157 ProviderMetadata(serde_json::Value),
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use serde_json::json;
164
165 #[test]
166 fn tool_display_payload_keeps_only_small_whitelisted_fields() {
167 let payload = tool_display_payload(
168 Some("write_file"),
169 Some(&json!({
170 "path": "src/lib.rs",
171 "command": "cargo test",
172 "content": "do not persist me",
173 "query": "needle",
174 "api_key": "secret"
175 })),
176 Some(&json!({
177 "path": "src/main.rs",
178 "shown": 4,
179 "truncated": false,
180 "engine": "indexed",
181 "candidate_files": 2,
182 "elapsed_ms": 5,
183 "hunks": [{ "path": "src/main.rs" }]
184 })),
185 )
186 .expect("display payload");
187
188 assert_eq!(payload["path"], "src/main.rs");
189 assert_eq!(payload["command"], "cargo test");
190 assert_eq!(payload["query"], "needle");
191 assert_eq!(payload["shown"], 4);
192 assert_eq!(payload["truncated"], false);
193 assert_eq!(payload["engine"], "indexed");
194 assert_eq!(payload["candidate_files"], 2);
195 assert_eq!(payload["elapsed_ms"], 5);
196 assert!(payload.get("content").is_none());
197 assert!(payload.get("api_key").is_none());
198 assert!(payload.get("hunks").is_none());
199 }
200}