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
+}