Skip to main content

opencode_ctl/tools/
changes.rs

1//! `session_changes` and `workspace_status`.
2
3use std::path::Path;
4
5use serde_json::{json, Map, Value};
6
7use crate::clock::Clock;
8use crate::engine::{DiffKey, Engine};
9use crate::error::{ErrorCode, ToolError, ToolResult};
10use crate::transport::Transport;
11
12use super::{SessionChangesArgs, WorkspaceStatusArgs};
13
14/// Bytes kept free under the cap for the markers `finish` may add.
15const RESERVE: usize = 64;
16/// `,"patch":` plus the quotes and separator around a patch value.
17const PATCH_OVERHEAD: usize = 12;
18/// `,"patch_truncated":true`
19const FLAG_OVERHEAD: usize = 23;
20
21fn serialized_len(v: &Value) -> usize {
22    v.to_string().len()
23}
24
25fn escaped_len(c: char) -> usize {
26    match c {
27        '"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2,
28        c if (c as u32) < 0x20 => 6,
29        c => c.len_utf8(),
30    }
31}
32
33/// The longest prefix of `s` whose JSON-escaped form is at most `max` bytes.
34fn cut_escaped(s: &str, max: usize) -> String {
35    let mut used = 0;
36    let mut out = String::new();
37    for c in s.chars() {
38        let n = escaped_len(c);
39        if used + n > max {
40            break;
41        }
42        used += n;
43        out.push(c);
44    }
45    out
46}
47
48fn range_error(e: ToolError) -> ToolError {
49    let tag = e
50        .details
51        .as_ref()
52        .and_then(|d| d.get("tag"))
53        .and_then(Value::as_str);
54    if matches!(tag, Some("MessageNotFoundError" | "InvalidRequestError")) {
55        ToolError {
56            code: ErrorCode::InvalidRequest,
57            ..e
58        }
59    } else {
60        e
61    }
62}
63
64fn meta_of(file: &Value) -> Map<String, Value> {
65    let mut m = Map::new();
66    for k in ["file", "status", "additions", "deletions"] {
67        m.insert(k.to_string(), file.get(k).cloned().unwrap_or(Value::Null));
68    }
69    m
70}
71
72/// Fit the diff under `max` bytes: patches go first (`omitted_patches`), then file entries
73/// (their names move to `omitted_files`); one patch too big for the whole budget is cut.
74fn pack(head: &Map<String, Value>, files: &[Value], patches: bool, max: usize) -> Value {
75    let budget = max.saturating_sub(RESERVE);
76    let metas: Vec<Map<String, Value>> = files.iter().map(meta_of).collect();
77    let meta_cost: Vec<usize> = metas
78        .iter()
79        .map(|m| serialized_len(&Value::Object(m.clone())) + 1)
80        .collect();
81    let name_cost: Vec<usize> = metas
82        .iter()
83        .map(|m| serialized_len(&m["file"]) + 1)
84        .collect();
85    let mut skeleton = head.clone();
86    skeleton.insert("files".into(), json!([]));
87    skeleton.insert("omitted_patches".into(), json!(files.len()));
88    skeleton.insert("omitted_files".into(), json!([]));
89    let base = serialized_len(&Value::Object(skeleton));
90    let mut keep = files.len();
91    let mut total = base + meta_cost.iter().sum::<usize>();
92    while keep > 0 && total > budget {
93        keep -= 1;
94        total = total - meta_cost[keep] + name_cost[keep];
95    }
96    let mut listed: Vec<Value> = Vec::new();
97    let mut with_patch = 0usize;
98    let full = budget.saturating_sub(total);
99    let mut remaining = full;
100    for (i, m) in metas.iter().take(keep).enumerate() {
101        let mut entry = m.clone();
102        if patches {
103            if let Some(p) = files[i].get("patch").and_then(Value::as_str) {
104                let need = serialized_len(&Value::String(p.to_string())) + PATCH_OVERHEAD;
105                if need <= remaining {
106                    entry.insert("patch".into(), json!(p));
107                    remaining -= need;
108                    with_patch += 1;
109                } else if need > full {
110                    // Cannot fit even alone: cut it to what is left.
111                    let room = remaining.saturating_sub(PATCH_OVERHEAD + FLAG_OVERHEAD);
112                    if room > 0 {
113                        entry.insert("patch".into(), json!(cut_escaped(p, room)));
114                        entry.insert("patch_truncated".into(), json!(true));
115                        with_patch += 1;
116                        remaining = 0;
117                    }
118                }
119            }
120        }
121        listed.push(Value::Object(entry));
122    }
123    let omitted_files: Vec<Value> = metas.iter().skip(keep).map(|m| m["file"].clone()).collect();
124    let omitted_patches = if patches {
125        keep - with_patch
126    } else {
127        files.len()
128    };
129    let mut out = head.clone();
130    out.insert("files".into(), Value::Array(listed));
131    out.insert("omitted_patches".into(), json!(omitted_patches));
132    out.insert("omitted_files".into(), Value::Array(omitted_files));
133    Value::Object(out)
134}
135
136impl<T: Transport, C: Clock> Engine<T, C> {
137    async fn first_user_message(&self, id: &str, order: &str) -> Result<Option<String>, ToolError> {
138        let params = vec![
139            ("sessionID".to_string(), id.to_string()),
140            ("type".to_string(), "user".to_string()),
141            ("limit".to_string(), "1".to_string()),
142            ("order".to_string(), order.to_string()),
143        ];
144        let (data, _, _) = self.call_json("session.message.list", params, None).await?;
145        Ok(data
146            .as_array()
147            .and_then(|a| a.first())
148            .and_then(|m| m.get("id"))
149            .and_then(Value::as_str)
150            .map(str::to_string))
151    }
152
153    async fn session_directory(&self, id: &str) -> Result<Value, ToolError> {
154        if self.env.session.as_deref() == Some(id) {
155            if let Some(d) = &self.env.directory {
156                return Ok(json!(d.to_string_lossy()));
157            }
158        }
159        let (s, _, _) = self
160            .call_json(
161                "session.get",
162                vec![("sessionID".to_string(), id.to_string())],
163                None,
164            )
165            .await?;
166        Ok(s.pointer("/location/directory")
167            .cloned()
168            .unwrap_or(Value::Null))
169    }
170
171    pub async fn session_changes(&self, a: SessionChangesArgs) -> ToolResult {
172        let id = self.resolve_session(a.session_id.as_deref())?;
173        let directory = self.session_directory(&id).await?;
174        let from = match a.from {
175            Some(f) => Some(f),
176            None => self.first_user_message(&id, "asc").await?,
177        };
178        let to = match a.to {
179            Some(t) => Some(t),
180            None => self.first_user_message(&id, "desc").await?,
181        };
182        let mut head = Map::new();
183        head.insert("directory".into(), directory);
184        head.insert("from".into(), json!(from));
185        head.insert("to".into(), json!(to));
186        let (Some(from), Some(to)) = (from, to) else {
187            // A session without a user message has no turns to diff.
188            return Ok(pack(&head, &[], true, self.settings.output_max_bytes));
189        };
190        let key: DiffKey = (id.clone(), from.clone(), to.clone(), a.context);
191        let diff = match self.diff_cache_get(&key) {
192            Some(d) => d,
193            None => {
194                let mut params = vec![
195                    ("sessionID".to_string(), id),
196                    ("from".to_string(), from),
197                    ("to".to_string(), to),
198                ];
199                if let Some(c) = a.context {
200                    params.push(("context".to_string(), c.to_string()));
201                }
202                let (d, _, _) = self
203                    .call_json("session.diff", params, None)
204                    .await
205                    .map_err(range_error)?;
206                self.diff_cache_put(key, &d);
207                d
208            }
209        };
210        let mut files = diff.as_array().cloned().unwrap_or_default();
211        if let Some(paths) = &a.paths {
212            files.retain(|f| {
213                f.get("file")
214                    .and_then(Value::as_str)
215                    .is_some_and(|n| paths.iter().any(|p| p == n))
216            });
217        }
218        Ok(pack(
219            &head,
220            &files,
221            a.patches.unwrap_or(true),
222            self.settings.output_max_bytes,
223        ))
224    }
225
226    pub async fn workspace_status(&self, a: WorkspaceStatusArgs) -> ToolResult {
227        let dir = self
228            .resolve_directory(a.directory.as_deref().map(Path::new))
229            .to_string_lossy()
230            .to_string();
231        let params = || vec![("location[directory]".to_string(), dir.clone())];
232        let (info, _, _) = self.call_json("vcs.get", params(), None).await?;
233        let provider = info.get("provider").filter(|p| !p.is_null()).cloned();
234        let Some(provider) = provider else {
235            return Ok(json!({"directory": dir, "vcs": null}));
236        };
237        let (status, _, meta) = self.call_json("vcs.status", params(), None).await?;
238        let entries: Vec<Value> = status
239            .as_array()
240            .map(|a| {
241                a.iter()
242                    .map(|e| {
243                        json!({
244                            "path": e.get("path").cloned().unwrap_or(Value::Null),
245                            "kind": e.get("kind").cloned().unwrap_or(Value::Null),
246                        })
247                    })
248                    .collect()
249            })
250            .unwrap_or_default();
251        let mut out = Map::new();
252        out.insert("directory".into(), json!(dir));
253        out.insert("vcs".into(), provider);
254        out.insert("status".into(), Value::Array(entries));
255        if let Some(m) = meta {
256            out.insert("meta".into(), m);
257        }
258        Ok(Value::Object(out))
259    }
260}