sessions: OpenCode v2's sessions are read (Kumo31: opencode v2的session改写到新表里了) — OpenCode 2 (v2.0.x, checked against v2.0.18's source) keeps sessions in the same opencode.db but in tables of its own (packages/core/src/session/sql.ts): session_v2 (the session row as before — directory, parent_id, a title that may now be null, time_created/updated, model {id, providerID, variant}, session totals) and session_message (id, session_id, type, seq, time_created/updated, data), not "sessionmessage". A message's kind is its type column (user, assistant, synthetic, system, compaction, idle, …); a user message's data is its prompt {text, files, …} with no part rows; an assistant's has model {id, providerID, variant}, content (text/reasoning/tool), cost, tokens {input, output, reasoning, cache {read, write}} and time {created, completed} (packages/schema/src/session-message.ts, token-usage.ts), counted as before: input without the cache, output without the reasoning (core/src/session/usage.ts). A compaction carries its own tokens, counted on its model, else the one last replied with. On first start OpenCode 2 copies every old session over (core/src/database/v1-migration.bun.ts), leaving session/message/part unwritten and recording kv migration.v1-v2 {"phase":"completed"}; so with session_v2 present only it is read once that is recorded (or no old table is left, as for the pre-split v2 databases it renamed in place), and while the copy is under way an old session not yet in session_v2 is read from the old tables — never both, so nothing counts twice and a session deleted in v2 doesn't come back. Resume stays opencode --session <ses…> (cli/src/commands/commands.ts). Tested with a v2 fixture made in the test from SQL beside stale old tables; the old-table and JSON-file tests still pass.

This commit is contained in:
yetone committed 2026-09-29 00:38:10 +08:00
1 parent a47e70fb63
commit e13677fd3a
2 files changed
+235 -8

No files matched your search

+136 -8
View File
@@ -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/
+99
View File
@@ -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)
}
}