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//! * Context by source is not produced either: the session row carries
37//!   totals, and sizing each tool's results needs per-message usage in
38//!   message order, which is a `message` table read this adapter does not
39//!   do yet. The detail pane shows no `context` section for an OpenCode row.
40//!
41//! A session has no file of its own, so a tracker is addressed by a virtual
42//! path `<db>/<session id>`: unique, stable, and with the session id as its
43//! file stem, which is all the collector and the trace resolver need.
44
45use 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
53/// `$XDG_DATA_HOME/opencode`, or `~/.local/share/opencode`.
54pub 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
64/// The session database, when it exists.
65pub fn db_path() -> Option<PathBuf> {
66    let p = data_dir()?.join("opencode.db");
67    p.exists().then_some(p)
68}
69
70/// Open the database read-only. Read-only means agent-top can never write to
71/// or lock the file OpenCode is using; the WAL and its shared-memory index are
72/// read, not created.
73fn 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/// One top-level conversation, enough to attribute it to a process and list it.
86#[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
94/// The virtual path that stands for a session on disk: the database path with
95/// the session id appended. Never opened as a file; only its stem is read.
96pub fn session_path(db: &Path, session_id: &str) -> PathBuf {
97    db.join(session_id)
98}
99
100/// The session id in a virtual path.
101pub fn session_id_of(path: &Path) -> Option<String> {
102    path.file_name().map(|f| f.to_string_lossy().into_owned())
103}
104
105/// Top-level sessions (no parent) written since `since`, newest activity first.
106pub 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
122/// The model id inside OpenCode's `model` JSON blob (`{"id":...}`); the raw
123/// string if it is not that shape.
124fn 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/// The OpenCode adapter. See the module notes for the store it reads.
136#[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    /// One process runs one conversation, in the directory it was started in.
166    /// The newest top-level session in that directory that started before the
167    /// process, and that no other process has claimed, is the row.
168    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    /// OpenCode keeps no per-session file, so nothing on disk is detected as
195    /// OpenCode; a session is reached through `transcripts` by its id.
196    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
206/// One OpenCode session as a `SessionSummary`, read from the database.
207///
208/// There is nothing to tail: each refresh re-reads the session row (and its
209/// subagent rows and tool parts) and rebuilds the summary. A cheap check on
210/// the session's `time_updated` skips the part scan when nothing changed, so a
211/// stopped session costs one small query per tick.
212pub struct OpenCodeTranscript {
213    db: PathBuf,
214    session_id: String,
215    virtual_path: PathBuf,
216    conn: Option<Connection>,
217    retention: SpanRetention,
218    summary: SessionSummary,
219    /// The `time_updated` last read, so an unchanged session is not re-scanned.
220    last_updated: Option<i64>,
221}
222
223impl OpenCodeTranscript {
224    pub fn new(virtual_path: &Path, retention: SpanRetention) -> Self {
225        // The virtual path is `<db>/<session id>`; split it back.
226        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        // The parent row plus every subagent row (parent_id = this session),
252        // so the fold is one query.
253        let mut summary = SessionSummary { harness: Some(Harness::OpenCode), spans: retention.log(), ..Default::default() };
254        let mut ids: Vec<(String, bool)> = Vec::new(); // (session id, is subagent)
255        {
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, // output + reasoning
270                    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            // The session was deleted; keep the empty summary.
289            self.summary = summary;
290            return Ok(());
291        }
292
293        // Turns: assistant messages, split parent vs subagent.
294        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        // Tool calls and their spans, plus turns and inferences from the
312        // message times. For the live view only the newest `MAX_SPANS` matter,
313        // so the queries are bounded; an export keeps everything.
314        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        // Every span this scan will add, so the bounded log can keep the newest
328        // across all three kinds rather than dropping one kind first.
329        struct Pending {
330            id: String,
331            name: String,
332            kind: SpanKind,
333            start: SystemTime,
334            /// `None` for a span still open: a tool with no end, an inference
335            /// whose message has not completed, a turn whose reply is not done.
336            end: Option<SystemTime>,
337            sidechain: bool,
338            error: bool,
339        }
340        let mut pending: Vec<Pending> = Vec::new();
341
342        // Tool spans, from `tool` parts.
343        {
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        // Turn and inference spans, from message times. A `user` message opens
379        // a turn; each `assistant` message is one inference (created to
380        // completed) and extends the turn it belongs to; the turn ends at the
381        // last assistant reply before the next user message. Built per session,
382        // since a subagent runs its own turns interleaved in time.
383        {
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            // Newest message decides the live activity: a finished reply is
410            // waiting, an open reply or a fresh prompt is working.
411            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            // Per session, close a turn when the next user message arrives.
417            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; // id, start, end
422                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                        // Open a turn if the window began mid-reply, and move
445                        // its end to this reply's completion.
446                        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        // Insert every span oldest first, so the bounded live log keeps the
461        // newest by time no matter which kind it is.
462        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        // If there were no messages at all, activity stays unknown.
475        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        // Nothing changed since the last read: keep the summary as is.
486        if updated.is_some() && updated == self.last_updated {
487            return Ok(false);
488        }
489        self.last_updated = updated;
490        // A schema change or a locked read must not crash the collector; on
491        // error the summary is left as it was and the row simply does not update.
492        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    /// A minimal OpenCode database with the columns the adapter reads.
511    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        // A parent session and one subagent.
524        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        // A user prompt and two assistant replies in the parent, one assistant
537        // reply in the subagent, with created/completed times so the turn and
538        // inference spans can be built.
539        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        // Tool parts: two in the parent (one failing), one in the child.
553        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        // Parent input 1000 + child 300; output+reasoning parent 250 + child 50.
584        assert_eq!(s.usage.input, 1300);
585        assert_eq!(s.usage.output, 300);
586        assert_eq!(s.usage.cache_read, 901000);
587        // OpenCode's own cost, parent + subagent, used directly.
588        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        // Inference spans: one per assistant message (created to completed).
603        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        // Turn spans: one per session. The parent's runs prompt to last reply.
610        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        // A different directory gets nothing.
656        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}