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