Skip to main content

agent_top_core/harness/
opencode.rs

1//! OpenCode: sessions in a SQLite database, not a JSONL log.
2//!
3//! Format notes (verified on OpenCode 1.18.15, 2026-09-05, against the live
4//! `opencode.db` on this machine):
5//! * The store is `$XDG_DATA_HOME/opencode/opencode.db` (or
6//!   `~/.local/share/opencode/opencode.db`), a WAL SQLite database. agent-top
7//!   opens it read-only and never writes, which honours the observe-only rule
8//!   and does not block OpenCode's own writes.
9//! * `session` is one row per conversation and already carries the accounting:
10//!   `directory`, `agent` (the agent type, `build` / `explore` / `plan`),
11//!   `model` (a JSON blob `{"id","providerID","variant"}`), `cost` (US
12//!   dollars, computed by OpenCode), `tokens_input`, `tokens_output`,
13//!   `tokens_reasoning`, `tokens_cache_read`, `tokens_cache_write`,
14//!   `time_created`, `time_updated` (epoch ms), `parent_id` and `version`.
15//!   A subagent is a `session` row whose `parent_id` is the parent's id.
16//! * Because OpenCode has already priced the session, its `cost` is used
17//!   directly rather than re-priced from agent-top's table: OpenCode runs
18//!   third-party models (DeepSeek, and so on) that the table does not carry,
19//!   and the harness's own figure is the real one. So an OpenCode row's cost
20//!   is exact and never a floor, and `unpriced_tokens` is zero.
21//! * `message` is one row per message, `data` JSON with `role`
22//!   (`user` / `assistant`). Assistant messages are the turn count.
23//! * `part` is one row per message part, `data` JSON with `type`. A `tool`
24//!   part has `tool` (the name), `callID` and `state` with `status`
25//!   (`completed` / `error` / ...) and `time` `{start,end}` in epoch ms, which
26//!   is one tool span. `step-start` / `step-finish`, `reasoning`, `text` and
27//!   `patch` parts are not read yet.
28//! * MCP tool naming was not observable here (no MCP server is configured), so
29//!   per-server MCP counts are not produced for OpenCode yet; every tool part
30//!   is counted as a tool call and a span.
31//!
32//! A session has no file of its own, so a tracker is addressed by a virtual
33//! path `<db>/<session id>`: unique, stable, and with the session id as its
34//! file stem, which is all the collector and the trace resolver need.
35
36use super::{AttributeContext, HarnessAdapter, RegistryHints, SessionSummary, SessionTracker, SpanRetention};
37use crate::model::{Activity, Attribution, Harness, ProcNode, SpanKind, TokenUsage};
38use crate::process::RawProc;
39use rusqlite::{Connection, OpenFlags};
40use std::collections::HashSet;
41use std::path::{Path, PathBuf};
42use std::time::{Duration, SystemTime, UNIX_EPOCH};
43
44/// `$XDG_DATA_HOME/opencode`, or `~/.local/share/opencode`.
45pub fn data_dir() -> Option<PathBuf> {
46    if let Some(d) = std::env::var_os("OPENCODE_DATA_DIR") {
47        return Some(PathBuf::from(d));
48    }
49    if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
50        return Some(PathBuf::from(d).join("opencode"));
51    }
52    std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/opencode"))
53}
54
55/// The session database, when it exists.
56pub fn db_path() -> Option<PathBuf> {
57    let p = data_dir()?.join("opencode.db");
58    p.exists().then_some(p)
59}
60
61/// Open the database read-only. Read-only means agent-top can never write to
62/// or lock the file OpenCode is using; the WAL and its shared-memory index are
63/// read, not created.
64fn open_ro(db: &Path) -> rusqlite::Result<Connection> {
65    Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)
66}
67
68fn to_ms(t: SystemTime) -> i64 {
69    t.duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
70}
71
72fn from_ms(ms: i64) -> Option<SystemTime> {
73    (ms > 0).then(|| UNIX_EPOCH + Duration::from_millis(ms as u64))
74}
75
76/// One top-level conversation, enough to attribute it to a process and list it.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Session {
79    pub id: String,
80    pub directory: PathBuf,
81    pub created: Option<SystemTime>,
82    pub updated: Option<SystemTime>,
83}
84
85/// The virtual path that stands for a session on disk: the database path with
86/// the session id appended. Never opened as a file; only its stem is read.
87pub fn session_path(db: &Path, session_id: &str) -> PathBuf {
88    db.join(session_id)
89}
90
91/// The session id in a virtual path.
92pub fn session_id_of(path: &Path) -> Option<String> {
93    path.file_name().map(|f| f.to_string_lossy().into_owned())
94}
95
96/// Top-level sessions (no parent) written since `since`, newest activity first.
97pub fn recent_sessions(db: &Path, since: SystemTime) -> Vec<Session> {
98    let Ok(conn) = open_ro(db) else { return Vec::new() };
99    let sql = "SELECT id, directory, time_created, time_updated FROM session \
100               WHERE parent_id IS NULL AND time_updated >= ?1 ORDER BY time_updated DESC";
101    let Ok(mut stmt) = conn.prepare(sql) else { return Vec::new() };
102    let rows = stmt.query_map([to_ms(since)], |r| {
103        Ok(Session {
104            id: r.get::<_, String>(0)?,
105            directory: PathBuf::from(r.get::<_, String>(1)?),
106            created: from_ms(r.get::<_, i64>(2)?),
107            updated: from_ms(r.get::<_, i64>(3)?),
108        })
109    });
110    rows.map(|it| it.flatten().collect()).unwrap_or_default()
111}
112
113/// The model id inside OpenCode's `model` JSON blob (`{"id":...}`); the raw
114/// string if it is not that shape.
115fn model_id(raw: &str) -> Option<String> {
116    let raw = raw.trim();
117    if raw.is_empty() {
118        return None;
119    }
120    match serde_json::from_str::<serde_json::Value>(raw) {
121        Ok(v) => v.get("id").and_then(|x| x.as_str()).map(str::to_string).or_else(|| Some(raw.to_string())),
122        Err(_) => Some(raw.to_string()),
123    }
124}
125
126/// The OpenCode adapter. See the module notes for the store it reads.
127#[derive(Default)]
128pub struct OpenCodeAdapter {
129    db: Option<PathBuf>,
130    recent: Vec<Session>,
131}
132
133impl OpenCodeAdapter {
134    fn db(&self) -> Option<PathBuf> {
135        self.db.clone().or_else(db_path)
136    }
137}
138
139impl HarnessAdapter for OpenCodeAdapter {
140    fn harness(&self) -> Harness {
141        Harness::OpenCode
142    }
143
144    fn rescan(&mut self, since: SystemTime) {
145        self.db = db_path();
146        self.recent = match &self.db {
147            Some(db) => recent_sessions(db, since),
148            None => Vec::new(),
149        };
150    }
151
152    fn hints(&self, _pid: u32) -> Option<RegistryHints> {
153        None
154    }
155
156    /// One process runs one conversation, in the directory it was started in.
157    /// The newest top-level session in that directory that started before the
158    /// process, and that no other process has claimed, is the row.
159    fn attribute(&self, _root: &ProcNode, _raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
160        let (Some(cwd), Some(db)) = (ctx.cwd, self.db()) else { return (Vec::new(), Attribution::None) };
161        let slack = Duration::from_secs(60);
162        let mut mine: Vec<&Session> = self
163            .recent
164            .iter()
165            .filter(|s| s.directory == cwd)
166            .filter(|s| s.created.is_none_or(|c| c + slack >= ctx.proc_start))
167            .filter(|s| !ctx.attached.contains(&session_path(&db, &s.id)))
168            .collect();
169        mine.sort_by_key(|s| std::cmp::Reverse(s.updated));
170        match mine.first() {
171            Some(s) => (vec![session_path(&db, &s.id)], Attribution::CwdHeuristic),
172            None => (Vec::new(), Attribution::None),
173        }
174    }
175
176    fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
177        let Some(db) = self.db() else { return Vec::new() };
178        self.recent.iter().map(|s| session_path(&db, &s.id)).filter(|p| !attached.contains(p)).collect()
179    }
180
181    fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
182        Box::new(OpenCodeTranscript::new(path, spans))
183    }
184
185    /// OpenCode keeps no per-session file, so nothing on disk is detected as
186    /// OpenCode; a session is reached through `transcripts` by its id.
187    fn detect(&self, _path: &Path) -> bool {
188        false
189    }
190
191    fn transcripts(&self) -> Vec<(String, PathBuf)> {
192        let Some(db) = self.db() else { return Vec::new() };
193        recent_sessions(&db, UNIX_EPOCH).into_iter().map(|s| (s.id.clone(), session_path(&db, &s.id))).collect()
194    }
195}
196
197/// One OpenCode session as a `SessionSummary`, read from the database.
198///
199/// There is nothing to tail: each refresh re-reads the session row (and its
200/// subagent rows and tool parts) and rebuilds the summary. A cheap check on
201/// the session's `time_updated` skips the part scan when nothing changed, so a
202/// stopped session costs one small query per tick.
203pub struct OpenCodeTranscript {
204    db: PathBuf,
205    session_id: String,
206    virtual_path: PathBuf,
207    conn: Option<Connection>,
208    retention: SpanRetention,
209    summary: SessionSummary,
210    /// The `time_updated` last read, so an unchanged session is not re-scanned.
211    last_updated: Option<i64>,
212}
213
214impl OpenCodeTranscript {
215    pub fn new(virtual_path: &Path, retention: SpanRetention) -> Self {
216        // The virtual path is `<db>/<session id>`; split it back.
217        let session_id = session_id_of(virtual_path).unwrap_or_default();
218        let db = virtual_path.parent().map(Path::to_path_buf).unwrap_or_default();
219        OpenCodeTranscript {
220            db,
221            session_id,
222            virtual_path: virtual_path.to_path_buf(),
223            conn: None,
224            retention,
225            summary: SessionSummary { harness: Some(Harness::OpenCode), spans: retention.log(), ..Default::default() },
226            last_updated: None,
227        }
228    }
229
230    fn conn(&mut self) -> Option<&Connection> {
231        if self.conn.is_none() {
232            self.conn = open_ro(&self.db).ok();
233        }
234        self.conn.as_ref()
235    }
236
237    fn reload(&mut self) -> rusqlite::Result<()> {
238        let retention = self.retention;
239        let id = self.session_id.clone();
240        let Some(conn) = self.conn() else { return Ok(()) };
241
242        // The parent row plus every subagent row (parent_id = this session),
243        // so the fold is one query.
244        let mut summary = SessionSummary { harness: Some(Harness::OpenCode), spans: retention.log(), ..Default::default() };
245        let mut ids: Vec<(String, bool)> = Vec::new(); // (session id, is subagent)
246        {
247            let sql = "SELECT id, directory, agent, model, cost, tokens_input, tokens_output, tokens_reasoning, \
248                       tokens_cache_read, tokens_cache_write, time_created, time_updated, version, parent_id \
249                       FROM session WHERE id = ?1 OR parent_id = ?1 ORDER BY (parent_id IS NOT NULL), time_created";
250            let mut stmt = conn.prepare(sql)?;
251            let mut rows = stmt.query([&id])?;
252            while let Some(r) = rows.next()? {
253                let row_id: String = r.get(0)?;
254                let parent: Option<String> = r.get(13)?;
255                let is_sub = parent.is_some();
256                ids.push((row_id.clone(), is_sub));
257
258                let usage = TokenUsage {
259                    input: r.get::<_, i64>(5)? as u64,
260                    output: (r.get::<_, i64>(6)? + r.get::<_, i64>(7)?) as u64, // output + reasoning
261                    cache_read: r.get::<_, i64>(8)? as u64,
262                    cache_write_5m: r.get::<_, i64>(9)? as u64,
263                    cache_write_1h: 0,
264                };
265                summary.usage.add(&usage);
266                summary.cost_usd += r.get::<_, f64>(4)?;
267
268                if !is_sub {
269                    summary.session_id = Some(row_id.clone());
270                    summary.cwd = Some(PathBuf::from(r.get::<_, String>(1)?));
271                    summary.model = r.get::<_, Option<String>>(3)?.as_deref().and_then(model_id);
272                    summary.harness_version = r.get::<_, Option<String>>(12)?;
273                    summary.started_at = from_ms(r.get::<_, i64>(10)?);
274                    summary.last_activity = from_ms(r.get::<_, i64>(11)?);
275                }
276            }
277        }
278        if ids.is_empty() {
279            // The session was deleted; keep the empty summary.
280            self.summary = summary;
281            return Ok(());
282        }
283
284        // Turns: assistant messages, split parent vs subagent.
285        for (sid, is_sub) in &ids {
286            let n: i64 = conn.query_row(
287                "SELECT count(*) FROM message WHERE session_id = ?1 AND json_extract(data,'$.role') = 'assistant'",
288                [sid],
289                |r| r.get(0),
290            )?;
291            summary.turns += n as u64;
292            summary.health.billable_messages += n as u64;
293            if *is_sub {
294                summary.subagent_turns += n as u64;
295            }
296        }
297        summary.health.usage_records = summary.health.billable_messages;
298        if summary.usage.total() == 0 {
299            summary.health.empty_usage_records = summary.health.usage_records;
300        }
301
302        // Tool calls and their spans. For the live view only the newest
303        // MAX_SPANS matter, so bound the query; the export keeps them all.
304        for (sid, _is_sub) in &ids {
305            summary.tool_calls +=
306                conn.query_row("SELECT count(*) FROM part WHERE session_id = ?1 AND json_extract(data,'$.type') = 'tool'", [sid], |r| {
307                    r.get::<_, i64>(0)
308                })? as u64;
309        }
310        let limit = match retention {
311            SpanRetention::All => -1,
312            SpanRetention::Recent => super::MAX_SPANS as i64,
313        };
314        let sub_ids: HashSet<&str> = ids.iter().filter(|(_, s)| *s).map(|(i, _)| i.as_str()).collect();
315        {
316            let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
317            let sql = format!(
318                "SELECT session_id, json_extract(data,'$.tool'), json_extract(data,'$.callID'), \
319                 json_extract(data,'$.state.status'), json_extract(data,'$.state.time.start'), \
320                 json_extract(data,'$.state.time.end') \
321                 FROM part WHERE json_extract(data,'$.type') = 'tool' AND session_id IN ({placeholders}) \
322                 ORDER BY time_created DESC LIMIT ?{}",
323                ids.len() + 1
324            );
325            let mut stmt = conn.prepare(&sql)?;
326            let params: Vec<&dyn rusqlite::ToSql> =
327                ids.iter().map(|(i, _)| i as &dyn rusqlite::ToSql).chain(std::iter::once(&limit as &dyn rusqlite::ToSql)).collect();
328            let mut rows = stmt.query(params.as_slice())?;
329            #[derive(Clone)]
330            struct Row {
331                name: String,
332                id: String,
333                sidechain: bool,
334                error: bool,
335                start: SystemTime,
336                end: SystemTime,
337            }
338            let mut collected: Vec<Row> = Vec::new();
339            while let Some(r) = rows.next()? {
340                let sid: String = r.get(0)?;
341                let name: String = r.get::<_, Option<String>>(1)?.unwrap_or_else(|| "tool".into());
342                let call_id: String = r.get::<_, Option<String>>(2)?.unwrap_or_default();
343                let status: Option<String> = r.get(3)?;
344                let Some(start) = r.get::<_, Option<i64>>(4)?.and_then(from_ms) else { continue };
345                let end = r.get::<_, Option<i64>>(5)?.and_then(from_ms).unwrap_or(start);
346                collected.push(Row {
347                    name,
348                    id: call_id,
349                    sidechain: sub_ids.contains(sid.as_str()),
350                    error: status.as_deref() == Some("error"),
351                    start,
352                    end,
353                });
354            }
355            // The query returned newest first; a waterfall wants oldest first.
356            collected.sort_by_key(|r| r.start);
357            for (i, row) in collected.into_iter().enumerate() {
358                // A part without a callID still gets a unique, stable-per-scan id.
359                let id = if row.id.is_empty() { format!("oc-{i}") } else { row.id };
360                summary.spans.open_kind(id.clone(), row.name, row.start, row.sidechain, SpanKind::Tool);
361                summary.spans.close(&id, row.end.max(row.start), row.error);
362            }
363        }
364
365        summary.activity = Activity::Unknown;
366        self.summary = summary;
367        Ok(())
368    }
369}
370
371impl SessionTracker for OpenCodeTranscript {
372    fn refresh(&mut self) -> anyhow::Result<bool> {
373        let id = self.session_id.clone();
374        let updated: Option<i64> =
375            self.conn().and_then(|c| c.query_row("SELECT time_updated FROM session WHERE id = ?1", [&id], |r| r.get(0)).ok());
376        // Nothing changed since the last read: keep the summary as is.
377        if updated.is_some() && updated == self.last_updated {
378            return Ok(false);
379        }
380        self.last_updated = updated;
381        // A schema change or a locked read must not crash the collector; on
382        // error the summary is left as it was and the row simply does not update.
383        let _ = self.reload();
384        Ok(false)
385    }
386
387    fn summary(&self) -> &SessionSummary {
388        &self.summary
389    }
390
391    fn path(&self) -> &Path {
392        &self.virtual_path
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use rusqlite::Connection;
400
401    /// A minimal OpenCode database with the columns the adapter reads.
402    fn make_db(dir: &Path) -> PathBuf {
403        let db = dir.join("opencode.db");
404        let conn = Connection::open(&db).unwrap();
405        conn.execute_batch(
406            "CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, directory TEXT, agent TEXT, \
407             model TEXT, cost REAL DEFAULT 0, tokens_input INTEGER DEFAULT 0, tokens_output INTEGER DEFAULT 0, \
408             tokens_reasoning INTEGER DEFAULT 0, tokens_cache_read INTEGER DEFAULT 0, tokens_cache_write INTEGER DEFAULT 0, \
409             time_created INTEGER, time_updated INTEGER, version TEXT);
410             CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT);
411             CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);",
412        )
413        .unwrap();
414        // A parent session and one subagent.
415        conn.execute(
416            "INSERT INTO session VALUES ('ses_parent', 'p', NULL, '/tmp/proj', 'build', \
417             '{\"id\":\"deepseek-v4-pro\",\"providerID\":\"deepseek\"}', 0.25, 1000, 200, 50, 900000, 0, 1000, 5000, '1.18.15')",
418            [],
419        )
420        .unwrap();
421        conn.execute(
422            "INSERT INTO session VALUES ('ses_child', 'p', 'ses_parent', '/tmp/proj', 'explore', \
423             '{\"id\":\"deepseek-v4-pro\"}', 0.05, 300, 40, 10, 1000, 0, 2000, 3000, '1.18.15')",
424            [],
425        )
426        .unwrap();
427        // Two assistant turns and one user in the parent, one assistant in the child.
428        for (i, sid, role) in
429            [(1, "ses_parent", "user"), (2, "ses_parent", "assistant"), (3, "ses_parent", "assistant"), (4, "ses_child", "assistant")]
430        {
431            conn.execute(
432                "INSERT INTO message VALUES (?1, ?2, ?3, ?4)",
433                rusqlite::params![format!("m{i}"), sid, 1000 + i as i64, format!("{{\"role\":\"{role}\"}}")],
434            )
435            .unwrap();
436        }
437        // Tool parts: two in the parent (one failing), one in the child.
438        let tool = |tool: &str, call: &str, status: &str, start: i64, end: i64| {
439            format!(
440                "{{\"type\":\"tool\",\"tool\":\"{tool}\",\"callID\":\"{call}\",\"state\":{{\"status\":\"{status}\",\"time\":{{\"start\":{start},\"end\":{end}}}}}}}"
441            )
442        };
443        for (i, sid, data) in [
444            (1, "ses_parent", tool("read", "c1", "completed", 1000, 1300)),
445            (2, "ses_parent", tool("bash", "c2", "error", 1400, 2400)),
446            (3, "ses_child", tool("grep", "c3", "completed", 1500, 1600)),
447        ] {
448            conn.execute("INSERT INTO part VALUES (?1, 'm', ?2, ?3, ?4)", rusqlite::params![format!("p{i}"), sid, 1000 + i as i64, data])
449                .unwrap();
450        }
451        db
452    }
453
454    #[test]
455    fn reads_a_session_folds_its_subagent_and_builds_tool_spans() {
456        let dir = std::env::temp_dir().join(format!("agent-top-oc-{}", std::process::id()));
457        let _ = std::fs::remove_dir_all(&dir);
458        std::fs::create_dir_all(&dir).unwrap();
459        let db = make_db(&dir);
460        let path = session_path(&db, "ses_parent");
461        let mut t = OpenCodeTranscript::new(&path, SpanRetention::All);
462        t.refresh().unwrap();
463        let s = t.summary();
464        assert_eq!(s.session_id.as_deref(), Some("ses_parent"));
465        assert_eq!(s.model.as_deref(), Some("deepseek-v4-pro"));
466        assert_eq!(s.cwd.as_deref(), Some(Path::new("/tmp/proj")));
467        assert_eq!(s.harness_version.as_deref(), Some("1.18.15"));
468        // Parent input 1000 + child 300; output+reasoning parent 250 + child 50.
469        assert_eq!(s.usage.input, 1300);
470        assert_eq!(s.usage.output, 300);
471        assert_eq!(s.usage.cache_read, 901000);
472        // OpenCode's own cost, parent + subagent, used directly.
473        assert!((s.cost_usd - 0.30).abs() < 1e-9, "{}", s.cost_usd);
474        assert_eq!(s.unpriced_tokens, 0, "OpenCode prices its own session");
475        assert_eq!(s.turns, 3, "two assistant turns in the parent, one in the subagent");
476        assert_eq!(s.subagent_turns, 1);
477        assert_eq!(s.tool_calls, 3);
478        let tools: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
479        assert_eq!(tools.len(), 3);
480        assert_eq!(tools[0].name, "read");
481        assert_eq!(tools[0].duration_ms, Some(300));
482        let bash = tools.iter().find(|sp| sp.name == "bash").unwrap();
483        assert!(bash.error);
484        let grep = tools.iter().find(|sp| sp.name == "grep").unwrap();
485        assert!(grep.sidechain, "the subagent's tool call is a sidechain");
486        assert!(!s.health.fields_unrecognised());
487        let _ = std::fs::remove_dir_all(&dir);
488    }
489
490    #[test]
491    fn recent_sessions_lists_only_top_level_and_attributes_by_directory() {
492        let dir = std::env::temp_dir().join(format!("agent-top-oc-attr-{}", std::process::id()));
493        let _ = std::fs::remove_dir_all(&dir);
494        std::fs::create_dir_all(&dir).unwrap();
495        let db = make_db(&dir);
496        let found = recent_sessions(&db, UNIX_EPOCH);
497        assert_eq!(found.len(), 1, "only the parent, not the subagent");
498        assert_eq!(found[0].id, "ses_parent");
499        assert_eq!(found[0].directory, PathBuf::from("/tmp/proj"));
500
501        let adapter = OpenCodeAdapter { db: Some(db.clone()), recent: found };
502        let ctx = AttributeContext {
503            cwd: Some(Path::new("/tmp/proj")),
504            proc_start: UNIX_EPOCH + Duration::from_secs(3),
505            now: SystemTime::now(),
506            attached: &HashSet::new(),
507            activity_timeout: Duration::from_secs(900),
508        };
509        let root = ProcNode {
510            pid: 1,
511            ppid: None,
512            name: "opencode".into(),
513            cmdline: "opencode".into(),
514            kind: crate::model::ProcKind::Agent,
515            harness: Some(Harness::OpenCode),
516            cpu_percent: 0.0,
517            rss_bytes: 0,
518            age_secs: 0,
519            cwd: None,
520            children: Vec::new(),
521        };
522        let (paths, attribution) = adapter.attribute(&root, None, &ctx);
523        assert_eq!(paths, vec![session_path(&db, "ses_parent")]);
524        assert_eq!(attribution, Attribution::CwdHeuristic);
525        // A different directory gets nothing.
526        let ctx2 = AttributeContext { cwd: Some(Path::new("/tmp/other")), ..ctx };
527        assert!(adapter.attribute(&root, None, &ctx2).0.is_empty());
528        let _ = std::fs::remove_dir_all(&dir);
529    }
530
531    #[test]
532    fn model_id_is_pulled_from_the_json_blob() {
533        assert_eq!(model_id(r#"{"id":"deepseek-v4-pro","providerID":"deepseek"}"#), Some("deepseek-v4-pro".into()));
534        assert_eq!(model_id("claude-fable-5-1"), Some("claude-fable-5-1".into()));
535        assert_eq!(model_id(""), None);
536    }
537}