1use super::{AttributeContext, HarnessAdapter, RegistryHints, SessionSummary, SessionTracker, SpanRetention};
42use crate::model::{Activity, Attribution, Harness, ProcNode, SpanKind, TokenUsage};
43use crate::process::RawProc;
44use rusqlite::{Connection, OpenFlags};
45use std::collections::HashSet;
46use std::path::{Path, PathBuf};
47use std::time::{Duration, SystemTime, UNIX_EPOCH};
48
49pub fn data_dir() -> Option<PathBuf> {
51 if let Some(d) = std::env::var_os("OPENCODE_DATA_DIR") {
52 return Some(PathBuf::from(d));
53 }
54 if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
55 return Some(PathBuf::from(d).join("opencode"));
56 }
57 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/opencode"))
58}
59
60pub fn db_path() -> Option<PathBuf> {
62 let p = data_dir()?.join("opencode.db");
63 p.exists().then_some(p)
64}
65
66fn open_ro(db: &Path) -> rusqlite::Result<Connection> {
70 Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)
71}
72
73fn to_ms(t: SystemTime) -> i64 {
74 t.duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
75}
76
77fn from_ms(ms: i64) -> Option<SystemTime> {
78 (ms > 0).then(|| UNIX_EPOCH + Duration::from_millis(ms as u64))
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Session {
84 pub id: String,
85 pub directory: PathBuf,
86 pub created: Option<SystemTime>,
87 pub updated: Option<SystemTime>,
88}
89
90pub fn session_path(db: &Path, session_id: &str) -> PathBuf {
93 db.join(session_id)
94}
95
96pub fn session_id_of(path: &Path) -> Option<String> {
98 path.file_name().map(|f| f.to_string_lossy().into_owned())
99}
100
101pub fn recent_sessions(db: &Path, since: SystemTime) -> Vec<Session> {
103 let Ok(conn) = open_ro(db) else { return Vec::new() };
104 let sql = "SELECT id, directory, time_created, time_updated FROM session \
105 WHERE parent_id IS NULL AND time_updated >= ?1 ORDER BY time_updated DESC";
106 let Ok(mut stmt) = conn.prepare(sql) else { return Vec::new() };
107 let rows = stmt.query_map([to_ms(since)], |r| {
108 Ok(Session {
109 id: r.get::<_, String>(0)?,
110 directory: PathBuf::from(r.get::<_, String>(1)?),
111 created: from_ms(r.get::<_, i64>(2)?),
112 updated: from_ms(r.get::<_, i64>(3)?),
113 })
114 });
115 rows.map(|it| it.flatten().collect()).unwrap_or_default()
116}
117
118fn model_id(raw: &str) -> Option<String> {
121 let raw = raw.trim();
122 if raw.is_empty() {
123 return None;
124 }
125 match serde_json::from_str::<serde_json::Value>(raw) {
126 Ok(v) => v.get("id").and_then(|x| x.as_str()).map(str::to_string).or_else(|| Some(raw.to_string())),
127 Err(_) => Some(raw.to_string()),
128 }
129}
130
131#[derive(Default)]
133pub struct OpenCodeAdapter {
134 db: Option<PathBuf>,
135 recent: Vec<Session>,
136}
137
138impl OpenCodeAdapter {
139 fn db(&self) -> Option<PathBuf> {
140 self.db.clone().or_else(db_path)
141 }
142}
143
144impl HarnessAdapter for OpenCodeAdapter {
145 fn harness(&self) -> Harness {
146 Harness::OpenCode
147 }
148
149 fn rescan(&mut self, since: SystemTime) {
150 self.db = db_path();
151 self.recent = match &self.db {
152 Some(db) => recent_sessions(db, since),
153 None => Vec::new(),
154 };
155 }
156
157 fn hints(&self, _pid: u32) -> Option<RegistryHints> {
158 None
159 }
160
161 fn attribute(&self, _root: &ProcNode, _raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
165 let (Some(cwd), Some(db)) = (ctx.cwd, self.db()) else { return (Vec::new(), Attribution::None) };
166 let slack = Duration::from_secs(60);
167 let mut mine: Vec<&Session> = self
168 .recent
169 .iter()
170 .filter(|s| s.directory == cwd)
171 .filter(|s| s.created.is_none_or(|c| c + slack >= ctx.proc_start))
172 .filter(|s| !ctx.attached.contains(&session_path(&db, &s.id)))
173 .collect();
174 mine.sort_by_key(|s| std::cmp::Reverse(s.updated));
175 match mine.first() {
176 Some(s) => (vec![session_path(&db, &s.id)], Attribution::CwdHeuristic),
177 None => (Vec::new(), Attribution::None),
178 }
179 }
180
181 fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
182 let Some(db) = self.db() else { return Vec::new() };
183 self.recent.iter().map(|s| session_path(&db, &s.id)).filter(|p| !attached.contains(p)).collect()
184 }
185
186 fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
187 Box::new(OpenCodeTranscript::new(path, spans))
188 }
189
190 fn detect(&self, _path: &Path) -> bool {
193 false
194 }
195
196 fn transcripts(&self) -> Vec<(String, PathBuf)> {
197 let Some(db) = self.db() else { return Vec::new() };
198 recent_sessions(&db, UNIX_EPOCH).into_iter().map(|s| (s.id.clone(), session_path(&db, &s.id))).collect()
199 }
200}
201
202pub struct OpenCodeTranscript {
209 db: PathBuf,
210 session_id: String,
211 virtual_path: PathBuf,
212 conn: Option<Connection>,
213 retention: SpanRetention,
214 summary: SessionSummary,
215 last_updated: Option<i64>,
217}
218
219impl OpenCodeTranscript {
220 pub fn new(virtual_path: &Path, retention: SpanRetention) -> Self {
221 let session_id = session_id_of(virtual_path).unwrap_or_default();
223 let db = virtual_path.parent().map(Path::to_path_buf).unwrap_or_default();
224 OpenCodeTranscript {
225 db,
226 session_id,
227 virtual_path: virtual_path.to_path_buf(),
228 conn: None,
229 retention,
230 summary: SessionSummary { harness: Some(Harness::OpenCode), spans: retention.log(), ..Default::default() },
231 last_updated: None,
232 }
233 }
234
235 fn conn(&mut self) -> Option<&Connection> {
236 if self.conn.is_none() {
237 self.conn = open_ro(&self.db).ok();
238 }
239 self.conn.as_ref()
240 }
241
242 fn reload(&mut self) -> rusqlite::Result<()> {
243 let retention = self.retention;
244 let id = self.session_id.clone();
245 let Some(conn) = self.conn() else { return Ok(()) };
246
247 let mut summary = SessionSummary { harness: Some(Harness::OpenCode), spans: retention.log(), ..Default::default() };
250 let mut ids: Vec<(String, bool)> = Vec::new(); {
252 let sql = "SELECT id, directory, agent, model, cost, tokens_input, tokens_output, tokens_reasoning, \
253 tokens_cache_read, tokens_cache_write, time_created, time_updated, version, parent_id \
254 FROM session WHERE id = ?1 OR parent_id = ?1 ORDER BY (parent_id IS NOT NULL), time_created";
255 let mut stmt = conn.prepare(sql)?;
256 let mut rows = stmt.query([&id])?;
257 while let Some(r) = rows.next()? {
258 let row_id: String = r.get(0)?;
259 let parent: Option<String> = r.get(13)?;
260 let is_sub = parent.is_some();
261 ids.push((row_id.clone(), is_sub));
262
263 let usage = TokenUsage {
264 input: r.get::<_, i64>(5)? as u64,
265 output: (r.get::<_, i64>(6)? + r.get::<_, i64>(7)?) as u64, cache_read: r.get::<_, i64>(8)? as u64,
267 cache_write_5m: r.get::<_, i64>(9)? as u64,
268 cache_write_1h: 0,
269 };
270 summary.usage.add(&usage);
271 summary.cost_usd += r.get::<_, f64>(4)?;
272
273 if !is_sub {
274 summary.session_id = Some(row_id.clone());
275 summary.cwd = Some(PathBuf::from(r.get::<_, String>(1)?));
276 summary.model = r.get::<_, Option<String>>(3)?.as_deref().and_then(model_id);
277 summary.harness_version = r.get::<_, Option<String>>(12)?;
278 summary.started_at = from_ms(r.get::<_, i64>(10)?);
279 summary.last_activity = from_ms(r.get::<_, i64>(11)?);
280 }
281 }
282 }
283 if ids.is_empty() {
284 self.summary = summary;
286 return Ok(());
287 }
288
289 for (sid, is_sub) in &ids {
291 let n: i64 = conn.query_row(
292 "SELECT count(*) FROM message WHERE session_id = ?1 AND json_extract(data,'$.role') = 'assistant'",
293 [sid],
294 |r| r.get(0),
295 )?;
296 summary.turns += n as u64;
297 summary.health.billable_messages += n as u64;
298 if *is_sub {
299 summary.subagent_turns += n as u64;
300 }
301 }
302 summary.health.usage_records = summary.health.billable_messages;
303 if summary.usage.total() == 0 {
304 summary.health.empty_usage_records = summary.health.usage_records;
305 }
306
307 for (sid, _is_sub) in &ids {
311 summary.tool_calls +=
312 conn.query_row("SELECT count(*) FROM part WHERE session_id = ?1 AND json_extract(data,'$.type') = 'tool'", [sid], |r| {
313 r.get::<_, i64>(0)
314 })? as u64;
315 }
316 let limit = match retention {
317 SpanRetention::All => -1,
318 SpanRetention::Recent => super::MAX_SPANS as i64,
319 };
320 let sub_ids: HashSet<&str> = ids.iter().filter(|(_, s)| *s).map(|(i, _)| i.as_str()).collect();
321 let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
322
323 struct Pending {
326 id: String,
327 name: String,
328 kind: SpanKind,
329 start: SystemTime,
330 end: Option<SystemTime>,
333 sidechain: bool,
334 error: bool,
335 }
336 let mut pending: Vec<Pending> = Vec::new();
337
338 {
340 let sql = format!(
341 "SELECT session_id, json_extract(data,'$.tool'), json_extract(data,'$.callID'), \
342 json_extract(data,'$.state.status'), json_extract(data,'$.state.time.start'), \
343 json_extract(data,'$.state.time.end') \
344 FROM part WHERE json_extract(data,'$.type') = 'tool' AND session_id IN ({placeholders}) \
345 ORDER BY time_created DESC LIMIT ?{}",
346 ids.len() + 1
347 );
348 let mut stmt = conn.prepare(&sql)?;
349 let params: Vec<&dyn rusqlite::ToSql> =
350 ids.iter().map(|(i, _)| i as &dyn rusqlite::ToSql).chain(std::iter::once(&limit as &dyn rusqlite::ToSql)).collect();
351 let mut rows = stmt.query(params.as_slice())?;
352 let mut i = 0;
353 while let Some(r) = rows.next()? {
354 let sid: String = r.get(0)?;
355 let name: String = r.get::<_, Option<String>>(1)?.unwrap_or_else(|| "tool".into());
356 let call_id: String = r.get::<_, Option<String>>(2)?.unwrap_or_default();
357 let status: Option<String> = r.get(3)?;
358 let Some(start) = r.get::<_, Option<i64>>(4)?.and_then(from_ms) else { continue };
359 let end = r.get::<_, Option<i64>>(5)?.and_then(from_ms);
360 let id = if call_id.is_empty() { format!("oc-tool-{i}") } else { call_id };
361 i += 1;
362 pending.push(Pending {
363 id,
364 name,
365 kind: SpanKind::Tool,
366 start,
367 end: Some(end.unwrap_or(start).max(start)),
368 sidechain: sub_ids.contains(sid.as_str()),
369 error: status.as_deref() == Some("error"),
370 });
371 }
372 }
373
374 {
380 let sql = format!(
381 "SELECT session_id, json_extract(data,'$.role'), json_extract(data,'$.time.created'), \
382 json_extract(data,'$.time.completed') FROM message WHERE session_id IN ({placeholders}) \
383 ORDER BY json_extract(data,'$.time.created') DESC LIMIT ?{}",
384 ids.len() + 1
385 );
386 let mut stmt = conn.prepare(&sql)?;
387 let params: Vec<&dyn rusqlite::ToSql> =
388 ids.iter().map(|(i, _)| i as &dyn rusqlite::ToSql).chain(std::iter::once(&limit as &dyn rusqlite::ToSql)).collect();
389 let mut rows = stmt.query(params.as_slice())?;
390 struct Msg {
391 sid: String,
392 role: String,
393 created: SystemTime,
394 completed: Option<SystemTime>,
395 }
396 let mut msgs: Vec<Msg> = Vec::new();
397 while let Some(r) = rows.next()? {
398 let sid: String = r.get(0)?;
399 let role: String = r.get::<_, Option<String>>(1)?.unwrap_or_default();
400 let Some(created) = r.get::<_, Option<i64>>(2)?.and_then(from_ms) else { continue };
401 let completed = r.get::<_, Option<i64>>(3)?.and_then(from_ms);
402 msgs.push(Msg { sid, role, created, completed });
403 }
404 msgs.sort_by_key(|m| m.created);
405 match msgs.last() {
408 Some(m) if m.role == "assistant" && m.completed.is_some() => summary.activity = Activity::Waiting,
409 Some(_) => summary.activity = Activity::Working,
410 None => {}
411 }
412 let sessions: Vec<String> = ids.iter().map(|(i, _)| i.clone()).collect();
414 for sess in &sessions {
415 let sidechain = sub_ids.contains(sess.as_str());
416 let mut turn_idx = 0u64;
417 let mut turn: Option<(String, SystemTime, Option<SystemTime>)> = None; let mut inf_idx = 0u64;
419 let flush = |turn: &mut Option<(String, SystemTime, Option<SystemTime>)>, pending: &mut Vec<Pending>| {
420 if let Some((id, start, end)) = turn.take() {
421 pending.push(Pending { id, name: "turn".into(), kind: SpanKind::Turn, start, end, sidechain, error: false });
422 }
423 };
424 for m in msgs.iter().filter(|m| &m.sid == sess) {
425 if m.role == "user" {
426 flush(&mut turn, &mut pending);
427 turn_idx += 1;
428 turn = Some((format!("turn:{sess}:{turn_idx}"), m.created, None));
429 } else if m.role == "assistant" {
430 inf_idx += 1;
431 pending.push(Pending {
432 id: format!("inf:{sess}:{inf_idx}"),
433 name: "inference".into(),
434 kind: SpanKind::Inference,
435 start: m.created,
436 end: m.completed,
437 sidechain,
438 error: false,
439 });
440 let end = m.completed.unwrap_or(m.created);
443 match &mut turn {
444 Some((_, _, e)) => *e = Some(end),
445 None => {
446 turn_idx += 1;
447 turn = Some((format!("turn:{sess}:{turn_idx}"), m.created, Some(end)));
448 }
449 }
450 }
451 }
452 flush(&mut turn, &mut pending);
453 }
454 }
455
456 pending.sort_by_key(|p| p.start);
459 for p in pending {
460 summary.spans.open_kind(p.id.clone(), p.name, p.start, p.sidechain, p.kind);
461 if let Some(end) = p.end {
462 if p.error {
463 summary.spans.close(&p.id, end.max(p.start), true);
464 } else {
465 summary.spans.end_at(&p.id, end.max(p.start));
466 }
467 }
468 }
469
470 self.summary = summary;
472 Ok(())
473 }
474}
475
476impl SessionTracker for OpenCodeTranscript {
477 fn refresh(&mut self) -> anyhow::Result<bool> {
478 let id = self.session_id.clone();
479 let updated: Option<i64> =
480 self.conn().and_then(|c| c.query_row("SELECT time_updated FROM session WHERE id = ?1", [&id], |r| r.get(0)).ok());
481 if updated.is_some() && updated == self.last_updated {
483 return Ok(false);
484 }
485 self.last_updated = updated;
486 let _ = self.reload();
489 Ok(false)
490 }
491
492 fn summary(&self) -> &SessionSummary {
493 &self.summary
494 }
495
496 fn path(&self) -> &Path {
497 &self.virtual_path
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use rusqlite::Connection;
505
506 fn make_db(dir: &Path) -> PathBuf {
508 let db = dir.join("opencode.db");
509 let conn = Connection::open(&db).unwrap();
510 conn.execute_batch(
511 "CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, directory TEXT, agent TEXT, \
512 model TEXT, cost REAL DEFAULT 0, tokens_input INTEGER DEFAULT 0, tokens_output INTEGER DEFAULT 0, \
513 tokens_reasoning INTEGER DEFAULT 0, tokens_cache_read INTEGER DEFAULT 0, tokens_cache_write INTEGER DEFAULT 0, \
514 time_created INTEGER, time_updated INTEGER, version TEXT);
515 CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT);
516 CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);",
517 )
518 .unwrap();
519 conn.execute(
521 "INSERT INTO session VALUES ('ses_parent', 'p', NULL, '/tmp/proj', 'build', \
522 '{\"id\":\"deepseek-v4-pro\",\"providerID\":\"deepseek\"}', 0.25, 1000, 200, 50, 900000, 0, 1000, 5000, '1.18.15')",
523 [],
524 )
525 .unwrap();
526 conn.execute(
527 "INSERT INTO session VALUES ('ses_child', 'p', 'ses_parent', '/tmp/proj', 'explore', \
528 '{\"id\":\"deepseek-v4-pro\"}', 0.05, 300, 40, 10, 1000, 0, 2000, 3000, '1.18.15')",
529 [],
530 )
531 .unwrap();
532 let msg = |role: &str, created: i64, completed: Option<i64>| match completed {
536 Some(c) => format!("{{\"role\":\"{role}\",\"time\":{{\"created\":{created},\"completed\":{c}}}}}"),
537 None => format!("{{\"role\":\"{role}\",\"time\":{{\"created\":{created}}}}}"),
538 };
539 for (i, sid, data) in [
540 (1, "ses_parent", msg("user", 100, None)),
541 (2, "ses_parent", msg("assistant", 110, Some(200))),
542 (3, "ses_parent", msg("assistant", 210, Some(300))),
543 (4, "ses_child", msg("assistant", 150, Some(180))),
544 ] {
545 conn.execute("INSERT INTO message VALUES (?1, ?2, ?3, ?4)", rusqlite::params![format!("m{i}"), sid, 1000 + i as i64, data])
546 .unwrap();
547 }
548 let tool = |tool: &str, call: &str, status: &str, start: i64, end: i64| {
550 format!(
551 "{{\"type\":\"tool\",\"tool\":\"{tool}\",\"callID\":\"{call}\",\"state\":{{\"status\":\"{status}\",\"time\":{{\"start\":{start},\"end\":{end}}}}}}}"
552 )
553 };
554 for (i, sid, data) in [
555 (1, "ses_parent", tool("read", "c1", "completed", 1000, 1300)),
556 (2, "ses_parent", tool("bash", "c2", "error", 1400, 2400)),
557 (3, "ses_child", tool("grep", "c3", "completed", 1500, 1600)),
558 ] {
559 conn.execute("INSERT INTO part VALUES (?1, 'm', ?2, ?3, ?4)", rusqlite::params![format!("p{i}"), sid, 1000 + i as i64, data])
560 .unwrap();
561 }
562 db
563 }
564
565 #[test]
566 fn reads_a_session_folds_its_subagent_and_builds_tool_spans() {
567 let dir = std::env::temp_dir().join(format!("agent-top-oc-{}", std::process::id()));
568 let _ = std::fs::remove_dir_all(&dir);
569 std::fs::create_dir_all(&dir).unwrap();
570 let db = make_db(&dir);
571 let path = session_path(&db, "ses_parent");
572 let mut t = OpenCodeTranscript::new(&path, SpanRetention::All);
573 t.refresh().unwrap();
574 let s = t.summary();
575 assert_eq!(s.session_id.as_deref(), Some("ses_parent"));
576 assert_eq!(s.model.as_deref(), Some("deepseek-v4-pro"));
577 assert_eq!(s.cwd.as_deref(), Some(Path::new("/tmp/proj")));
578 assert_eq!(s.harness_version.as_deref(), Some("1.18.15"));
579 assert_eq!(s.usage.input, 1300);
581 assert_eq!(s.usage.output, 300);
582 assert_eq!(s.usage.cache_read, 901000);
583 assert!((s.cost_usd - 0.30).abs() < 1e-9, "{}", s.cost_usd);
585 assert_eq!(s.unpriced_tokens, 0, "OpenCode prices its own session");
586 assert_eq!(s.turns, 3, "two assistant turns in the parent, one in the subagent");
587 assert_eq!(s.subagent_turns, 1);
588 assert_eq!(s.tool_calls, 3);
589 let tools: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
590 assert_eq!(tools.len(), 3);
591 assert_eq!(tools[0].name, "read");
592 assert_eq!(tools[0].duration_ms, Some(300));
593 let bash = tools.iter().find(|sp| sp.name == "bash").unwrap();
594 assert!(bash.error);
595 let grep = tools.iter().find(|sp| sp.name == "grep").unwrap();
596 assert!(grep.sidechain, "the subagent's tool call is a sidechain");
597
598 let inf: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
600 assert_eq!(inf.len(), 3, "two in the parent, one in the subagent");
601 let parent_inf: Vec<_> = inf.iter().filter(|sp| !sp.sidechain).collect();
602 assert_eq!(parent_inf[0].duration_ms, Some(90), "110 to 200");
603 assert_eq!(parent_inf[1].duration_ms, Some(90), "210 to 300");
604 assert_eq!(inf.iter().find(|sp| sp.sidechain).unwrap().duration_ms, Some(30), "subagent inference 150 to 180");
605 let turns: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Turn).collect();
607 assert_eq!(turns.len(), 2, "one parent turn, one subagent turn");
608 let parent_turn = turns.iter().find(|sp| !sp.sidechain).unwrap();
609 assert_eq!(parent_turn.duration_ms, Some(200), "user at 100 to the last reply completing at 300");
610 assert!(turns.iter().any(|sp| sp.sidechain), "the subagent has its own turn");
611 assert_eq!(s.activity, Activity::Waiting, "the newest message is a completed reply");
612 assert!(!s.health.fields_unrecognised());
613 let _ = std::fs::remove_dir_all(&dir);
614 }
615
616 #[test]
617 fn recent_sessions_lists_only_top_level_and_attributes_by_directory() {
618 let dir = std::env::temp_dir().join(format!("agent-top-oc-attr-{}", std::process::id()));
619 let _ = std::fs::remove_dir_all(&dir);
620 std::fs::create_dir_all(&dir).unwrap();
621 let db = make_db(&dir);
622 let found = recent_sessions(&db, UNIX_EPOCH);
623 assert_eq!(found.len(), 1, "only the parent, not the subagent");
624 assert_eq!(found[0].id, "ses_parent");
625 assert_eq!(found[0].directory, PathBuf::from("/tmp/proj"));
626
627 let adapter = OpenCodeAdapter { db: Some(db.clone()), recent: found };
628 let ctx = AttributeContext {
629 cwd: Some(Path::new("/tmp/proj")),
630 proc_start: UNIX_EPOCH + Duration::from_secs(3),
631 now: SystemTime::now(),
632 attached: &HashSet::new(),
633 activity_timeout: Duration::from_secs(900),
634 };
635 let root = ProcNode {
636 pid: 1,
637 ppid: None,
638 name: "opencode".into(),
639 cmdline: "opencode".into(),
640 kind: crate::model::ProcKind::Agent,
641 harness: Some(Harness::OpenCode),
642 cpu_percent: 0.0,
643 rss_bytes: 0,
644 age_secs: 0,
645 cwd: None,
646 children: Vec::new(),
647 };
648 let (paths, attribution) = adapter.attribute(&root, None, &ctx);
649 assert_eq!(paths, vec![session_path(&db, "ses_parent")]);
650 assert_eq!(attribution, Attribution::CwdHeuristic);
651 let ctx2 = AttributeContext { cwd: Some(Path::new("/tmp/other")), ..ctx };
653 assert!(adapter.attribute(&root, None, &ctx2).0.is_empty());
654 let _ = std::fs::remove_dir_all(&dir);
655 }
656
657 #[test]
658 fn model_id_is_pulled_from_the_json_blob() {
659 assert_eq!(model_id(r#"{"id":"deepseek-v4-pro","providerID":"deepseek"}"#), Some("deepseek-v4-pro".into()));
660 assert_eq!(model_id("claude-fable-5-1"), Some("claude-fable-5-1".into()));
661 assert_eq!(model_id(""), None);
662 }
663}