26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
229 lines
7.3 KiB
Go
229 lines
7.3 KiB
Go
package intent
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// buildCodexFixture writes a state_5.sqlite + a rollout JSONL referenced by
|
|
// it, with the supplied cwd. The rollout has user, assistant, and tool-call
|
|
// turns so we can verify they're all parsed correctly.
|
|
func buildCodexFixture(t *testing.T, cwd string) (homeDir, rolloutPath string) {
|
|
t.Helper()
|
|
homeDir = t.TempDir()
|
|
codexDir := filepath.Join(homeDir, ".codex")
|
|
if err := os.MkdirAll(codexDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rolloutPath = filepath.Join(codexDir, "sessions", "2026", "04", "rollout-thread-1.jsonl")
|
|
if err := os.MkdirAll(filepath.Dir(rolloutPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rollout := strings.Join([]string{
|
|
// User turn via event_msg envelope.
|
|
`{"type":"event_msg","payload":{"type":"user_message","message":"please add a Bar() helper to internal/foo.go"}}`,
|
|
// Assistant text reply.
|
|
`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"on it - editing internal/foo.go now"}]}}`,
|
|
// Tool call: shell command. File paths should be captured for matching, but text empty.
|
|
`{"type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"{\"command\":[\"sed\",\"-i\",\"\",\"s/old/new/\",\"internal/foo.go\"]}"}}`,
|
|
// Another user turn via response_item shape.
|
|
`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"thanks, also update internal/bar.go"}]}}`,
|
|
// Non-content envelope - should be skipped.
|
|
`{"type":"turn_context","payload":{}}`,
|
|
}, "\n")
|
|
if err := os.WriteFile(rolloutPath, []byte(rollout), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dbPath := filepath.Join(codexDir, "state_5.sqlite")
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
if _, err := db.Exec(`CREATE TABLE threads (
|
|
id TEXT PRIMARY KEY,
|
|
cwd TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
rollout_path TEXT NOT NULL DEFAULT ''
|
|
)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now().Unix()
|
|
if _, err := db.Exec(
|
|
`INSERT INTO threads (id, cwd, created_at, updated_at, rollout_path) VALUES (?, ?, ?, ?, ?)`,
|
|
"thread-1", cwd, now, now, rolloutPath,
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return homeDir, rolloutPath
|
|
}
|
|
|
|
func TestCodexReader_ParsesAllTurnsFromRollout(t *testing.T) {
|
|
repoCWD := t.TempDir()
|
|
home, _ := buildCodexFixture(t, repoCWD)
|
|
|
|
r := NewCodexReader()
|
|
sessions, err := r.Discover(context.Background(), DiscoverOpts{
|
|
HomeDir: home,
|
|
OriginCWD: repoCWD,
|
|
WindowStart: time.Now().Add(-time.Hour),
|
|
WindowEnd: time.Now().Add(time.Hour),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("discover: %v", err)
|
|
}
|
|
if len(sessions) != 1 {
|
|
t.Fatalf("got %d sessions, want 1", len(sessions))
|
|
}
|
|
s := sessions[0]
|
|
if len(s.Messages) != 0 {
|
|
t.Errorf("Discover should not populate Messages, got %d", len(s.Messages))
|
|
}
|
|
|
|
if err := r.Load(context.Background(), s); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
// Expect: user(event_msg) + assistant(text) + assistant(tool_call paths only) + user(response_item)
|
|
if len(s.Messages) != 4 {
|
|
t.Fatalf("got %d messages, want 4: %+v", len(s.Messages), s.Messages)
|
|
}
|
|
|
|
if s.Messages[0].Role != RoleUser || !strings.Contains(s.Messages[0].Text, "Bar()") {
|
|
t.Errorf("turn 0 wrong: %+v", s.Messages[0])
|
|
}
|
|
if s.Messages[1].Role != RoleAssistant || !strings.Contains(s.Messages[1].Text, "editing") {
|
|
t.Errorf("turn 1 wrong: %+v", s.Messages[1])
|
|
}
|
|
|
|
// Tool-call message must NOT contain the shell command in Text.
|
|
if s.Messages[2].Text != "" {
|
|
t.Errorf("tool call turn leaked text: %q", s.Messages[2].Text)
|
|
}
|
|
foundPath := false
|
|
for _, p := range s.Messages[2].FilePaths {
|
|
if strings.Contains(p, "internal/foo.go") || p == "foo.go" {
|
|
foundPath = true
|
|
}
|
|
}
|
|
if !foundPath {
|
|
t.Errorf("tool call turn should have captured internal/foo.go, got %v", s.Messages[2].FilePaths)
|
|
}
|
|
|
|
// Second user turn (via response_item shape).
|
|
if s.Messages[3].Role != RoleUser || !strings.Contains(s.Messages[3].Text, "bar.go") {
|
|
t.Errorf("turn 3 wrong: %+v", s.Messages[3])
|
|
}
|
|
}
|
|
|
|
func TestCodexReader_FiltersByCWD(t *testing.T) {
|
|
home, _ := buildCodexFixture(t, "/some/other/path")
|
|
r := NewCodexReader()
|
|
sessions, err := r.Discover(context.Background(), DiscoverOpts{
|
|
HomeDir: home,
|
|
OriginCWD: "/different",
|
|
WindowStart: time.Now().Add(-time.Hour),
|
|
WindowEnd: time.Now().Add(time.Hour),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("discover: %v", err)
|
|
}
|
|
if len(sessions) != 0 {
|
|
t.Errorf("expected 0 sessions, got %d", len(sessions))
|
|
}
|
|
}
|
|
|
|
func TestCodexReader_NoStateDB(t *testing.T) {
|
|
r := NewCodexReader()
|
|
sessions, err := r.Discover(context.Background(), DiscoverOpts{HomeDir: t.TempDir()})
|
|
if err != nil {
|
|
t.Fatalf("discover: %v", err)
|
|
}
|
|
if len(sessions) != 0 {
|
|
t.Errorf("expected 0 sessions when no DB, got %d", len(sessions))
|
|
}
|
|
}
|
|
|
|
func TestCodexReader_MissingRollout(t *testing.T) {
|
|
repoCWD := t.TempDir()
|
|
home, rolloutPath := buildCodexFixture(t, repoCWD)
|
|
if err := os.Remove(rolloutPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := NewCodexReader()
|
|
sessions, _ := r.Discover(context.Background(), DiscoverOpts{
|
|
HomeDir: home,
|
|
OriginCWD: repoCWD,
|
|
WindowStart: time.Now().Add(-time.Hour),
|
|
WindowEnd: time.Now().Add(time.Hour),
|
|
})
|
|
if len(sessions) != 1 {
|
|
t.Fatalf("got %d sessions, want 1", len(sessions))
|
|
}
|
|
// Load must error gracefully when the rollout is gone, not panic.
|
|
if err := r.Load(context.Background(), sessions[0]); err == nil {
|
|
t.Error("expected error when rollout missing")
|
|
}
|
|
}
|
|
|
|
func TestResolveCodexStateDB_PicksHighestVersion(t *testing.T) {
|
|
root := t.TempDir()
|
|
for _, name := range []string{"state_4.sqlite", "state_5.sqlite", "state_6.sqlite", "unrelated.sqlite"} {
|
|
if err := os.WriteFile(filepath.Join(root, name), []byte{}, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
got, err := resolveCodexStateDB(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if filepath.Base(got) != "state_6.sqlite" {
|
|
t.Errorf("picked %s, want state_6.sqlite", got)
|
|
}
|
|
}
|
|
|
|
// Lexicographic sort would rank state_9 ahead of state_10 (because '9' > '1');
|
|
// numeric sort must pick state_10. Once Codex bumps past state_9 this is the
|
|
// difference between reading the live DB and reading a stale one.
|
|
func TestResolveCodexStateDB_NumericSortPastNine(t *testing.T) {
|
|
root := t.TempDir()
|
|
for _, name := range []string{"state_9.sqlite", "state_10.sqlite", "state_11.sqlite", "state_5.sqlite"} {
|
|
if err := os.WriteFile(filepath.Join(root, name), []byte{}, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
got, err := resolveCodexStateDB(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if filepath.Base(got) != "state_11.sqlite" {
|
|
t.Errorf("picked %s, want state_11.sqlite (numeric sort)", got)
|
|
}
|
|
}
|
|
|
|
// Non-numeric suffixes (e.g. backups) must not override a real numbered DB.
|
|
func TestResolveCodexStateDB_IgnoresNonNumericSuffix(t *testing.T) {
|
|
root := t.TempDir()
|
|
for _, name := range []string{"state_5.sqlite", "state_backup.sqlite"} {
|
|
if err := os.WriteFile(filepath.Join(root, name), []byte{}, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
got, err := resolveCodexStateDB(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if filepath.Base(got) != "state_5.sqlite" {
|
|
t.Errorf("picked %s, want state_5.sqlite (numeric must outrank non-numeric)", got)
|
|
}
|
|
}
|