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/// the `view_image` tool. The Responses provider reads this to forward the
63/// image to the model as `input_image` inside the tool output; nothing else
64/// writes it. Kept out of the scalar allow-list because it is a large data URL,
65/// not a display field.
66pub 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}