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, "") { 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) } }