test: expand compact and ui coverage

This commit is contained in:
Jordan Hubbard
2025-12-29 19:32:38 -04:00
committed by Steve Yegge
parent d3b6855aa9
commit c9fa7af04c
7 changed files with 639 additions and 30 deletions

View File

@@ -7,6 +7,7 @@ import (
"sync"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
const (
@@ -24,9 +25,22 @@ type Config struct {
// Compactor handles issue compaction using AI summarization.
type Compactor struct {
store *sqlite.SQLiteStorage
haiku *HaikuClient
config *Config
store issueStore
summarizer summarizer
config *Config
}
type issueStore interface {
CheckEligibility(ctx context.Context, issueID string, tier int) (bool, string, error)
GetIssue(ctx context.Context, issueID string) (*types.Issue, error)
UpdateIssue(ctx context.Context, issueID string, updates map[string]interface{}, actor string) error
ApplyCompaction(ctx context.Context, issueID string, tier int, originalSize int, compactedSize int, commitHash string) error
AddComment(ctx context.Context, issueID, actor, comment string) error
MarkIssueDirty(ctx context.Context, issueID string) error
}
type summarizer interface {
SummarizeTier1(ctx context.Context, issue *types.Issue) (string, error)
}
// New creates a new Compactor instance with the given configuration.
@@ -43,7 +57,7 @@ func New(store *sqlite.SQLiteStorage, apiKey string, config *Config) (*Compactor
config.APIKey = apiKey
}
var haikuClient *HaikuClient
var haikuClient summarizer
var err error
if !config.DryRun {
haikuClient, err = NewHaikuClient(config.APIKey)
@@ -55,15 +69,15 @@ func New(store *sqlite.SQLiteStorage, apiKey string, config *Config) (*Compactor
}
}
}
if haikuClient != nil {
haikuClient.auditEnabled = config.AuditEnabled
haikuClient.auditActor = config.Actor
if hc, ok := haikuClient.(*HaikuClient); ok {
hc.auditEnabled = config.AuditEnabled
hc.auditActor = config.Actor
}
return &Compactor{
store: store,
haiku: haikuClient,
config: config,
store: store,
summarizer: haikuClient,
config: config,
}, nil
}
@@ -104,7 +118,10 @@ func (c *Compactor) CompactTier1(ctx context.Context, issueID string) error {
return fmt.Errorf("dry-run: would compact %s (original size: %d bytes)", issueID, originalSize)
}
summary, err := c.haiku.SummarizeTier1(ctx, issue)
if c.summarizer == nil {
return fmt.Errorf("summarizer not configured")
}
summary, err := c.summarizer.SummarizeTier1(ctx, issue)
if err != nil {
return fmt.Errorf("failed to summarize with Haiku: %w", err)
}
@@ -249,7 +266,10 @@ func (c *Compactor) compactSingleWithResult(ctx context.Context, issueID string,
result.OriginalSize = len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria)
summary, err := c.haiku.SummarizeTier1(ctx, issue)
if c.summarizer == nil {
return fmt.Errorf("summarizer not configured")
}
summary, err := c.summarizer.SummarizeTier1(ctx, issue)
if err != nil {
return fmt.Errorf("failed to summarize with Haiku: %w", err)
}

View File

@@ -0,0 +1,291 @@
package compact
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"github.com/steveyegge/beads/internal/types"
)
type stubStore struct {
checkEligibilityFn func(context.Context, string, int) (bool, string, error)
getIssueFn func(context.Context, string) (*types.Issue, error)
updateIssueFn func(context.Context, string, map[string]interface{}, string) error
applyCompactionFn func(context.Context, string, int, int, int, string) error
addCommentFn func(context.Context, string, string, string) error
markDirtyFn func(context.Context, string) error
}
func (s *stubStore) CheckEligibility(ctx context.Context, issueID string, tier int) (bool, string, error) {
if s.checkEligibilityFn != nil {
return s.checkEligibilityFn(ctx, issueID, tier)
}
return false, "", nil
}
func (s *stubStore) GetIssue(ctx context.Context, issueID string) (*types.Issue, error) {
if s.getIssueFn != nil {
return s.getIssueFn(ctx, issueID)
}
return nil, fmt.Errorf("GetIssue not stubbed")
}
func (s *stubStore) UpdateIssue(ctx context.Context, issueID string, updates map[string]interface{}, actor string) error {
if s.updateIssueFn != nil {
return s.updateIssueFn(ctx, issueID, updates, actor)
}
return nil
}
func (s *stubStore) ApplyCompaction(ctx context.Context, issueID string, tier int, originalSize int, compactedSize int, commitHash string) error {
if s.applyCompactionFn != nil {
return s.applyCompactionFn(ctx, issueID, tier, originalSize, compactedSize, commitHash)
}
return nil
}
func (s *stubStore) AddComment(ctx context.Context, issueID, actor, comment string) error {
if s.addCommentFn != nil {
return s.addCommentFn(ctx, issueID, actor, comment)
}
return nil
}
func (s *stubStore) MarkIssueDirty(ctx context.Context, issueID string) error {
if s.markDirtyFn != nil {
return s.markDirtyFn(ctx, issueID)
}
return nil
}
type stubSummarizer struct {
summary string
err error
calls int
}
func (s *stubSummarizer) SummarizeTier1(ctx context.Context, issue *types.Issue) (string, error) {
s.calls++
return s.summary, s.err
}
func stubIssue() *types.Issue {
return &types.Issue{
ID: "bd-123",
Title: "Fix login",
Description: strings.Repeat("A", 20),
Design: strings.Repeat("B", 10),
Notes: strings.Repeat("C", 5),
AcceptanceCriteria: "done",
Status: types.StatusClosed,
}
}
func withGitHash(t *testing.T, hash string) func() {
orig := gitExec
gitExec = func(string, ...string) ([]byte, error) {
return []byte(hash), nil
}
return func() { gitExec = orig }
}
func TestCompactTier1_Success(t *testing.T) {
cleanup := withGitHash(t, "deadbeef\n")
t.Cleanup(cleanup)
updateCalled := false
applyCalled := false
markCalled := false
store := &stubStore{
checkEligibilityFn: func(context.Context, string, int) (bool, string, error) { return true, "", nil },
getIssueFn: func(context.Context, string) (*types.Issue, error) { return stubIssue(), nil },
updateIssueFn: func(ctx context.Context, id string, updates map[string]interface{}, actor string) error {
updateCalled = true
if updates["description"].(string) != "short" {
t.Fatalf("expected summarized description")
}
if updates["design"].(string) != "" {
t.Fatalf("design should be cleared")
}
return nil
},
applyCompactionFn: func(ctx context.Context, id string, tier, original, compacted int, hash string) error {
applyCalled = true
if hash != "deadbeef" {
t.Fatalf("unexpected hash %q", hash)
}
return nil
},
addCommentFn: func(ctx context.Context, id, actor, comment string) error {
if !strings.Contains(comment, "saved") {
t.Fatalf("unexpected comment %q", comment)
}
return nil
},
markDirtyFn: func(context.Context, string) error {
markCalled = true
return nil
},
}
summary := &stubSummarizer{summary: "short"}
c := &Compactor{store: store, summarizer: summary, config: &Config{}}
if err := c.CompactTier1(context.Background(), "bd-123"); err != nil {
t.Fatalf("CompactTier1 unexpected error: %v", err)
}
if summary.calls != 1 {
t.Fatalf("expected summarizer used once, got %d", summary.calls)
}
if !updateCalled || !applyCalled || !markCalled {
t.Fatalf("expected update/apply/mark to be called")
}
}
func TestCompactTier1_DryRun(t *testing.T) {
store := &stubStore{
checkEligibilityFn: func(context.Context, string, int) (bool, string, error) { return true, "", nil },
getIssueFn: func(context.Context, string) (*types.Issue, error) { return stubIssue(), nil },
}
summary := &stubSummarizer{summary: "short"}
c := &Compactor{store: store, summarizer: summary, config: &Config{DryRun: true}}
err := c.CompactTier1(context.Background(), "bd-123")
if err == nil || !strings.Contains(err.Error(), "dry-run") {
t.Fatalf("expected dry-run error, got %v", err)
}
if summary.calls != 0 {
t.Fatalf("summarizer should not be used in dry run")
}
}
func TestCompactTier1_Ineligible(t *testing.T) {
store := &stubStore{
checkEligibilityFn: func(context.Context, string, int) (bool, string, error) { return false, "recently compacted", nil },
}
c := &Compactor{store: store, config: &Config{}}
err := c.CompactTier1(context.Background(), "bd-123")
if err == nil || !strings.Contains(err.Error(), "recently compacted") {
t.Fatalf("expected ineligible error, got %v", err)
}
}
func TestCompactTier1_SummaryNotSmaller(t *testing.T) {
commentCalled := false
store := &stubStore{
checkEligibilityFn: func(context.Context, string, int) (bool, string, error) { return true, "", nil },
getIssueFn: func(context.Context, string) (*types.Issue, error) { return stubIssue(), nil },
addCommentFn: func(ctx context.Context, id, actor, comment string) error {
commentCalled = true
if !strings.Contains(comment, "Tier 1 compaction skipped") {
t.Fatalf("unexpected comment %q", comment)
}
return nil
},
}
summary := &stubSummarizer{summary: strings.Repeat("X", 40)}
c := &Compactor{store: store, summarizer: summary, config: &Config{}}
err := c.CompactTier1(context.Background(), "bd-123")
if err == nil || !strings.Contains(err.Error(), "compaction would increase size") {
t.Fatalf("expected size error, got %v", err)
}
if !commentCalled {
t.Fatalf("expected warning comment to be recorded")
}
}
func TestCompactTier1_UpdateError(t *testing.T) {
store := &stubStore{
checkEligibilityFn: func(context.Context, string, int) (bool, string, error) { return true, "", nil },
getIssueFn: func(context.Context, string) (*types.Issue, error) { return stubIssue(), nil },
updateIssueFn: func(context.Context, string, map[string]interface{}, string) error { return errors.New("boom") },
}
summary := &stubSummarizer{summary: "short"}
c := &Compactor{store: store, summarizer: summary, config: &Config{}}
err := c.CompactTier1(context.Background(), "bd-123")
if err == nil || !strings.Contains(err.Error(), "failed to update issue") {
t.Fatalf("expected update error, got %v", err)
}
}
func TestCompactTier1Batch_MixedResults(t *testing.T) {
cleanup := withGitHash(t, "cafebabe\n")
t.Cleanup(cleanup)
var mu sync.Mutex
updated := make(map[string]int)
applied := make(map[string]int)
marked := make(map[string]int)
store := &stubStore{
checkEligibilityFn: func(ctx context.Context, id string, tier int) (bool, string, error) {
switch id {
case "bd-1":
return true, "", nil
case "bd-2":
return false, "not eligible", nil
default:
return false, "", fmt.Errorf("unexpected id %s", id)
}
},
getIssueFn: func(ctx context.Context, id string) (*types.Issue, error) {
issue := stubIssue()
issue.ID = id
return issue, nil
},
updateIssueFn: func(ctx context.Context, id string, updates map[string]interface{}, actor string) error {
mu.Lock()
updated[id]++
mu.Unlock()
return nil
},
applyCompactionFn: func(ctx context.Context, id string, tier, original, compacted int, hash string) error {
mu.Lock()
applied[id]++
mu.Unlock()
return nil
},
addCommentFn: func(context.Context, string, string, string) error { return nil },
markDirtyFn: func(ctx context.Context, id string) error {
mu.Lock()
marked[id]++
mu.Unlock()
return nil
},
}
summary := &stubSummarizer{summary: "short"}
c := &Compactor{store: store, summarizer: summary, config: &Config{Concurrency: 2}}
results, err := c.CompactTier1Batch(context.Background(), []string{"bd-1", "bd-2"})
if err != nil {
t.Fatalf("CompactTier1Batch unexpected error: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
resMap := map[string]*Result{}
for _, r := range results {
resMap[r.IssueID] = r
}
if res := resMap["bd-1"]; res == nil || res.Err != nil || res.CompactedSize == 0 {
t.Fatalf("expected success result for bd-1, got %+v", res)
}
if res := resMap["bd-2"]; res == nil || res.Err == nil || !strings.Contains(res.Err.Error(), "not eligible") {
t.Fatalf("expected ineligible error for bd-2, got %+v", res)
}
if updated["bd-1"] != 1 || applied["bd-1"] != 1 || marked["bd-1"] != 1 {
t.Fatalf("expected store operations for bd-1 exactly once")
}
if updated["bd-2"] != 0 || applied["bd-2"] != 0 {
t.Fatalf("bd-2 should not be processed")
}
if summary.calls != 1 {
t.Fatalf("summarizer should run once; got %d", summary.calls)
}
}

View File

@@ -3,13 +3,21 @@ package compact
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/anthropics/anthropic-sdk-go"
"github.com/steveyegge/beads/internal/types"
)
type timeoutErr struct{}
func (timeoutErr) Error() string { return "timeout" }
func (timeoutErr) Timeout() bool { return true }
func (timeoutErr) Temporary() bool { return true }
func TestNewHaikuClient_RequiresAPIKey(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
@@ -178,6 +186,11 @@ func TestIsRetryable(t *testing.T) {
{"context canceled", context.Canceled, false},
{"context deadline exceeded", context.DeadlineExceeded, false},
{"generic error", errors.New("some error"), false},
{"timeout error", timeoutErr{}, true},
{"anthropic 429", &anthropic.Error{StatusCode: 429}, true},
{"anthropic 500", &anthropic.Error{StatusCode: 500}, true},
{"anthropic 400", &anthropic.Error{StatusCode: 400}, false},
{"wrapped timeout", fmt.Errorf("wrap: %w", timeoutErr{}), true},
}
for _, tt := range tests {
@@ -189,3 +202,16 @@ func TestIsRetryable(t *testing.T) {
})
}
}
func TestBytesWriterAppends(t *testing.T) {
w := &bytesWriter{}
if _, err := w.Write([]byte("hello")); err != nil {
t.Fatalf("first write failed: %v", err)
}
if _, err := w.Write([]byte(" world")); err != nil {
t.Fatalf("second write failed: %v", err)
}
if got := string(w.buf); got != "hello world" {
t.Fatalf("unexpected buffer content: %q", got)
}
}