package opencode
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
)
func TestDiscover_FindsCanonicalDatabase(t *testing.T) {
root := t.TempDir()
dbPath := filepath.Join(root, "opencode.db")
writeFile(t, dbPath, "sqlite fixture")
writeFile(t, filepath.Join(root, "opencode.db-wal"), "wal")
writeFile(t, filepath.Join(root, "storage", "message", "ignored.json"), "{}")
p := New("laptop")
files, err := p.Discover(root)
if err != nil {
t.Fatalf("Discover(root): %v", err)
}
if len(files) != 1 {
t.Fatalf("len(files) = %d, want 1", len(files))
}
if got := files[0].Path; got != dbPath {
t.Fatalf("files[0].Path = %q, want %q", got, dbPath)
}
if files[0].Size == 0 {
t.Fatal("files[0].Size = 0, want database size")
}
files, err = p.Discover(dbPath)
if err != nil {
t.Fatalf("Discover(dbPath): %v", err)
}
if len(files) != 1 || files[0].Path != dbPath {
t.Fatalf("Discover(dbPath) = %#v, want canonical db only", files)
}
}
func TestParse_MapsTurnsAndIdentity(t *testing.T) {
dbPath := createOpenCodeDB(t)
insertSession(t, dbPath, "ses_demo", "/workspace/demo", 1700000000000)
insertMessage(t, dbPath, "msg_user", "ses_demo", 1700000001000, `{"role":"user","path":{"cwd":"/workspace/demo"},"time":{"created":1700000001000}}`)
insertPart(t, dbPath, "prt_user_text", "msg_user", "ses_demo", 1700000001001, `{"type":"text","text":"Please inspect the failing test."}`)
insertMessage(t, dbPath, "msg_assistant", "ses_demo", 1700000002000, `{"role":"assistant","modelID":"model-redacted","tokens":{"input":7,"output":11},"cost":0.00042}`)
insertPart(t, dbPath, "prt_step", "msg_assistant", "ses_demo", 1700000002001, `{"type":"step-start"}`)
insertPart(t, dbPath, "prt_reason", "msg_assistant", "ses_demo", 1700000002002, `{"type":"reasoning","text":"Short safe reasoning summary."}`)
insertPart(t, dbPath, "prt_text", "msg_assistant", "ses_demo", 1700000002003, `{"type":"text","text":"The test fails because the parser is missing."}`)
p := New("laptop")
events, next, err := p.Parse(dbPath, 0)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(events) != 2 {
t.Fatalf("len(events) = %d, want 2", len(events))
}
if next != 3 {
t.Fatalf("next = %d, want marker after assistant message", next)
}
user := events[0]
if user.Tool != "opencode" || user.Host != "laptop" || user.SessionID != "ses_demo" || user.TurnID != "msg_user" {
t.Fatalf("user identity = tool=%q host=%q session=%q turn=%q", user.Tool, user.Host, user.SessionID, user.TurnID)
}
if user.Seq != 1 || user.Timestamp != 1700000001 || user.Role != "user" {
t.Fatalf("user seq/timestamp/role = %d/%d/%q", user.Seq, user.Timestamp, user.Role)
}
if user.Content != "Please inspect the failing test." {
t.Fatalf("user.Content = %q", user.Content)
}
if user.SessionMeta.WorkingDir == nil || *user.SessionMeta.WorkingDir != "/workspace/demo" {
t.Fatalf("user working dir = %v, want session directory", user.SessionMeta.WorkingDir)
}
if user.SessionMeta.SourceFile != dbPath {
t.Fatalf("user source_file = %q, want %q", user.SessionMeta.SourceFile, dbPath)
}
assistant := events[1]
if assistant.Role != "assistant" {
t.Fatalf("assistant.Role = %q", assistant.Role)
}
if !strings.Contains(assistant.Content, "Short safe reasoning summary.") || !strings.Contains(assistant.Content, "The test fails because the parser is missing.") {
t.Fatalf("assistant.Content = %q", assistant.Content)
}
if assistant.Model == nil || *assistant.Model != "model-redacted" {
t.Fatalf("assistant.Model = %v", assistant.Model)
}
if assistant.TokensIn == nil || *assistant.TokensIn != 7 || assistant.TokensOut == nil || *assistant.TokensOut != 11 {
t.Fatalf("assistant tokens = %v/%v", assistant.TokensIn, assistant.TokensOut)
}
if assistant.CostUSD == nil || *assistant.CostUSD != 0.00042 {
t.Fatalf("assistant.CostUSD = %v", assistant.CostUSD)
}
}
func TestParse_MapsToolPartSummaryWithoutExternalBlob(t *testing.T) {
dbPath := createOpenCodeDB(t)
insertSession(t, dbPath, "ses_tools", "/workspace/tools", 1700000100000)
insertMessage(t, dbPath, "msg_tool", "ses_tools", 1700000101000, `{"role":"assistant"}`)
insertPart(t, dbPath, "prt_tool", "msg_tool", "ses_tools", 1700000101001, `{"type":"tool","tool":"bash","callID":"call_safe","state":{"status":"completed","output":"go test ./internal/collector/parser/opencode ok"}}`)
toolOutputPath := filepath.Join(filepath.Dir(dbPath), "tool-output", "tool_call_safe")
writeFile(t, toolOutputPath, "SECRET=do-not-ingest")
events, _, err := New("laptop").Parse(dbPath, 0)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(events) != 1 {
t.Fatalf("len(events) = %d, want 1", len(events))
}
if events[0].Role != "assistant" {
t.Fatalf("Role = %q, want assistant", events[0].Role)
}
if !strings.Contains(events[0].Content, "<tool: bash completed - go test ./internal/collector/parser/opencode ok>") {
t.Fatalf("Content = %q", events[0].Content)
}
if strings.Contains(events[0].Content, "SECRET") || strings.Contains(string(events[0].ToolCalls), "SECRET") {
t.Fatalf("external tool-output blob was ingested: content=%q tool_calls=%s", events[0].Content, string(events[0].ToolCalls))
}
if !strings.Contains(string(events[0].ToolCalls), `"call_id":"call_safe"`) || !strings.Contains(string(events[0].ToolCalls), `"output":"go test ./internal/collector/parser/opencode ok"`) {
t.Fatalf("ToolCalls = %s, want safe part summary", string(events[0].ToolCalls))
}
}
func TestParse_ResumesFromMessageRowIDMarker(t *testing.T) {
dbPath := createOpenCodeDB(t)
insertSession(t, dbPath, "ses_resume", "/workspace/resume", 1700000200000)
insertMessage(t, dbPath, "msg_old", "ses_resume", 1700000201000, `{"role":"user"}`)
insertPart(t, dbPath, "prt_old", "msg_old", "ses_resume", 1700000201001, `{"type":"text","text":"old"}`)
insertMessage(t, dbPath, "msg_new", "ses_resume", 1700000202000, `{"role":"assistant"}`)
insertPart(t, dbPath, "prt_new", "msg_new", "ses_resume", 1700000202001, `{"type":"text","text":"new"}`)
events, next, err := New("laptop").Parse(dbPath, 2)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(events) != 1 || events[0].TurnID != "msg_new" || events[0].Content != "new" {
t.Fatalf("events = %#v, want only new message", events)
}
if next != 3 {
t.Fatalf("next = %d, want newest marker", next)
}
events, next, err = New("laptop").Parse(dbPath, next)
if err != nil {
t.Fatalf("Parse second: %v", err)
}
if len(events) != 0 || next != 3 {
t.Fatalf("second parse = len %d next %d, want no events and stable marker", len(events), next)
}
}
func TestParse_IdenticalMessageTimesResumeByRowID(t *testing.T) {
dbPath := createOpenCodeDB(t)
insertSession(t, dbPath, "ses_same_time", "/workspace/same-time", 1700000250000)
insertMessage(t, dbPath, "msg_first", "ses_same_time", 1700000251000, `{"role":"user"}`)
insertPart(t, dbPath, "prt_first", "msg_first", "ses_same_time", 1700000251001, `{"type":"text","text":"first"}`)
insertMessage(t, dbPath, "msg_second", "ses_same_time", 1700000251000, `{"role":"assistant"}`)
insertPart(t, dbPath, "prt_second", "msg_second", "ses_same_time", 1700000251002, `{"type":"text","text":"second"}`)
events, next, err := New("laptop").Parse(dbPath, 0)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(events) != 2 || events[0].TurnID != "msg_first" || events[1].TurnID != "msg_second" {
t.Fatalf("events = %#v, want both equal-time messages in rowid order", events)
}
if events[0].Seq != 1 || events[1].Seq != 2 || next != 3 {
t.Fatalf("seqs/next = %d/%d/%d, want rowid markers 1/2/3", events[0].Seq, events[1].Seq, next)
}
events, next, err = New("laptop").Parse(dbPath, events[1].Seq)
if err != nil {
t.Fatalf("Parse resume: %v", err)
}
if len(events) != 1 || events[0].TurnID != "msg_second" || events[0].Content != "second" {
t.Fatalf("resume events = %#v, want only second equal-time message", events)
}
if next != 3 {
t.Fatalf("resume next = %d, want marker after second rowid", next)
}
events, next, err = New("laptop").Parse(dbPath, next)
if err != nil {
t.Fatalf("Parse after full resume: %v", err)
}
if len(events) != 0 || next != 3 {
t.Fatalf("after full parse = len %d next %d, want no rows and unchanged marker 3", len(events), next)
}
}
func TestParse_EmptyParseReturnsInputMarker(t *testing.T) {
dbPath := createOpenCodeDB(t)
insertSession(t, dbPath, "ses_empty", "/workspace/empty", 1700000260000)
events, next, err := New("laptop").Parse(dbPath, 7)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(events) != 0 || next != 7 {
t.Fatalf("empty parse = len %d next %d, want no events and unchanged marker 7", len(events), next)
}
}
func TestParse_ReturnsMarkerAfterHighestScannedRowIDWithGaps(t *testing.T) {
dbPath := createOpenCodeDB(t)
insertSession(t, dbPath, "ses_gap", "/workspace/gap", 1700000270000)
insertMessageWithRowID(t, dbPath, 5, "msg_gap", "ses_gap", 1700000271000, `{"role":"user"}`)
insertPart(t, dbPath, "prt_gap", "msg_gap", "ses_gap", 1700000271001, `{"type":"text","text":"gap"}`)
events, next, err := New("laptop").Parse(dbPath, 1)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(events) != 1 || events[0].Seq != 5 || events[0].TurnID != "msg_gap" {
t.Fatalf("events = %#v, want rowid 5 event", events)
}
if next != 6 {
t.Fatalf("next = %d, want marker after highest scanned rowid", next)
}
}
func TestParse_SkipsMalformedAndUnknownRecordsButAdvances(t *testing.T) {
dbPath := createOpenCodeDB(t)
insertSession(t, dbPath, "ses_bad", "/workspace/bad", 1700000300000)
insertMessage(t, dbPath, "msg_bad_json", "ses_bad", 1700000301000, `{not-json}`)
insertPart(t, dbPath, "prt_bad", "msg_bad_json", "ses_bad", 1700000301001, `{"type":"text","text":"bad message should not leak"}`)
insertMessage(t, dbPath, "msg_unknown_role", "ses_bad", 1700000302000, `{"role":"plugin"}`)
insertPart(t, dbPath, "prt_unknown", "msg_unknown_role", "ses_bad", 1700000302001, `{"type":"text","text":"unknown role"}`)
insertMessage(t, dbPath, "msg_good", "ses_bad", 1700000303000, `{"role":"user"}`)
insertPart(t, dbPath, "prt_good", "msg_good", "ses_bad", 1700000303001, `{"type":"text","text":"good"}`)
events, next, err := New("laptop").Parse(dbPath, 0)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(events) != 1 || events[0].TurnID != "msg_good" || events[0].Content != "good" {
t.Fatalf("events = %#v, want only good event", events)
}
if next != 4 {
t.Fatalf("next = %d, want all records consumed", next)
}
}
func createOpenCodeDB(t *testing.T) string {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "opencode.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer func() { _ = db.Close() }()
ddl, err := os.ReadFile(filepath.Join("testdata", "schema.sql"))
if err != nil {
t.Fatalf("read schema: %v", err)
}
if _, err := db.Exec(string(ddl)); err != nil {
t.Fatalf("create schema: %v", err)
}
return dbPath
}
func insertSession(t *testing.T, dbPath, id, directory string, created int64) {
t.Helper()
withDB(t, dbPath, func(db *sql.DB) {
_, err := db.Exec(`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (?, 'proj_redacted', 'quiet-river', ?, 'redacted', '0.0.0', ?, ?)`, id, directory, created, created)
if err != nil {
t.Fatalf("insert session: %v", err)
}
})
}
func insertMessage(t *testing.T, dbPath, id, sessionID string, created int64, data string) {
t.Helper()
if !json.Valid([]byte(data)) && !strings.HasPrefix(data, "{not-json}") {
t.Fatalf("test fixture message data is invalid unexpectedly: %s", data)
}
withDB(t, dbPath, func(db *sql.DB) {
_, err := db.Exec(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`, id, sessionID, created, created, data)
if err != nil {
t.Fatalf("insert message: %v", err)
}
})
}
func insertMessageWithRowID(t *testing.T, dbPath string, rowID int64, id, sessionID string, created int64, data string) {
t.Helper()
if !json.Valid([]byte(data)) && !strings.HasPrefix(data, "{not-json}") {
t.Fatalf("test fixture message data is invalid unexpectedly: %s", data)
}
withDB(t, dbPath, func(db *sql.DB) {
_, err := db.Exec(`INSERT INTO message (rowid, id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`, rowID, id, sessionID, created, created, data)
if err != nil {
t.Fatalf("insert message with rowid: %v", err)
}
})
}
func insertPart(t *testing.T, dbPath, id, messageID, sessionID string, created int64, data string) {
t.Helper()
withDB(t, dbPath, func(db *sql.DB) {
_, err := db.Exec(`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`, id, messageID, sessionID, created, created, data)
if err != nil {
t.Fatalf("insert part: %v", err)
}
})
}
func withDB(t *testing.T, dbPath string, fn func(*sql.DB)) {
t.Helper()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer func() { _ = db.Close() }()
fn(db)
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", path, err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
}