@ondeinference / ed / commits / de60c9c

ed-acp: prompt content, the Onde Cloud client, session store, CLI

First part of the ed-acp crate, split across three PRs so each fits a review. The crate joins the workspace in the third one, so nothing here is built yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.3 <noreply@sigit.si>

Seto Elkahfi committed Oct 9, 2026 at 00:12 UTC de60c9c9508c65791e9a41e8c833a2fe5378fc7e
4 files changed +1269
crates/ed-acp/src/cli.rs new
+141
@@ -0,0 +1,141 @@
1 +//! Command-line pieces every Onde agent shares: `--setup` (store and verify the Onde key),
2 +//! `--list-models`, flag parsing and logging.
3 +
4 +use std::io::IsTerminal;
5 +
6 +use anyhow::Context;
7 +
8 +use crate::AgentInfo;
9 +use crate::llm::{LlmClient, LlmConfig, LlmEnv};
10 +
11 +/// Log to stderr, filtered by `RUST_LOG`. Stdout is reserved for ACP.
12 +pub fn init_logging() {
13 + let _ = tracing_subscriber::fmt()
14 + .with_writer(std::io::stderr)
15 + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
16 + .try_init();
17 +}
18 +
19 +/// `1` or `true` (any case).
20 +pub fn env_flag(name: &str) -> bool {
21 + std::env::var(name).is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
22 +}
23 +
24 +/// Onde credentials are `app-id:app-secret`: exactly one colon, both halves non-empty.
25 +pub fn valid_onde_key(key: &str) -> bool {
26 + let mut parts = key.split(':');
27 + matches!(
28 + (parts.next(), parts.next(), parts.next()),
29 + (Some(id), Some(secret), None) if !id.is_empty() && !secret.is_empty()
30 + )
31 +}
32 +
33 +/// `env` file content with `ONDE_API_KEY` set, keeping any other lines.
34 +pub fn with_api_key(existing: &str, key: &str) -> String {
35 + let mut lines: Vec<String> = existing
36 + .lines()
37 + .filter(|l| {
38 + let l = l.trim().strip_prefix("export ").unwrap_or(l.trim());
39 + !l.starts_with("ONDE_API_KEY=")
40 + })
41 + .map(String::from)
42 + .collect();
43 + lines.push(format!("ONDE_API_KEY={key}"));
44 + lines.join("\n") + "\n"
45 +}
46 +
47 +/// Interactive first-run setup (`--setup`): prompt for the Onde key, verify it against Onde
48 +/// Cloud, and store it in the platform config dir (`config_dir()/env`, mode 0600).
49 +pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> {
50 + use std::io::Write;
51 +
52 + if !std::io::stdin().is_terminal() {
53 + anyhow::bail!("--setup needs an interactive terminal");
54 + }
55 + println!("{} setup\n", info.name);
56 + println!("Get credentials: sign in at https://ondeinference.com/root/login,");
57 + println!("register an app and assign a model. Your key is \"app-id:app-secret\".\n");
58 + let key = loop {
59 + print!("Paste your ONDE_API_KEY: ");
60 + std::io::stdout().flush()?;
61 + let mut line = String::new();
62 + if std::io::stdin().read_line(&mut line)? == 0 {
63 + anyhow::bail!("no input");
64 + }
65 + let key = line.trim().to_string();
66 + if valid_onde_key(&key) {
67 + break key;
68 + }
69 + println!("Onde credentials look like \"app-id:app-secret\" (exactly one colon).");
70 + };
71 +
72 + // Verify before writing anything.
73 + let mut config = LlmConfig::from_env(&info.env);
74 + config.api_key = Some(key.clone());
75 + print!("Verifying the key with Onde Cloud… ");
76 + std::io::stdout().flush()?;
77 + match LlmClient::new(config).check_auth().await {
78 + Ok(()) => println!("OK"),
79 + Err(e) => anyhow::bail!("\nKey check failed: {e:#}\nNothing was written."),
80 + }
81 +
82 + let dir = info
83 + .env
84 + .config_dir()
85 + .context("no config directory available")?;
86 + std::fs::create_dir_all(&dir)?;
87 + let path = dir.join("env");
88 + let existing = std::fs::read_to_string(&path).unwrap_or_default();
89 + std::fs::write(&path, with_api_key(&existing, &key))?;
90 + #[cfg(unix)]
91 + {
92 + use std::os::unix::fs::PermissionsExt;
93 + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
94 + }
95 + println!("\nWrote {}. You're all set.", path.display());
96 + Ok(())
97 +}
98 +
99 +/// Print the models the configured endpoint serves, one per line (`id<TAB>owner`).
100 +pub async fn list_models(info: &AgentInfo) -> anyhow::Result<()> {
101 + let client = LlmClient::new(LlmConfig::from_env(&info.env));
102 + for m in client.models().await? {
103 + match m.owned_by {
104 + Some(owner) => println!("{}\t{}", m.id, owner),
105 + None => println!("{}", m.id),
106 + }
107 + }
108 + Ok(())
109 +}
110 +
111 +/// How the agent was launched: `tui` (interactive terminal UI) or `acp` (editor).
112 +/// The TUI sets `<PREFIX>_SURFACE=tui` on its subprocess; editors launch `--acp`
113 +/// directly so the default is `acp`.
114 +pub fn surface(env: &LlmEnv) -> &'static str {
115 + match std::env::var(env.var("SURFACE")) {
116 + Ok(s) if s == "tui" => "tui",
117 + _ => "acp",
118 + }
119 +}
120 +
121 +#[cfg(test)]
122 +mod tests {
123 + use super::*;
124 +
125 + #[test]
126 + fn onde_key_shape() {
127 + assert!(valid_onde_key("app:secret"));
128 + assert!(!valid_onde_key("secret"));
129 + assert!(!valid_onde_key("a:b:c"));
130 + assert!(!valid_onde_key(":secret"));
131 + assert!(!valid_onde_key("app:"));
132 + assert!(!valid_onde_key(""));
133 + }
134 +
135 + #[test]
136 + fn env_file_keeps_other_lines_and_replaces_key() {
137 + let out = with_api_key("# hi\nX_MODEL=m\nexport ONDE_API_KEY=old:old\n", "new:key");
138 + assert_eq!(out, "# hi\nX_MODEL=m\nONDE_API_KEY=new:key\n");
139 + assert_eq!(with_api_key("", "a:b"), "ONDE_API_KEY=a:b\n");
140 + }
141 +}
crates/ed-acp/src/content.rs new
+411
@@ -0,0 +1,411 @@
1 +//! Converting ACP prompt content into model content: text, embedded and linked files
2 +//! (inlined, a selection as just its lines), images, and audio saved to a scratch file.
3 +
4 +use std::path::{Path, PathBuf};
5 +
6 +use agent_client_protocol::schema::v1::{
7 + ClientCapabilities, ContentBlock, EmbeddedResource, EmbeddedResourceResource,
8 + ReadTextFileRequest, ResourceLink, SessionId, TextResourceContents,
9 +};
10 +use agent_client_protocol::{Client, ConnectionTo};
11 +use anyhow::{Context, Result};
12 +use base64::Engine;
13 +use serde_json::{Value, json};
14 +
15 +fn embedded_text(res: &EmbeddedResource) -> Option<String> {
16 + match &res.resource {
17 + EmbeddedResourceResource::TextResourceContents(r) => {
18 + Some(format!("<file uri=\"{}\">\n{}\n</file>", r.uri, r.text))
19 + }
20 + // Describe binary resources rather than dropping them, so the model knows they exist.
21 + EmbeddedResourceResource::BlobResourceContents(b) => Some(format!(
22 + "[Attached binary resource: {} ({})]",
23 + b.uri,
24 + b.mime_type.as_deref().unwrap_or("unknown type")
25 + )),
26 + _ => None,
27 + }
28 +}
29 +
30 +/// How audio reaches the model. A product with an audio tool points the model at it here.
31 +#[derive(Clone, Copy)]
32 +pub struct AudioHints {
33 + /// The text for a `file://` link to an audio file, given its path.
34 + pub link: fn(&Path) -> String,
35 + /// The text for an attached clip, given its MIME type and the path it was saved to.
36 + pub attachment: fn(&str, &Path) -> String,
37 +}
38 +
39 +impl Default for AudioHints {
40 + fn default() -> Self {
41 + Self {
42 + link: |path| format!("[Referenced audio file: {}]", path.display()),
43 + attachment: |mime, path| {
44 + format!("[Attached audio ({mime}) saved at {}]", path.display())
45 + },
46 + }
47 + }
48 +}
49 +
50 +/// How a resource link is shown to the model. Audio files are never read as text; they get
51 +/// the product's [`AudioHints::link`] text instead.
52 +fn link_text(link: &ResourceLink, hints: &AudioHints) -> String {
53 + match parse_file_link(&link.uri) {
54 + Some(FileLink { path, .. }) if is_audio(&path, link.mime_type.as_deref()) => {
55 + (hints.link)(&path)
56 + }
57 + _ => format!("[Referenced: {}]", link.uri),
58 + }
59 +}
60 +
61 +/// Common audio file extensions; a link to one is never read as text.
62 +const AUDIO_EXTENSIONS: &[&str] = &[
63 + "wav", "wave", "mp3", "flac", "m4a", "aac", "mp4", "ogg", "oga", "opus", "aif", "aiff", "aifc",
64 + "caf", "webm", "mkv",
65 +];
66 +
67 +fn is_audio(path: &Path, mime: Option<&str>) -> bool {
68 + mime.is_some_and(|m| m.starts_with("audio/"))
69 + || path
70 + .extension()
71 + .and_then(|e| e.to_str())
72 + .is_some_and(|e| AUDIO_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
73 +}
74 +
75 +/// Largest file a `file://` resource link is inlined for; bigger ones stay as references.
76 +const MAX_LINKED_FILE_BYTES: usize = 256 * 1024;
77 +
78 +/// Replace `file://` resource links to text files with their embedded contents, so the model
79 +/// sees the file. Reads through the client when it supports `fs/read_text_file` (unsaved
80 +/// buffers), else from disk. A link to a selection (`#L10:20`) is inlined as just those
81 +/// lines. Audio files, links that can't be read and oversized files stay as references.
82 +pub async fn resolve_resource_links(
83 + blocks: &[ContentBlock],
84 + caps: &ClientCapabilities,
85 + connection: &ConnectionTo<Client>,
86 + session_id: &SessionId,
87 +) -> Vec<ContentBlock> {
88 + let mut out = Vec::with_capacity(blocks.len());
89 + for block in blocks {
90 + if let ContentBlock::ResourceLink(link) = block
91 + && let Some(FileLink { path, lines }) = parse_file_link(&link.uri)
92 + && !is_audio(&path, link.mime_type.as_deref())
93 + {
94 + let text = if caps.fs.read_text_file {
95 + connection
96 + .send_request(ReadTextFileRequest::new(session_id.clone(), path.clone()))
97 + .block_task()
98 + .await
99 + .map(|r| r.content)
100 + .map_err(|e| e.to_string())
101 + } else {
102 + tokio::fs::read_to_string(&path)
103 + .await
104 + .map_err(|e| e.to_string())
105 + };
106 + let text = match lines {
107 + Some(range) => text.map(|t| slice_lines(&t, range)),
108 + None => text,
109 + };
110 + match text {
111 + Ok(text) if lines.is_some() && text.is_empty() => {
112 + tracing::debug!("{} selects no lines", link.uri)
113 + }
114 + Ok(text) if text.len() <= MAX_LINKED_FILE_BYTES => {
115 + out.push(ContentBlock::Resource(EmbeddedResource::new(
116 + EmbeddedResourceResource::TextResourceContents(
117 + TextResourceContents::new(text, link.uri.clone())
118 + .mime_type(link.mime_type.clone()),
119 + ),
120 + )));
121 + continue;
122 + }
123 + Ok(_) => tracing::debug!("{} too large to inline", link.uri),
124 + Err(e) => tracing::debug!("could not read {}: {e}", link.uri),
125 + }
126 + }
127 + out.push(block.clone());
128 + }
129 + out
130 +}
131 +
132 +/// A `file://` resource link: the absolute path, and the lines it selects, if any.
133 +#[derive(Debug, PartialEq)]
134 +struct FileLink {
135 + path: PathBuf,
136 + /// 1-based, inclusive.
137 + lines: Option<(usize, usize)>,
138 +}
139 +
140 +/// Parse a `file://` URI, percent-decoding the path. Editors put more than the path in it:
141 +/// Zed links a selection as `file:///a.md?column=5#L10:20` and a symbol as
142 +/// `file:///a.rs?symbol=main#L3:9`. The query is dropped and the fragment read as a line
143 +/// range; a literal `?` or `#` in a file name arrives percent-encoded, so splitting first is
144 +/// safe.
145 +fn parse_file_link(uri: &str) -> Option<FileLink> {
146 + let rest = uri.strip_prefix("file://")?;
147 + let (rest, fragment) = match rest.split_once('#') {
148 + Some((rest, fragment)) => (rest, Some(fragment)),
149 + None => (rest, None),
150 + };
151 + let rest = rest.split_once('?').map_or(rest, |(rest, _)| rest);
152 + // Allow an authority of "" or "localhost"; reject other hosts.
153 + let path = if rest.starts_with('/') {
154 + rest
155 + } else {
156 + rest.strip_prefix("localhost")?
157 + };
158 + let bytes = path.as_bytes();
159 + let mut decoded = Vec::with_capacity(bytes.len());
160 + let mut i = 0;
161 + while i < bytes.len() {
162 + if bytes[i] == b'%'
163 + && i + 2 < bytes.len()
164 + && let Ok(b) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?, 16)
165 + {
166 + decoded.push(b);
167 + i += 3;
168 + } else {
169 + decoded.push(bytes[i]);
170 + i += 1;
171 + }
172 + }
173 + let path = PathBuf::from(String::from_utf8(decoded).ok()?);
174 + path.is_absolute().then(|| FileLink {
175 + path,
176 + lines: fragment.and_then(parse_line_range),
177 + })
178 +}
179 +
180 +/// A `#L10:20` fragment as a 1-based inclusive range. Accepts `L10:20`, `L10-20`, `L10-L20`
181 +/// and a single line `L10`.
182 +fn parse_line_range(fragment: &str) -> Option<(usize, usize)> {
183 + let range = fragment.strip_prefix('L')?;
184 + let (start, end) = range
185 + .split_once(':')
186 + .or_else(|| range.split_once('-'))
187 + .unwrap_or((range, range));
188 + let end = end.strip_prefix('L').unwrap_or(end);
189 + let (start, end) = (start.parse::<usize>().ok()?, end.parse::<usize>().ok()?);
190 + (start >= 1 && end >= start).then_some((start, end))
191 +}
192 +
193 +/// Lines `start..=end` (1-based) of `text`, keeping their line endings.
194 +fn slice_lines(text: &str, (start, end): (usize, usize)) -> String {
195 + text.split_inclusive('\n')
196 + .skip(start - 1)
197 + .take(end - start + 1)
198 + .collect()
199 +}
200 +
201 +/// Flatten text-like prompt blocks into one string (used for titles and slash commands).
202 +pub fn prompt_to_text(blocks: &[ContentBlock], hints: &AudioHints) -> String {
203 + let mut parts = Vec::new();
204 + for block in blocks {
205 + match block {
206 + ContentBlock::Text(t) => parts.push(t.text.clone()),
207 + ContentBlock::ResourceLink(link) => parts.push(link_text(link, hints)),
208 + ContentBlock::Resource(res) => parts.extend(embedded_text(res)),
209 + _ => {}
210 + }
211 + }
212 + parts.join("\n\n")
213 +}
214 +
215 +fn audio_extension(mime: &str) -> &'static str {
216 + match mime.split(';').next().unwrap_or("").trim() {
217 + "audio/wav" | "audio/x-wav" | "audio/wave" | "audio/vnd.wave" => "wav",
218 + "audio/mpeg" | "audio/mp3" => "mp3",
219 + "audio/flac" | "audio/x-flac" => "flac",
220 + "audio/ogg" | "audio/vorbis" | "application/ogg" => "ogg",
221 + "audio/mp4" | "audio/m4a" | "audio/x-m4a" | "audio/aac" => "m4a",
222 + "audio/aiff" | "audio/x-aiff" => "aiff",
223 + "audio/webm" | "video/webm" => "webm",
224 + _ => "audio",
225 + }
226 +}
227 +
228 +/// Write each audio block under `dir`; returns the saved path per audio block, in order.
229 +pub async fn save_audio_blocks(blocks: &[ContentBlock], dir: &Path) -> Result<Vec<PathBuf>> {
230 + let mut saved = Vec::new();
231 + for block in blocks {
232 + let ContentBlock::Audio(audio) = block else {
233 + continue;
234 + };
235 + let bytes = base64::engine::general_purpose::STANDARD
236 + .decode(audio.data.trim())
237 + .context("audio block is not valid base64")?;
238 + tokio::fs::create_dir_all(dir).await?;
239 + let path = dir.join(format!(
240 + "audio-{}.{}",
241 + uuid::Uuid::new_v4().simple(),
242 + audio_extension(&audio.mime_type)
243 + ));
244 + tokio::fs::write(&path, bytes).await?;
245 + saved.push(path);
246 + }
247 + Ok(saved)
248 +}
249 +
250 +/// Convert prompt blocks to an OpenAI `content` value: a string for text only, or parts when
251 +/// images are present. Audio is referenced by the path it was saved to, worded by
252 +/// [`AudioHints::attachment`]. `slash` replaces the first text block (an expanded slash
253 +/// command).
254 +pub fn prompt_to_content(
255 + blocks: &[ContentBlock],
256 + audio_paths: &[PathBuf],
257 + slash: Option<&str>,
258 + hints: &AudioHints,
259 +) -> Value {
260 + let mut audio = audio_paths.iter();
261 + let mut parts: Vec<Value> = Vec::new();
262 + let mut slash = slash;
263 + let mut has_image = false;
264 + let text = |t: String| json!({ "type": "text", "text": t });
265 + for block in blocks {
266 + match block {
267 + ContentBlock::Text(t) => match slash.take() {
268 + Some(expanded) => parts.push(text(expanded.to_string())),
269 + None => parts.push(text(t.text.clone())),
270 + },
271 + ContentBlock::ResourceLink(link) => parts.push(text(link_text(link, hints))),
272 + ContentBlock::Resource(res) => {
273 + if let Some(t) = embedded_text(res) {
274 + parts.push(text(t));
275 + }
276 + }
277 + ContentBlock::Image(img) => {
278 + has_image = true;
279 + let url = format!("data:{};base64,{}", img.mime_type, img.data);
280 + parts.push(json!({ "type": "image_url", "image_url": { "url": url } }));
281 + }
282 + ContentBlock::Audio(a) => match audio.next() {
283 + Some(path) => parts.push(text((hints.attachment)(&a.mime_type, path))),
284 + None => parts.push(text("[Attached audio could not be saved]".into())),
285 + },
286 + _ => {}
287 + }
288 + }
289 + if has_image {
290 + return Value::Array(parts);
291 + }
292 + let joined = parts
293 + .iter()
294 + .filter_map(|p| p.get("text").and_then(Value::as_str))
295 + .collect::<Vec<_>>()
296 + .join("\n\n");
297 + Value::String(joined)
298 +}
299 +
300 +#[cfg(test)]
301 +mod tests {
302 + use super::*;
303 + use agent_client_protocol::schema::v1::{AudioContent, ImageContent, TextContent};
304 +
305 + fn link(uri: &str) -> Option<FileLink> {
306 + parse_file_link(uri)
307 + }
308 +
309 + #[test]
310 + fn plain_paths_decode_and_must_be_absolute() {
311 + assert_eq!(
312 + link("file:///a%20b/c.md"),
313 + Some(FileLink {
314 + path: "/a b/c.md".into(),
315 + lines: None
316 + })
317 + );
318 + assert_eq!(
319 + link("file://localhost/x.md").unwrap().path,
320 + Path::new("/x.md")
321 + );
322 + assert_eq!(link("file://other-host/x.md"), None);
323 + assert_eq!(link("https://example.com/x.md"), None);
324 + }
325 +
326 + #[test]
327 + fn zed_selection_and_symbol_links() {
328 + let sel = link("file:///notes/song.md?column=5#L10:20").unwrap();
329 + assert_eq!(sel.path, Path::new("/notes/song.md"));
330 + assert_eq!(sel.lines, Some((10, 20)));
331 + assert_eq!(
332 + link("file:///a.rs?symbol=main#L3:9").unwrap().lines,
333 + Some((3, 9))
334 + );
335 + }
336 +
337 + #[test]
338 + fn line_range_forms() {
339 + assert_eq!(parse_line_range("L10:20"), Some((10, 20)));
340 + assert_eq!(parse_line_range("L10-20"), Some((10, 20)));
341 + assert_eq!(parse_line_range("L10-L20"), Some((10, 20)));
342 + assert_eq!(parse_line_range("L7"), Some((7, 7)));
343 + assert_eq!(parse_line_range("L0"), None);
344 + assert_eq!(parse_line_range("L5:2"), None);
345 + assert_eq!(parse_line_range("x"), None);
346 + }
347 +
348 + #[test]
349 + fn slices_inclusive_lines() {
350 + assert_eq!(slice_lines("a\nb\nc\nd\n", (2, 3)), "b\nc\n");
351 + assert_eq!(slice_lines("a\nb", (2, 9)), "b");
352 + assert_eq!(slice_lines("a\n", (5, 6)), "");
353 + }
354 +
355 + #[test]
356 + fn audio_links_use_the_product_hint_and_are_never_read() {
357 + let hints = AudioHints {
358 + link: |p| format!("analyze {}", p.display()),
359 + ..AudioHints::default()
360 + };
361 + let wav = ResourceLink::new("mix.wav", "file:///music/mix%20v2.WAV");
362 + assert_eq!(link_text(&wav, &hints), "analyze /music/mix v2.WAV");
363 + let by_mime =
364 + ResourceLink::new("take", "file:///t/take").mime_type("audio/flac".to_string());
365 + assert_eq!(
366 + link_text(&by_mime, &AudioHints::default()),
367 + "[Referenced audio file: /t/take]"
368 + );
369 + let md = ResourceLink::new("notes", "file:///t/notes.md");
370 + assert_eq!(link_text(&md, &hints), "[Referenced: file:///t/notes.md]");
371 + }
372 +
373 + #[tokio::test]
374 + async fn audio_is_saved_and_referenced() {
375 + let dir = std::env::temp_dir().join(format!("sf-prompt-{}", uuid::Uuid::new_v4()));
376 + let data = base64::engine::general_purpose::STANDARD.encode(b"RIFFfake");
377 + let blocks = vec![
378 + ContentBlock::Text(TextContent::new("what key?")),
379 + ContentBlock::Audio(AudioContent::new(data, "audio/wav")),
380 + ];
381 + let saved = save_audio_blocks(&blocks, &dir).await.unwrap();
382 + assert_eq!(saved.len(), 1);
383 + assert_eq!(saved[0].extension().unwrap(), "wav");
384 + assert_eq!(std::fs::read(&saved[0]).unwrap(), b"RIFFfake");
385 + let content = prompt_to_content(&blocks, &saved, None, &AudioHints::default());
386 + let s = content.as_str().unwrap();
387 + assert!(s.contains("what key?") && s.contains(&saved[0].display().to_string()));
388 + std::fs::remove_dir_all(dir).ok();
389 +
390 + let bad = vec![ContentBlock::Audio(AudioContent::new("!!!", "audio/wav"))];
391 + assert!(
392 + save_audio_blocks(&bad, &std::env::temp_dir())
393 + .await
394 + .is_err()
395 + );
396 + }
397 +
398 + #[test]
399 + fn images_use_parts_and_slash_replaces_text() {
400 + let blocks = vec![
401 + ContentBlock::Text(TextContent::new("/chords Am F")),
402 + ContentBlock::Image(ImageContent::new("AAAA", "image/png")),
403 + ];
404 + let v = prompt_to_content(&blocks, &[], Some("EXPANDED"), &AudioHints::default());
405 + let parts = v.as_array().unwrap();
406 + assert_eq!(parts[0]["text"], "EXPANDED");
407 + assert_eq!(parts[1]["type"], "image_url");
408 + let plain = prompt_to_content(&blocks[..1], &[], None, &AudioHints::default());
409 + assert_eq!(plain, json!("/chords Am F"));
410 + }
411 +}
crates/ed-acp/src/llm.rs new
+543
@@ -0,0 +1,543 @@
1 +//! Streaming client for Onde Cloud's OpenAI-compatible `/chat/completions` endpoint: text and
2 +//! reasoning deltas, tool-call accumulation, token usage, and `GET /models`.
3 +//!
4 +//! Superseded by `onde-backend` once it exists (Onde Agent Platform §5.2); the agent depends
5 +//! only on this module's public types so that swap stays local.
6 +
7 +use anyhow::{Context, Result, bail};
8 +use futures::StreamExt;
9 +use serde::Deserialize;
10 +use serde_json::{Value, json};
11 +
12 +/// Onde Cloud's OpenAI-compatible endpoint.
13 +pub const DEFAULT_BASE_URL: &str = "https://cloud.ondeinference.com/v1";
14 +/// The model used when neither the profile nor the environment names one.
15 +pub const DEFAULT_MODEL: &str = "onde-kkk";
16 +/// The credential every Onde agent reads: `app-id:app-secret`.
17 +pub const API_KEY_VAR: &str = "ONDE_API_KEY";
18 +
19 +/// Where an agent's settings come from: `<PREFIX>_BASE_URL`, `<PREFIX>_MODEL` and
20 +/// `<PREFIX>_DATA_DIR` in the environment, then `<config dir>/<dir_name>/env`.
21 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 +pub struct LlmEnv {
23 + /// Environment prefix, e.g. `SPLITFIRE`.
24 + pub prefix: &'static str,
25 + /// Directory name under the platform config dir, e.g. `splitfire-agent`.
26 + pub dir_name: &'static str,
27 + /// Model when `<PREFIX>_MODEL` is unset.
28 + pub default_model: &'static str,
29 +}
30 +
31 +impl LlmEnv {
32 + pub fn var(&self, suffix: &str) -> String {
33 + format!("{}_{suffix}", self.prefix)
34 + }
35 +
36 + /// Directory for the stored `env` file: `~/.config/<dir_name>` on Linux,
37 + /// `~/Library/Application Support/<dir_name>` on macOS, `%APPDATA%\\<dir_name>` on
38 + /// Windows. `<PREFIX>_DATA_DIR` overrides it (tests). `None` without a home directory.
39 + pub fn config_dir(&self) -> Option<std::path::PathBuf> {
40 + if let Some(dir) = std::env::var_os(self.var("DATA_DIR")).filter(|v| !v.is_empty()) {
41 + return Some(dir.into());
42 + }
43 + directories::BaseDirs::new().map(|dirs| {
44 + #[cfg(target_os = "linux")]
45 + let base = dirs.config_dir();
46 + #[cfg(not(target_os = "linux"))]
47 + let base = dirs.data_dir();
48 + base.join(self.dir_name)
49 + })
50 + }
51 +
52 + /// The stored `env` file `--setup` writes and `logout` removes.
53 + pub fn config_file(&self) -> Option<std::path::PathBuf> {
54 + self.config_dir().map(|d| d.join("env"))
55 + }
56 +
57 + /// Where durable state (sessions, audio scratch) lives. Same directory as the `env` file.
58 + pub fn data_dir(&self) -> std::path::PathBuf {
59 + self.config_dir()
60 + .unwrap_or_else(|| std::env::temp_dir().join(self.dir_name))
61 + }
62 +
63 + fn load_file_vars(&self) -> std::collections::HashMap<String, String> {
64 + let mut vars = std::collections::HashMap::new();
65 + let content = self
66 + .config_file()
67 + .and_then(|p| std::fs::read_to_string(p).ok());
68 + let Some(content) = content else {
69 + return vars;
70 + };
71 + for line in content.lines() {
72 + let line = line.trim();
73 + if line.is_empty() || line.starts_with('#') {
74 + continue;
75 + }
76 + let line = line.strip_prefix("export ").unwrap_or(line).trim();
77 + if let Some((k, v)) = line.split_once('=') {
78 + let mut v = v.trim();
79 + if v.len() >= 2
80 + && ((v.starts_with('"') && v.ends_with('"'))
81 + || (v.starts_with('\'') && v.ends_with('\'')))
82 + {
83 + v = &v[1..v.len() - 1];
84 + }
85 + vars.insert(k.trim().to_string(), v.to_string());
86 + }
87 + }
88 + vars
89 + }
90 +}
91 +
92 +#[derive(Debug, Clone, PartialEq, Eq)]
93 +pub struct LlmConfig {
94 + pub base_url: String,
95 + /// Onde credentials: `app-id:app-secret`, sent as the bearer token.
96 + pub api_key: Option<String>,
97 + pub model: String,
98 +}
99 +
100 +impl LlmConfig {
101 + /// `ONDE_API_KEY` from the environment, falling back to the stored `env` file.
102 + /// `<PREFIX>_BASE_URL` and `<PREFIX>_MODEL` override the endpoint and default model.
103 + /// Empty values are ignored: GUI launchers (e.g. Zed via launchd) often export
104 + /// variables set to "", which must not mask the config file.
105 + pub fn from_env(env: &LlmEnv) -> Self {
106 + let file_vars = env.load_file_vars();
107 + Self::from_lookup(env, |name| {
108 + std::env::var(name)
109 + .ok()
110 + .filter(|v| !v.is_empty())
111 + .or_else(|| file_vars.get(name).cloned())
112 + })
113 + }
114 +
115 + /// Build a config from an arbitrary variable lookup (tests, setup).
116 + pub fn from_lookup(env: &LlmEnv, lookup: impl Fn(&str) -> Option<String>) -> Self {
117 + let get = |name: &str| lookup(name).filter(|v| !v.is_empty());
118 + Self {
119 + base_url: get(&env.var("BASE_URL"))
120 + .unwrap_or_else(|| DEFAULT_BASE_URL.into())
121 + .trim_end_matches('/')
122 + .to_string(),
123 + api_key: get(API_KEY_VAR),
124 + model: get(&env.var("MODEL")).unwrap_or_else(|| env.default_model.into()),
125 + }
126 + }
127 +}
128 +
129 +#[cfg(test)]
130 +mod tests {
131 + use super::*;
132 +
133 + const ENV: LlmEnv = LlmEnv {
134 + prefix: "SPLITFIRE",
135 + dir_name: "splitfire-agent",
136 + default_model: DEFAULT_MODEL,
137 + };
138 +
139 + fn config(vars: &[(&str, &str)]) -> LlmConfig {
140 + LlmConfig::from_lookup(&ENV, |name| {
141 + vars.iter()
142 + .find(|(k, _)| *k == name)
143 + .map(|(_, v)| v.to_string())
144 + })
145 + }
146 +
147 + #[test]
148 + fn defaults_point_at_onde_cloud() {
149 + let c = config(&[("ONDE_API_KEY", "app:secret")]);
150 + assert_eq!(c.base_url, "https://cloud.ondeinference.com/v1");
151 + assert_eq!(c.api_key.as_deref(), Some("app:secret"));
152 + assert_eq!(c.model, "onde-kkk");
153 + assert_eq!(config(&[]).api_key, None);
154 + assert_eq!(config(&[("ONDE_API_KEY", "")]).api_key, None);
155 + }
156 +
157 + #[test]
158 + fn base_url_and_model_overrides_apply() {
159 + let c = config(&[
160 + ("ONDE_API_KEY", "app:secret"),
161 + ("SPLITFIRE_MODEL", "onde-prism"),
162 + ("SPLITFIRE_BASE_URL", "http://x/v1/"),
163 + ]);
164 + assert_eq!(
165 + (c.base_url.as_str(), c.model.as_str()),
166 + ("http://x/v1", "onde-prism")
167 + );
168 + }
169 +
170 + /// One test touches the process environment, so it can't race another.
171 + #[test]
172 + fn key_from_env_then_file_ignoring_empty_env() {
173 + let dir = std::env::temp_dir().join(format!("sf-llm-{}", uuid::Uuid::new_v4()));
174 + std::fs::create_dir_all(&dir).unwrap();
175 + std::fs::write(
176 + dir.join("env"),
177 + "# comment\nexport ONDE_API_KEY=\"file:key\"\nSPLITFIRE_MODEL=m1\n",
178 + )
179 + .unwrap();
180 + // SAFETY: only this test mutates these variables.
181 + unsafe {
182 + std::env::set_var("SPLITFIRE_DATA_DIR", &dir);
183 + std::env::set_var("ONDE_API_KEY", "");
184 + }
185 + let c = LlmConfig::from_env(&ENV);
186 + assert_eq!(c.api_key.as_deref(), Some("file:key"));
187 + assert_eq!(c.model, "m1");
188 + unsafe { std::env::set_var("ONDE_API_KEY", "env:key") };
189 + assert_eq!(
190 + LlmConfig::from_env(&ENV).api_key.as_deref(),
191 + Some("env:key")
192 + );
193 + unsafe {
194 + std::env::remove_var("ONDE_API_KEY");
195 + std::env::remove_var("SPLITFIRE_DATA_DIR");
196 + }
197 + let _ = std::fs::remove_dir_all(dir);
198 + }
199 +
200 + #[test]
201 + fn usage_tolerates_missing_total() {
202 + let chunk: Chunk = serde_json::from_str(
203 + r#"{"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3}}"#,
204 + )
205 + .unwrap();
206 + assert_eq!(chunk.usage.unwrap().used(), 10);
207 + let full = Usage {
208 + prompt_tokens: 1,
209 + completion_tokens: 1,
210 + total_tokens: 5,
211 + };
212 + assert_eq!(full.used(), 5);
213 + }
214 +
215 + #[test]
216 + fn parses_models_response() {
217 + let body = r#"{
218 + "object": "list",
219 + "data": [
220 + {"id": "onde-balanced", "object": "model", "created": 1700000000, "owned_by": "onde"},
221 + {"id": "custom-model"}
222 + ]
223 + }"#;
224 + let parsed: ModelsResponse = serde_json::from_str(body).unwrap();
225 + assert_eq!(parsed.data.len(), 2);
226 + assert_eq!(parsed.data[0].owned_by.as_deref(), Some("onde"));
227 + assert_eq!(parsed.data[1].owned_by, None);
228 + }
229 +}
230 +
231 +#[derive(Debug, Clone, Default)]
232 +pub struct ToolCallRequest {
233 + pub id: String,
234 + pub name: String,
235 + pub arguments: String,
236 +}
237 +
238 +#[derive(Debug, Default)]
239 +pub struct Completion {
240 + pub content: String,
241 + pub tool_calls: Vec<ToolCallRequest>,
242 + pub finish_reason: Option<String>,
243 + /// Token usage, when the endpoint reports it (`stream_options.include_usage`).
244 + pub usage: Option<Usage>,
245 +}
246 +
247 +#[derive(Debug, Clone, Copy, Default, Deserialize)]
248 +pub struct Usage {
249 + #[serde(default)]
250 + pub prompt_tokens: u64,
251 + #[serde(default)]
252 + pub completion_tokens: u64,
253 + #[serde(default)]
254 + pub total_tokens: u64,
255 +}
256 +
257 +impl Usage {
258 + /// Tokens in the context after this completion. Some servers leave `total_tokens` at 0.
259 + pub fn used(&self) -> u64 {
260 + self.total_tokens
261 + .max(self.prompt_tokens + self.completion_tokens)
262 + }
263 +}
264 +
265 +impl Completion {
266 + /// The assistant message to append to the conversation history.
267 + pub fn to_message(&self) -> Value {
268 + let mut msg = json!({ "role": "assistant", "content": self.content });
269 + if !self.tool_calls.is_empty() {
270 + msg["tool_calls"] = self
271 + .tool_calls
272 + .iter()
273 + .map(|tc| {
274 + json!({
275 + "id": tc.id,
276 + "type": "function",
277 + "function": { "name": tc.name, "arguments": tc.arguments },
278 + })
279 + })
280 + .collect();
281 + }
282 + msg
283 + }
284 +}
285 +
286 +/// Streamed pieces surfaced to the caller as they arrive.
287 +pub enum Delta<'a> {
288 + Text(&'a str),
289 + Reasoning(&'a str),
290 +}
291 +
292 +#[derive(Deserialize)]
293 +struct Chunk {
294 + #[serde(default)]
295 + choices: Vec<Choice>,
296 + usage: Option<Usage>,
297 +}
298 +
299 +#[derive(Deserialize)]
300 +struct Choice {
301 + #[serde(default)]
302 + delta: ChunkDelta,
303 + finish_reason: Option<String>,
304 +}
305 +
306 +#[derive(Deserialize, Default)]
307 +struct ChunkDelta {
308 + content: Option<String>,
309 + // Non-standard but common (DeepSeek, vLLM, OpenRouter, llama.cpp).
310 + reasoning_content: Option<String>,
311 + reasoning: Option<String>,
312 + #[serde(default)]
313 + tool_calls: Vec<ToolCallDelta>,
314 +}
315 +
316 +#[derive(Deserialize)]
317 +struct ToolCallDelta {
318 + #[serde(default)]
319 + index: usize,
320 + id: Option<String>,
321 + function: Option<FunctionDelta>,
322 +}
323 +
324 +#[derive(Deserialize)]
325 +struct FunctionDelta {
326 + name: Option<String>,
327 + arguments: Option<String>,
328 +}
329 +
330 +/// A model from `GET /v1/models`. Extra fields in the response are ignored.
331 +#[derive(Debug, Clone, Deserialize)]
332 +pub struct ModelInfo {
333 + pub id: String,
334 + #[serde(default)]
335 + pub owned_by: Option<String>,
336 +}
337 +
338 +#[derive(Deserialize)]
339 +struct ModelsResponse {
340 + data: Vec<ModelInfo>,
341 +}
342 +
343 +#[derive(Debug, Clone)]
344 +pub struct LlmClient {
345 + http: reqwest::Client,
346 + config: LlmConfig,
347 +}
348 +
349 +impl LlmClient {
350 + pub fn new(config: LlmConfig) -> Self {
351 + Self {
352 + http: reqwest::Client::new(),
353 + config,
354 + }
355 + }
356 +
357 + pub fn model(&self) -> &str {
358 + &self.config.model
359 + }
360 +
361 + pub fn config(&self) -> &LlmConfig {
362 + &self.config
363 + }
364 +
365 + /// Whether an API key is configured.
366 + pub fn has_api_key(&self) -> bool {
367 + self.config.api_key.is_some()
368 + }
369 +
370 + /// Verify the configured key with a minimal chat completion.
371 + /// Returns `Err` when no key is set, the key is rejected, or the endpoint is unreachable.
372 + pub async fn check_auth(&self) -> Result<()> {
373 + let Some(key) = &self.config.api_key else {
374 + bail!("no ONDE_API_KEY configured");
375 + };
376 + let body = json!({
377 + "model": self.config.model,
378 + "messages": [{ "role": "user", "content": "ping" }],
379 + "max_tokens": 1,
380 + "stream": false,
381 + });
382 + let resp = self
383 + .http
384 + .post(format!("{}/chat/completions", self.config.base_url))
385 + .json(&body)
386 + .bearer_auth(key)
387 + .send()
388 + .await
389 + .context("reaching Onde Cloud")?;
390 + let status = resp.status();
391 + if matches!(status.as_u16(), 401 | 403) {
392 + bail!("Onde Cloud rejected the API key ({status})");
393 + }
394 + if !status.is_success() {
395 + let text = resp.text().await.unwrap_or_default();
396 + bail!("Onde Cloud returned {status}: {text}");
397 + }
398 + Ok(())
399 + }
400 +
401 + /// List the models the configured endpoint serves (`GET {base_url}/models`).
402 + pub async fn models(&self) -> Result<Vec<ModelInfo>> {
403 + let url = format!("{}/models", self.config.base_url);
404 + let mut req = self.http.get(&url);
405 + if let Some(key) = &self.config.api_key {
406 + req = req.bearer_auth(key);
407 + }
408 + let mut resp = req.send().await.context("sending models request")?;
409 + // Some endpoints (Onde Cloud) list models publicly but reject an invalid key, so a
410 + // bad key shouldn't hide the catalog: retry without auth.
411 + if self.config.api_key.is_some() && matches!(resp.status().as_u16(), 401 | 403) {
412 + resp = self
413 + .http
414 + .get(&url)
415 + .send()
416 + .await
417 + .context("sending models request")?;
418 + }
419 + if !resp.status().is_success() {
420 + let status = resp.status();
421 + let text = resp.text().await.unwrap_or_default();
422 + bail!("models endpoint returned {status}: {text}");
423 + }
424 + let mut models = resp
425 + .json::<ModelsResponse>()
426 + .await
427 + .context("parsing models response")?
428 + .data;
429 + models.sort_by(|a, b| a.id.cmp(&b.id));
430 + Ok(models)
431 + }
432 +
433 + /// Run one streaming chat completion, invoking `on_delta` for every text fragment.
434 + pub async fn complete(
435 + &self,
436 + model: &str,
437 + messages: &[Value],
438 + tools: &Value,
439 + mut on_delta: impl FnMut(Delta<'_>),
440 + ) -> Result<Completion> {
441 + if self.config.api_key.is_none() {
442 + bail!(
443 + "No API key configured. Set ONDE_API_KEY in the environment or run the agent's `--setup`"
444 + );
445 + }
446 + let mut body = json!({
447 + "model": model,
448 + "messages": messages,
449 + "tools": tools,
450 + "stream": true,
451 + "stream_options": { "include_usage": true },
452 + });
453 + let send = |body: &Value| {
454 + let mut req = self
455 + .http
456 + .post(format!("{}/chat/completions", self.config.base_url))
457 + .json(body);
458 + if let Some(key) = &self.config.api_key {
459 + req = req.bearer_auth(key);
460 + }
461 + req.send()
462 + };
463 + let mut resp = send(&body)
464 + .await
465 + .context("sending chat completion request")?;
466 + // Not every OpenAI-compatible server knows `stream_options`; retry without it.
467 + if matches!(resp.status().as_u16(), 400 | 422) {
468 + body.as_object_mut().unwrap().remove("stream_options");
469 + resp = send(&body)
470 + .await
471 + .context("sending chat completion request")?;
472 + }
473 + if !resp.status().is_success() {
474 + let status = resp.status();
475 + let text = resp.text().await.unwrap_or_default();
476 + bail!("LLM endpoint returned {status}: {text}");
477 + }
478 +
479 + let mut out = Completion::default();
480 + let mut stream = resp.bytes_stream();
481 + let mut buf: Vec<u8> = Vec::new();
482 + while let Some(bytes) = stream.next().await {
483 + buf.extend_from_slice(&bytes.context("reading response stream")?);
484 + while let Some(pos) = buf.iter().position(|&b| b == b'\n') {
485 + let line: Vec<u8> = buf.drain(..=pos).collect();
486 + let line = String::from_utf8_lossy(&line);
487 + let Some(data) = line.trim().strip_prefix("data:") else {
488 + continue;
489 + };
490 + let data = data.trim();
491 + if data == "[DONE]" {
492 + return Ok(out);
493 + }
494 + let chunk: Chunk = match serde_json::from_str(data) {
495 + Ok(c) => c,
496 + Err(e) => {
497 + tracing::warn!("skipping unparseable chunk ({e}): {data}");
498 + continue;
499 + }
500 + };
501 + if chunk.usage.is_some() {
502 + out.usage = chunk.usage;
503 + }
504 + for choice in chunk.choices {
505 + let d = choice.delta;
506 + if let Some(r) = d.reasoning_content.as_deref().or(d.reasoning.as_deref())
507 + && !r.is_empty()
508 + {
509 + on_delta(Delta::Reasoning(r));
510 + }
511 + if let Some(t) = d.content.as_deref()
512 + && !t.is_empty()
513 + {
514 + out.content.push_str(t);
515 + on_delta(Delta::Text(t));
516 + }
517 + for tc in d.tool_calls {
518 + if out.tool_calls.len() <= tc.index {
519 + out.tool_calls
520 + .resize(tc.index + 1, ToolCallRequest::default());
521 + }
522 + let slot = &mut out.tool_calls[tc.index];
523 + if let Some(id) = tc.id {
524 + slot.id = id;
525 + }
526 + if let Some(f) = tc.function {
527 + if let Some(n) = f.name {
528 + slot.name.push_str(&n);
529 + }
530 + if let Some(a) = f.arguments {
531 + slot.arguments.push_str(&a);
532 + }
533 + }
534 + }
535 + if choice.finish_reason.is_some() {
536 + out.finish_reason = choice.finish_reason;
537 + }
538 + }
539 + }
540 + }
541 + Ok(out)
542 + }
543 +}
crates/ed-acp/src/store.rs new
+174
@@ -0,0 +1,174 @@
1 +//! Durable sessions: one JSON file per session under `<data_dir>/sessions/`.
2 +//!
3 +//! The history stored here is the OpenAI-style message list without the system prompt
4 +//! (that is rebuilt every turn). Tool results are `role: "tool"` messages, which is what
5 +//! lets `session/load` replay tool calls with their output.
6 +
7 +use std::path::{Path, PathBuf};
8 +
9 +use anyhow::{Context, Result, bail};
10 +use serde::{Deserialize, Serialize};
11 +use serde_json::Value;
12 +
13 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14 +pub struct StoredSession {
15 + pub cwd: PathBuf,
16 + #[serde(default)]
17 + pub roots: Vec<PathBuf>,
18 + pub model: String,
19 + #[serde(default)]
20 + pub title: Option<String>,
21 + pub updated_at: u64,
22 + #[serde(default)]
23 + pub messages: Vec<Value>,
24 +}
25 +
26 +#[derive(Debug, Clone)]
27 +pub struct SessionStore {
28 + dir: PathBuf,
29 +}
30 +
31 +/// Session ids end up in file names: allow only characters that cannot escape the directory.
32 +pub fn sanitize_id(id: &str) -> Result<&str> {
33 + let ok = !id.is_empty()
34 + && id.len() <= 128
35 + && id
36 + .chars()
37 + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
38 + if !ok {
39 + bail!("invalid session id");
40 + }
41 + Ok(id)
42 +}
43 +
44 +impl SessionStore {
45 + pub fn new(data_dir: &Path) -> Self {
46 + Self {
47 + dir: data_dir.join("sessions"),
48 + }
49 + }
50 +
51 + fn path(&self, id: &str) -> Result<PathBuf> {
52 + Ok(self.dir.join(format!("{}.json", sanitize_id(id)?)))
53 + }
54 +
55 + /// Atomic write: temp file in the same directory, then rename.
56 + pub fn save(&self, id: &str, session: &StoredSession) -> Result<()> {
57 + let path = self.path(id)?;
58 + std::fs::create_dir_all(&self.dir)
59 + .with_context(|| format!("creating {}", self.dir.display()))?;
60 + let tmp = self.dir.join(format!(".{id}.{}.tmp", uuid::Uuid::new_v4()));
61 + std::fs::write(&tmp, serde_json::to_vec(session)?)?;
62 + std::fs::rename(&tmp, &path).inspect_err(|_| {
63 + let _ = std::fs::remove_file(&tmp);
64 + })?;
65 + Ok(())
66 + }
67 +
68 + /// `Ok(None)` when no such session exists (or the id is not a valid one).
69 + pub fn load(&self, id: &str) -> Result<Option<StoredSession>> {
70 + let Ok(path) = self.path(id) else {
71 + return Ok(None);
72 + };
73 + match std::fs::read(&path) {
74 + Ok(bytes) => Ok(Some(
75 + serde_json::from_slice(&bytes)
76 + .with_context(|| format!("parsing {}", path.display()))?,
77 + )),
78 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
79 + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
80 + }
81 + }
82 +
83 + /// All stored sessions, newest first. Unreadable files are skipped.
84 + pub fn list(&self) -> Vec<(String, StoredSession)> {
85 + let Ok(rd) = std::fs::read_dir(&self.dir) else {
86 + return Vec::new();
87 + };
88 + let mut out: Vec<(String, StoredSession)> = rd
89 + .filter_map(|e| {
90 + let path = e.ok()?.path();
91 + if path.extension()? != "json" {
92 + return None;
93 + }
94 + let id = path.file_stem()?.to_str()?.to_string();
95 + let session = serde_json::from_slice(&std::fs::read(&path).ok()?).ok()?;
96 + Some((id, session))
97 + })
98 + .collect();
99 + out.sort_by(|a, b| b.1.updated_at.cmp(&a.1.updated_at).then(a.0.cmp(&b.0)));
100 + out
101 + }
102 +
103 + /// Returns whether a file was removed.
104 + pub fn delete(&self, id: &str) -> Result<bool> {
105 + let Ok(path) = self.path(id) else {
106 + return Ok(false);
107 + };
108 + match std::fs::remove_file(&path) {
109 + Ok(()) => Ok(true),
110 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
111 + Err(e) => Err(e).with_context(|| format!("deleting {}", path.display())),
112 + }
113 + }
114 +}
115 +
116 +#[cfg(test)]
117 +mod tests {
118 + use super::*;
119 + use serde_json::json;
120 +
121 + fn tmp() -> PathBuf {
122 + let d = std::env::temp_dir().join(format!("sf-store-{}", uuid::Uuid::new_v4()));
123 + std::fs::create_dir_all(&d).unwrap();
124 + d
125 + }
126 +
127 + fn sample(updated_at: u64) -> StoredSession {
128 + StoredSession {
129 + cwd: "/work".into(),
130 + roots: vec!["/other".into()],
131 + model: "m".into(),
132 + title: Some("hello".into()),
133 + updated_at,
134 + messages: vec![
135 + json!({"role": "user", "content": "hi"}),
136 + json!({"role": "tool", "tool_call_id": "c1", "content": "out"}),
137 + ],
138 + }
139 + }
140 +
141 + #[test]
142 + fn round_trip_list_delete() {
143 + let dir = tmp();
144 + let store = SessionStore::new(&dir);
145 + assert!(store.load("a").unwrap().is_none());
146 + store.save("a", &sample(10)).unwrap();
147 + store.save("b", &sample(20)).unwrap();
148 + assert_eq!(store.load("a").unwrap().unwrap(), sample(10));
149 + let ids: Vec<_> = store.list().into_iter().map(|(id, _)| id).collect();
150 + assert_eq!(ids, ["b", "a"]);
151 + assert!(store.delete("a").unwrap());
152 + assert!(!store.delete("a").unwrap());
153 + assert!(store.load("a").unwrap().is_none());
154 + // No temp files left behind.
155 + let leftovers = std::fs::read_dir(dir.join("sessions"))
156 + .unwrap()
157 + .filter(|e| e.as_ref().unwrap().path().extension().unwrap() != "json")
158 + .count();
159 + assert_eq!(leftovers, 0);
160 + std::fs::remove_dir_all(dir).ok();
161 + }
162 +
163 + #[test]
164 + fn rejects_path_traversal_ids() {
165 + let dir = tmp();
166 + let store = SessionStore::new(&dir);
167 + for bad in ["../x", "a/b", "", "a.b", "..", "a\\b"] {
168 + assert!(store.save(bad, &sample(1)).is_err(), "{bad}");
169 + assert!(store.load(bad).unwrap().is_none());
170 + assert!(!store.delete(bad).unwrap());
171 + }
172 + std::fs::remove_dir_all(dir).ok();
173 + }
174 +}