diff --git a/internal/sessions/opencode.go b/internal/sessions/opencode.go index 0a1051f8..1b11abbc 100644 --- a/internal/sessions/opencode.go +++ b/internal/sessions/opencode.go @@ -3,6 +3,7 @@ package sessions import ( "database/sql" "encoding/json" + "fmt" "os" "path/filepath" "sort" @@ -21,6 +22,24 @@ import ( // assistant message carries its model and its tokens, input without the // cache and output without the reasoning. A subagent's work is a session // of its own whose parent is the session it ran in, and counts there. +// +// OpenCode 2 keeps its sessions in the same database but in tables of its +// own (packages/core/src/session/sql.ts): session_v2, the session row as +// before (directory, a title that may be null, parent_id, time_created and +// _updated), and session_message, one row a message with its kind in type +// (user, assistant, synthetic, system, compaction, idle, …), its order in +// seq and the rest in data. A user message's data is its prompt, {text, +// files, …}, with no parts of its own; an assistant's has model {id, +// providerID, variant}, content (its text, reasoning and tools), cost, +// tokens {input, output, reasoning, cache {read, write}} counted as before, +// and time {created, completed} (packages/schema/src/session-message.ts, +// token-usage.ts). A compaction's data has its own tokens. On its first +// start OpenCode 2 copies every old session into these tables +// (packages/core/src/database/v1-migration.bun.ts, which records kv +// migration.v1-v2 {"phase":"completed"} when done) and leaves the old +// tables behind unwritten, so once it has, only the new tables are read; +// while it is under way, an old session not yet copied is read from the +// old ones. // OpenCodeDir is OpenCode's data folder: $XDG_DATA_HOME/opencode, else // ~/.local/share/opencode — on Windows too, where OpenCode keeps it there. @@ -171,18 +190,37 @@ func openCodeDBFiles(agent, path string) []file { if db == nil { return nil } - rows, err := db.Query(`SELECT s.id, COALESCE(s.parent_id, ''), s.time_updated, COUNT(m.id), COALESCE(MAX(m.time_updated), 0) - FROM session s LEFT JOIN message m ON m.session_id = s.id GROUP BY s.id`) - if err != nil { - return nil - } - defer rows.Close() var store ocStore = ocDB{db} if agent == "zcode" { store = zcDB{ocDB{db}} } - var out []file + const q = `SELECT s.id, COALESCE(s.parent_id, ''), s.time_updated, COUNT(m.id), COALESCE(MAX(m.time_updated), 0) + FROM %s s LEFT JOIN %s m ON m.session_id = s.id %s GROUP BY s.id` parent := map[string]string{} + old := fmt.Sprintf(q, "session", "message", "") + var out []file + if agent == "opencode" && ocHasTable(db, "session_v2") { + out = ocDBRows(db, fmt.Sprintf(q, "session_v2", "session_message", ""), agent, path, ocV2DB{db}, parent) + switch { + case !ocHasTable(db, "session") || ocMigrated(db): + old = "" + default: // being copied over: what isn't yet + old = fmt.Sprintf(q, "session", "message", "WHERE s.id NOT IN (SELECT id FROM session_v2)") + } + } + if old != "" { + out = append(out, ocDBRows(db, old, agent, path, store, parent)...) + } + return ocRoots(out, parent) +} + +func ocDBRows(db *sql.DB, query, agent, path string, store ocStore, parent map[string]string) []file { + rows, err := db.Query(query) + if err != nil { + return nil + } + defer rows.Close() + var out []file for rows.Next() { var id, par string var updated, n, last int64 @@ -192,7 +230,24 @@ func openCodeDBFiles(agent, path string) []file { parent[id] = par out = append(out, file{agent: agent, path: path + "#" + id, sid: id, oc: store, size: n, mod: ms(max(updated, last))}) } - return ocRoots(out, parent) + return out +} + +func ocHasTable(db *sql.DB, name string) bool { + var n int + return db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&n) == nil && n > 0 +} + +// ocMigrated is whether OpenCode 2 has copied every old session over. +func ocMigrated(db *sql.DB) bool { + var b []byte + if db.QueryRow(`SELECT value FROM kv WHERE key = 'migration.v1-v2'`).Scan(&b) != nil { + return false + } + var v struct { + Phase string `json:"phase"` + } + return json.Unmarshal(b, &v) == nil && v.Phase == "completed" } func (s ocDB) info(sid string) (ocInfo, bool) { @@ -226,6 +281,79 @@ func (s ocDB) parts(mid string) [][]byte { return s.rows(`SELECT data FROM part WHERE message_id = ? ORDER BY id`, mid) } +// ---- OpenCode 2's tables ------------------------------------------------------ + +// ocV2DB is OpenCode 2's session_v2 and session_message. Its messages are +// given as the older ones are, to be read the same way. +type ocV2DB struct{ db *sql.DB } + +func (s ocV2DB) info(sid string) (ocInfo, bool) { + var i ocInfo + err := s.db.QueryRow(`SELECT id, COALESCE(parent_id, ''), directory, COALESCE(title, ''), time_created, time_updated FROM session_v2 WHERE id = ?`, sid). + Scan(&i.ID, &i.ParentID, &i.Directory, &i.Title, &i.Time.Created, &i.Time.Updated) + return i, err == nil +} + +// messages are a session's prompts, replies and compactions: a reply's +// model is its model's id, a compaction's (which may name none) the one +// last replied with. +func (s ocV2DB) messages(sid string) [][]byte { + rows, err := s.db.Query(`SELECT id, type, data FROM session_message + WHERE session_id = ? AND type IN ('user', 'assistant', 'compaction') ORDER BY seq`, sid) + if err != nil { + return nil + } + defer rows.Close() + var out [][]byte + last := "" + for rows.Next() { + var id, typ string + var b []byte + if rows.Scan(&id, &typ, &b) != nil { + continue + } + var m ocMessage + var v struct { + Model *struct { + ID string `json:"id"` + } `json:"model"` + } + if json.Unmarshal(b, &m) != nil || json.Unmarshal(b, &v) != nil { + continue + } + m.ID, m.Role, m.ModelID = id, "assistant", last + if v.Model != nil && v.Model.ID != "" { + m.ModelID = v.Model.ID + } + switch typ { + case "user": + m.Role, m.ModelID, m.Tokens = "user", "", nil + case "assistant": + last = m.ModelID + } + if o, err := json.Marshal(m); err == nil { + out = append(out, o) + } + } + return out +} + +// parts are a prompt's words, which OpenCode 2 keeps in the prompt itself. +func (s ocV2DB) parts(mid string) [][]byte { + var b []byte + if s.db.QueryRow(`SELECT data FROM session_message WHERE id = ? AND type = 'user'`, mid).Scan(&b) != nil { + return nil + } + var u struct { + Text string `json:"text"` + } + if json.Unmarshal(b, &u) != nil { + return nil + } + p, _ := json.Marshal(ocPart{Type: "text", Text: u.Text}) + return [][]byte{p} +} + // ---- the files ---------------------------------------------------------------- type ocFiles struct{ root string } // storage/ diff --git a/internal/sessions/opencode_pi_test.go b/internal/sessions/opencode_pi_test.go index 8efde254..725cb3fd 100644 --- a/internal/sessions/opencode_pi_test.go +++ b/internal/sessions/opencode_pi_test.go @@ -287,3 +287,102 @@ func TestDirs(t *testing.T) { t.Fatalf("a Pi folder that isn't there listed: %v", d) } } + +// ocMakeV2 makes OpenCode 2's tables (packages/core/src/session/sql.ts, +// the columns read and those beside them) beside the old ones, with the +// fixtures' sessions as its migration copies them over: a prompt's words +// in the prompt, what OpenCode put in beside them a synthetic message of +// its own, a reply's model {id, providerID, variant}. +func ocMakeV2(t *testing.T, path string) *sql.DB { + t.Helper() + db := ocMakeEmpty(t, path, "") + for _, q := range []string{ + "CREATE TABLE session_v2 (id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, parent_id text, slug text NOT NULL, directory text NOT NULL, path text, title text, version text NOT NULL, cost real DEFAULT 0 NOT NULL, tokens_input integer DEFAULT 0 NOT NULL, tokens_output integer DEFAULT 0 NOT NULL, tokens_reasoning integer DEFAULT 0 NOT NULL, tokens_cache_read integer DEFAULT 0 NOT NULL, tokens_cache_write integer DEFAULT 0 NOT NULL, agent text, model text, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer)", + "CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)", + "CREATE TABLE kv (key text PRIMARY KEY, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL)", + `INSERT INTO kv VALUES ('migration.v1-v2', '{"phase":"completed"}', 0, 0)`, + `INSERT INTO session_v2 (id, project_id, slug, directory, title, version, model, time_created, time_updated) VALUES + ('` + ocMain + `', 'global', 'brave-otter', '/work/oc', 'Flaky test hunt', '2.0.18', '{"id":"codex/gpt-6-luna","providerID":"magpie","variant":"default"}', 1790416800000, 1790416800000), + ('ses_1a2b3c4d5e6fCCCCCCCCCCCCCC', 'global', 'idle-crow', '/work/oc', NULL, '2.0.18', NULL, 1790413200000, 1790413200000)`, + `INSERT INTO session_v2 (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated) VALUES + ('` + ocChild + `', 'global', '` + ocMain + `', 'calm-heron', '/work/oc', NULL, '2.0.18', 1790416860000, 1790416860000)`, + } { + if _, err := db.Exec(q); err != nil { + t.Fatal(err) + } + } + for i, m := range []struct{ sid, id, typ, data string }{ + {ocMain, "msg_0001", "user", `{"text":"Why does the login test\nflake?","time":{"created":1790416805000}}`}, + {ocMain, "msg_0001s", "synthetic", `{"text":"Called the Read tool with the following input","time":{"created":1790416805000}}`}, + {ocMain, "msg_0002", "assistant", `{"agent":"build","model":{"id":"gpt-6-astra","providerID":"openai","variant":"default"},"content":[{"type":"reasoning","text":"…"},{"type":"text","text":"Let me look."}],"cost":0.01,"tokens":{"input":1000,"output":100,"reasoning":20,"cache":{"read":5000,"write":0}},"time":{"created":1790416806000,"completed":1790416830000}}`}, + {ocMain, "msg_0003", "assistant", `{"agent":"build","model":{"id":"codex/gpt-6-luna","providerID":"magpie","variant":"default"},"content":[{"type":"text","text":"It races the clock."}],"cost":0,"tokens":{"input":300,"output":50,"reasoning":10,"cache":{"read":2000,"write":0}},"time":{"created":1790416890000,"completed":1790416980000}}`}, + {ocMain, "msg_0003i", "idle", `{"outcome":"succeeded","time":{"created":1790416980000}}`}, + {ocChild, "msg_0004", "assistant", `{"agent":"general","model":{"id":"claude-opus-5-5","providerID":"anthropic"},"content":[],"cost":0,"tokens":{"input":40,"output":60,"reasoning":0,"cache":{"read":700,"write":300}},"time":{"created":1790416860000,"completed":1790416890000}}`}, + } { + var d struct { + Time struct{ Created, Completed int64 } + } + json.Unmarshal([]byte(m.data), &d) + if _, err := db.Exec("INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)", m.id, m.sid, m.typ, i, d.Time.Created, max(d.Time.Created, d.Time.Completed), m.data); err != nil { + t.Fatal(err) + } + } + return db +} + +func TestOpenCodeV2(t *testing.T) { + inZone(t, 0) + data, _ := setupMore(t) + db := ocMakeV2(t, filepath.Join(data, "opencode", "opencode.db")) + // an old session left behind in the old tables that OpenCode 2 no + // longer has (deleted since it was copied over) + const gone = "ses_1a2b3c4d5e6fDDDDDDDDDDDDDD" + if _, err := db.Exec("INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (?, '0f3a', 's', '/work/gone', 'Gone', '1.1.53', 1790416800000, 1790416800000)", gone); err != nil { + t.Fatal(err) + } + ocInsert(t, db, gone, "msg_0009", 1790416800000, 1790416810000, + `{"id":"msg_0009","role":"assistant","modelID":"claude-opus-5-5","tokens":{"input":1,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1790416800000,"completed":1790416810000}}`) + + ss := List(0) + checkOpenCode(t, ss) + oc := find(t, ss, "opencode", ocMain) + if filepath.Base(oc.Path) != "opencode.db#"+ocMain { + t.Fatalf("v2 session: %+v", oc) + } + s := statsAt(0, statsNow) + if got, sec := usageOn(s, "2026-09-26", "/work/oc"); got != (Tokens{1340, 240, 7700, 300}) || sec != 180 { + t.Fatalf("opencode 2 on the 26th: %+v, %ds active", got, sec) + } + + // a compaction names no model: it counts on the one last replied with + if _, err := db.Exec(`INSERT INTO session_message VALUES ('msg_0005', ?, 'compaction', 9, 1790417000000, 1790417010000, ?)`, ocMain, + `{"status":"failed","reason":"auto","error":{"type":"unknown","message":"x"},"cost":0,"tokens":{"input":5,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1790417000000}}`); err != nil { + t.Fatal(err) + } + oc = find(t, List(0), "opencode", ocMain) + if l := model(oc, "codex/gpt-6-luna"); l.Tokens != (Tokens{305, 61, 2000, 0}) { + t.Fatalf("luna after a compaction: %+v", l) + } + + // while the old sessions are still being copied over, one not copied + // yet is read from the old tables, one copied only from the new + if _, err := db.Exec(`UPDATE kv SET value = '{"phase":"sessions","cursor":"x"}'`); err != nil { + t.Fatal(err) + } + ss = List(0) + if g := find(t, ss, "opencode", gone); g.Cwd != "/work/gone" { + t.Fatalf("an old session not copied yet: %+v", g) + } + n := 0 + for _, s := range ss { + if s.ID == ocMain { + n++ + } + } + if n != 1 { + t.Fatalf("a copied session read %d times", n) + } + if len(dbs) != 0 { + t.Fatalf("databases left open: %v", dbs) + } +}