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`) and `time` `{created, completed}` in epoch ms. A
23//!   `user` message opens a turn; each `assistant` message is one inference,
24//!   from `created` to `completed`, and extends the turn it belongs to, which
25//!   ends at the last reply before the next prompt. A reply with no
26//!   `completed` is still in flight, so its inference and turn stay open.
27//!   Assistant messages are also the turn count.
28//! * `part` is one row per message part, `data` JSON with `type`. A `tool`
29//!   part has `tool` (the name), `callID` and `state` with `status`
30//!   (`completed` / `error` / ...) and `time` `{start,end}` in epoch ms, which
31//!   is one tool span. `step-start` / `step-finish`, `reasoning`, `text` and
32//!   `patch` parts are not read.
33//! * MCP tool naming was not observable here (no MCP server is configured), so
34//!   per-server MCP counts are not produced for OpenCode yet; every tool part
35//!   is counted as a tool call and a span.
36//!
37//! A session has no file of its own, so a tracker is addressed by a virtual
38//! path `<db>/<session id>`: unique, stable, and with the session id as its
39//! file stem, which is all the collector and the trace resolver need.
40
41use 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
49/// `$XDG_DATA_HOME/opencode`, or `~/.local/share/opencode`.
50pub 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
60/// The session database, when it exists.
61pub fn db_path() -> Option<PathBuf> {
62    let p = data_dir()?.join("opencode.db");
63    p.exists().then_some(p)
64}
65
66/// Open the database read-only. Read-only means agent-top can never write to
67/// or lock the file OpenCode is using; the WAL and its shared-memory index are
68/// read, not created.
69fn 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/// One top-level conversation, enough to attribute it to a process and list it.
82#[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
90/// The virtual path that stands for a session on disk: the database path with
91/// the session id appended. Never opened as a file; only its stem is read.
92pub fn session_path(db: &Path, session_id: &str) -> PathBuf {
93    db.join(session_id)
94}
95
96/// The session id in a virtual path.
97pub fn session_id_of(path: &Path) -> Option<String> {
98    path.file_name().map(|f| f.to_string_lossy().into_owned())
99}
100
101/// Top-level sessions (no parent) written since `since`, newest activity first.
102pub 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
118/// The model id inside OpenCode's `model` JSON blob (`{"id":...}`); the raw
119/// string if it is not that shape.
120fn 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/// The OpenCode adapter. See the module notes for the store it reads.
132#[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    /// One process runs one conversation, in the directory it was started in.
162    /// The newest top-level session in that directory that started before the
163    /// process, and that no other process has claimed, is the row.
164    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    /// OpenCode keeps no per-session file, so nothing on disk is detected as
191    /// OpenCode; a session is reached through `transcripts` by its id.
192    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
202/// One OpenCode session as a `SessionSummary`, read from the database.
203///
204/// There is nothing to tail: each refresh re-reads the session row (and its
205/// subagent rows and tool parts) and rebuilds the summary. A cheap check on
206/// the session's `time_updated` skips the part scan when nothing changed, so a
207/// stopped session costs one small query per tick.
208pub struct OpenCodeTranscript {
209    db: PathBuf,
210    session_id: String,
211    virtual_path: PathBuf,
212    conn: Option<Connection>,
213    retention: SpanRetention,
214    summary: SessionSummary,
215    /// The `time_updated` last read, so an unchanged session is not re-scanned.
216    last_updated: Option<i64>,
217}
218
219impl OpenCodeTranscript {
220    pub fn new(virtual_path: &Path, retention: SpanRetention) -> Self {
221        // The virtual path is `<db>/<session id>`; split it back.
222        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        // The parent row plus every subagent row (parent_id = this session),
248        // so the fold is one query.
249        let mut summary = SessionSummary { harness: Some(Harness::OpenCode), spans: retention.log(), ..Default::default() };
250        let mut ids: Vec<(String, bool)> = Vec::new(); // (session id, is subagent)
251        {
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, // output + reasoning
266                    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            // The session was deleted; keep the empty summary.
285            self.summary = summary;
286            return Ok(());
287        }
288
289        // Turns: assistant messages, split parent vs subagent.
290        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        // Tool calls and their spans, plus turns and inferences from the
308        // message times. For the live view only the newest `MAX_SPANS` matter,
309        // so the queries are bounded; an export keeps everything.
310        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        // Every span this scan will add, so the bounded log can keep the newest
324        // across all three kinds rather than dropping one kind first.
325        struct Pending {
326            id: String,
327            name: String,
328            kind: SpanKind,
329            start: SystemTime,
330            /// `None` for a span still open: a tool with no end, an inference
331            /// whose message has not completed, a turn whose reply is not done.
332            end: Option<SystemTime>,
333            sidechain: bool,
334            error: bool,
335        }
336        let mut pending: Vec<Pending> = Vec::new();
337
338        // Tool spans, from `tool` parts.
339        {
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        // Turn and inference spans, from message times. A `user` message opens
375        // a turn; each `assistant` message is one inference (created to
376        // completed) and extends the turn it belongs to; the turn ends at the
377        // last assistant reply before the next user message. Built per session,
378        // since a subagent runs its own turns interleaved in time.
379        {
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            // Newest message decides the live activity: a finished reply is
406            // waiting, an open reply or a fresh prompt is working.
407            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            // Per session, close a turn when the next user message arrives.
413            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; // id, start, end
418                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                        // Open a turn if the window began mid-reply, and move
441                        // its end to this reply's completion.
442                        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        // Insert every span oldest first, so the bounded live log keeps the
457        // newest by time no matter which kind it is.
458        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        // If there were no messages at all, activity stays unknown.
471        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        // Nothing changed since the last read: keep the summary as is.
482        if updated.is_some() && updated == self.last_updated {
483            return Ok(false);
484        }
485        self.last_updated = updated;
486        // A schema change or a locked read must not crash the collector; on
487        // error the summary is left as it was and the row simply does not update.
488        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    /// A minimal OpenCode database with the columns the adapter reads.
507    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        // A parent session and one subagent.
520        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        // A user prompt and two assistant replies in the parent, one assistant
533        // reply in the subagent, with created/completed times so the turn and
534        // inference spans can be built.
535        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        // Tool parts: two in the parent (one failing), one in the child.
549        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        // Parent input 1000 + child 300; output+reasoning parent 250 + child 50.
580        assert_eq!(s.usage.input, 1300);
581        assert_eq!(s.usage.output, 300);
582        assert_eq!(s.usage.cache_read, 901000);
583        // OpenCode's own cost, parent + subagent, used directly.
584        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        // Inference spans: one per assistant message (created to completed).
599        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        // Turn spans: one per session. The parent's runs prompt to last reply.
606        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        // A different directory gets nothing.
652        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}