Add Claude Haiku client for issue compaction (bd-255)
- Implement HaikuClient with Tier 1 and Tier 2 summarization prompts - Add exponential backoff retry logic for transient errors (network, 429, 5xx) - API key precedence: env var overrides explicit key parameter - Comprehensive tests with UTF-8 support and edge case coverage - Retry logic only retries transient errors, stops on permanent failures - Clarified retry semantics: maxRetries=3 means 4 total attempts (1 initial + 3 retries)
This commit is contained in:
@@ -170,7 +170,7 @@
|
||||
{"id":"bd-252","title":"Add compaction schema and migrations","description":"Add database schema support for issue compaction tracking and snapshot storage.","design":"Add three columns to `issues` table:\n- `compaction_level INTEGER DEFAULT 0` - 0=original, 1=tier1, 2=tier2\n- `compacted_at DATETIME` - when last compacted\n- `original_size INTEGER` - bytes before first compaction\n\nCreate `issue_snapshots` table:\n```sql\nCREATE TABLE issue_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n issue_id TEXT NOT NULL,\n snapshot_time DATETIME NOT NULL,\n compaction_level INTEGER NOT NULL,\n original_size INTEGER NOT NULL,\n compressed_size INTEGER NOT NULL,\n original_content TEXT NOT NULL, -- JSON blob\n archived_events TEXT,\n FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE\n);\n```\n\nAdd indexes:\n- `idx_snapshots_issue` on `issue_id`\n- `idx_snapshots_level` on `compaction_level`\n\nAdd migration functions in `internal/storage/sqlite/sqlite.go`:\n- `migrateCompactionColumns(db *sql.DB) error`\n- `migrateSnapshotsTable(db *sql.DB) error`","acceptance_criteria":"- Existing databases migrate automatically\n- New databases include columns by default\n- Migration is idempotent (safe to run multiple times)\n- No data loss during migration\n- Tests verify migration on fresh and existing DBs","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-15T21:51:23.216371-07:00","updated_at":"2025-10-15T22:02:27.638283-07:00","closed_at":"2025-10-15T22:02:27.638283-07:00"}
|
||||
{"id":"bd-253","title":"Add compaction configuration keys","description":"Add configuration keys for compaction behavior with sensible defaults.","design":"Add to `internal/storage/sqlite/schema.go` initial config:\n```sql\nINSERT OR IGNORE INTO config (key, value) VALUES\n ('compact_tier1_days', '30'),\n ('compact_tier1_dep_levels', '2'),\n ('compact_tier2_days', '90'),\n ('compact_tier2_dep_levels', '5'),\n ('compact_tier2_commits', '100'),\n ('compact_model', 'claude-3-5-haiku-20241022'),\n ('compact_batch_size', '50'),\n ('compact_parallel_workers', '5'),\n ('auto_compact_enabled', 'false');\n```\n\nAdd helper functions for loading config into typed struct.","acceptance_criteria":"- Config keys created on init\n- Existing DBs get defaults on migration\n- `bd config get/set` works with all keys\n- Type validation (days=int, enabled=bool)\n- Documentation in README.md","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-15T21:51:23.22391-07:00","updated_at":"2025-10-15T22:08:44.984927-07:00","closed_at":"2025-10-15T22:08:44.984927-07:00"}
|
||||
{"id":"bd-254","title":"Implement candidate identification queries","description":"Write SQL queries to identify issues eligible for Tier 1 and Tier 2 compaction based on closure time and dependency status.","design":"Create `internal/storage/sqlite/compact.go` with:\n\n```go\ntype CompactionCandidate struct {\n IssueID string\n ClosedAt time.Time\n OriginalSize int\n EstimatedSize int\n DependentCount int\n}\n\nfunc (s *SQLiteStorage) GetTier1Candidates(ctx context.Context) ([]*CompactionCandidate, error)\nfunc (s *SQLiteStorage) GetTier2Candidates(ctx context.Context) ([]*CompactionCandidate, error)\nfunc (s *SQLiteStorage) CheckEligibility(ctx context.Context, issueID string, tier int) (bool, string, error)\n```\n\nUse recursive CTE for dependency depth checking (similar to ready_issues view).","acceptance_criteria":"- Tier 1 query filters by days and dependency depth\n- Tier 2 query includes commit/issue count checks\n- Dependency checking handles circular deps gracefully\n- Performance: \u003c100ms for 10,000 issue database\n- Tests cover edge cases (no deps, circular deps, mixed status)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-15T21:51:23.225835-07:00","updated_at":"2025-10-15T22:16:45.517562-07:00","closed_at":"2025-10-15T22:16:45.517562-07:00"}
|
||||
{"id":"bd-255","title":"Create Haiku client and prompt templates","description":"Implement Claude Haiku API client with template-based prompts for Tier 1 and Tier 2 summarization.","design":"Create `internal/compact/haiku.go`:\n\n```go\ntype HaikuClient struct {\n client *anthropic.Client\n model string\n}\n\nfunc NewHaikuClient(apiKey string) (*HaikuClient, error)\nfunc (h *HaikuClient) SummarizeTier1(ctx context.Context, issue *types.Issue) (string, error)\nfunc (h *HaikuClient) SummarizeTier2(ctx context.Context, issue *types.Issue) (string, error)\n```\n\nUse text/template for prompt rendering.\n\nTier 1 output format:\n```\n**Summary:** [2-3 sentences]\n**Key Decisions:** [bullet points]\n**Resolution:** [outcome]\n```\n\nTier 2 output format:\n```\nSingle paragraph ≤150 words covering what was built, why it mattered, lasting impact.\n```","acceptance_criteria":"- API key from env var or config (env takes precedence)\n- Prompts render correctly with templates\n- Rate limiting handled gracefully (exponential backoff)\n- Network errors retry up to 3 times\n- Mock tests for API calls","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-15T21:51:23.229702-07:00","updated_at":"2025-10-15T21:51:23.229702-07:00"}
|
||||
{"id":"bd-255","title":"Create Haiku client and prompt templates","description":"Implement Claude Haiku API client with template-based prompts for Tier 1 and Tier 2 summarization.","design":"Create `internal/compact/haiku.go`:\n\n```go\ntype HaikuClient struct {\n client *anthropic.Client\n model string\n}\n\nfunc NewHaikuClient(apiKey string) (*HaikuClient, error)\nfunc (h *HaikuClient) SummarizeTier1(ctx context.Context, issue *types.Issue) (string, error)\nfunc (h *HaikuClient) SummarizeTier2(ctx context.Context, issue *types.Issue) (string, error)\n```\n\nUse text/template for prompt rendering.\n\nTier 1 output format:\n```\n**Summary:** [2-3 sentences]\n**Key Decisions:** [bullet points]\n**Resolution:** [outcome]\n```\n\nTier 2 output format:\n```\nSingle paragraph ≤150 words covering what was built, why it mattered, lasting impact.\n```","acceptance_criteria":"- API key from env var or config (env takes precedence)\n- Prompts render correctly with templates\n- Rate limiting handled gracefully (exponential backoff)\n- Network errors retry up to 3 times\n- Mock tests for API calls","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-15T21:51:23.229702-07:00","updated_at":"2025-10-15T22:32:51.491798-07:00","closed_at":"2025-10-15T22:32:51.491798-07:00"}
|
||||
{"id":"bd-256","title":"Implement snapshot creation and restoration","description":"Implement snapshot creation before compaction and restoration capability to undo compaction.","design":"Add to `internal/storage/sqlite/compact.go`:\n\n```go\nfunc (s *SQLiteStorage) CreateSnapshot(ctx context.Context, issue *types.Issue, level int) error\nfunc (s *SQLiteStorage) RestoreFromSnapshot(ctx context.Context, issueID string, level int) error\nfunc (s *SQLiteStorage) GetSnapshots(ctx context.Context, issueID string) ([]*Snapshot, error)\n```\n\nSnapshot JSON structure:\n```json\n{\n \"description\": \"...\",\n \"design\": \"...\",\n \"notes\": \"...\",\n \"acceptance_criteria\": \"...\",\n \"title\": \"...\"\n}\n```","acceptance_criteria":"- Snapshot created atomically with compaction\n- Restore returns exact original content\n- Multiple snapshots per issue supported (Tier 1 → Tier 2)\n- JSON encoding handles UTF-8 and special characters\n- Size calculation is accurate (UTF-8 bytes)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-15T21:51:23.231906-07:00","updated_at":"2025-10-15T21:51:23.231906-07:00"}
|
||||
{"id":"bd-257","title":"Implement Tier 1 compaction logic","description":"Implement the core Tier 1 compaction process: snapshot → summarize → update.","design":"Add to `internal/compact/compactor.go`:\n\n```go\ntype Compactor struct {\n store storage.Storage\n haiku *HaikuClient\n config *CompactConfig\n}\n\nfunc New(store storage.Storage, apiKey string, config *CompactConfig) (*Compactor, error)\nfunc (c *Compactor) CompactTier1(ctx context.Context, issueID string) error\nfunc (c *Compactor) CompactTier1Batch(ctx context.Context, issueIDs []string) error\n```\n\nProcess:\n1. Verify eligibility\n2. Calculate original size\n3. Create snapshot\n4. Call Haiku for summary\n5. Update issue (description=summary, clear design/notes/criteria)\n6. Set compaction_level=1, compacted_at=now, original_size\n7. Record EventCompacted\n8. Mark dirty for export","acceptance_criteria":"- Single issue compaction works end-to-end\n- Batch processing with parallel workers (5 concurrent)\n- Errors don't corrupt database (transaction rollback)\n- EventCompacted includes size savings\n- Dry-run mode (identify + size estimate only, no API calls)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-15T21:51:23.23391-07:00","updated_at":"2025-10-15T21:51:23.23391-07:00"}
|
||||
{"id":"bd-258","title":"Implement Tier 2 compaction logic","description":"Implement Tier 2 ultra-compression: more aggressive summarization and optional event pruning.","design":"Add to `internal/compact/compactor.go`:\n\n```go\nfunc (c *Compactor) CompactTier2(ctx context.Context, issueID string) error\nfunc (c *Compactor) CompactTier2Batch(ctx context.Context, issueIDs []string) error\n```\n\nProcess:\n1. Verify issue is at compaction_level = 1\n2. Check Tier 2 eligibility (days, deps, commits/issues)\n3. Create Tier 2 snapshot\n4. Call Haiku with ultra-compression prompt\n5. Update issue (description = single paragraph, clear all other fields)\n6. Set compaction_level = 2\n7. Optionally prune events (keep created/closed, archive rest to snapshot)","acceptance_criteria":"- Requires existing Tier 1 compaction\n- Git commit counting works (with fallback to issue counter)\n- Events optionally pruned (config: compact_events_enabled)\n- Archived events stored in snapshot JSON\n- Size reduction 90-95%","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-15T21:51:23.23586-07:00","updated_at":"2025-10-15T21:51:23.23586-07:00"}
|
||||
|
||||
5
go.mod
5
go.mod
@@ -9,6 +9,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.14.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
@@ -17,6 +18,10 @@ require (
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
|
||||
12
go.sum
12
go.sum
@@ -1,3 +1,5 @@
|
||||
github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4=
|
||||
github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
@@ -24,6 +26,16 @@ github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
|
||||
254
internal/compact/haiku.go
Normal file
254
internal/compact/haiku.go
Normal file
@@ -0,0 +1,254 @@
|
||||
// Package compact provides AI-powered issue compaction using Claude Haiku.
|
||||
package compact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultModel = "claude-3-5-haiku-20241022"
|
||||
maxRetries = 3
|
||||
initialBackoff = 1 * time.Second
|
||||
)
|
||||
|
||||
// HaikuClient wraps the Anthropic API for issue summarization.
|
||||
type HaikuClient struct {
|
||||
client anthropic.Client
|
||||
model anthropic.Model
|
||||
tier1Template *template.Template
|
||||
tier2Template *template.Template
|
||||
maxRetries int
|
||||
initialBackoff time.Duration
|
||||
}
|
||||
|
||||
// NewHaikuClient creates a new Haiku API client. Env var ANTHROPIC_API_KEY takes precedence over explicit apiKey.
|
||||
func NewHaikuClient(apiKey string) (*HaikuClient, error) {
|
||||
envKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if envKey != "" {
|
||||
apiKey = envKey
|
||||
}
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("API key required: set ANTHROPIC_API_KEY environment variable or provide via config")
|
||||
}
|
||||
|
||||
client := anthropic.NewClient(option.WithAPIKey(apiKey))
|
||||
|
||||
tier1Tmpl, err := template.New("tier1").Parse(tier1PromptTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse tier1 template: %w", err)
|
||||
}
|
||||
|
||||
tier2Tmpl, err := template.New("tier2").Parse(tier2PromptTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse tier2 template: %w", err)
|
||||
}
|
||||
|
||||
return &HaikuClient{
|
||||
client: client,
|
||||
model: defaultModel,
|
||||
tier1Template: tier1Tmpl,
|
||||
tier2Template: tier2Tmpl,
|
||||
maxRetries: maxRetries,
|
||||
initialBackoff: initialBackoff,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SummarizeTier1 creates a structured summary of an issue (Summary, Key Decisions, Resolution).
|
||||
func (h *HaikuClient) SummarizeTier1(ctx context.Context, issue *types.Issue) (string, error) {
|
||||
prompt, err := h.renderTier1Prompt(issue)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render prompt: %w", err)
|
||||
}
|
||||
|
||||
return h.callWithRetry(ctx, prompt)
|
||||
}
|
||||
|
||||
// SummarizeTier2 creates an ultra-compressed single-paragraph summary (≤150 words).
|
||||
func (h *HaikuClient) SummarizeTier2(ctx context.Context, issue *types.Issue) (string, error) {
|
||||
prompt, err := h.renderTier2Prompt(issue)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render prompt: %w", err)
|
||||
}
|
||||
|
||||
return h.callWithRetry(ctx, prompt)
|
||||
}
|
||||
|
||||
func (h *HaikuClient) callWithRetry(ctx context.Context, prompt string) (string, error) {
|
||||
var lastErr error
|
||||
params := anthropic.MessageNewParams{
|
||||
Model: h.model,
|
||||
MaxTokens: 1024,
|
||||
Messages: []anthropic.MessageParam{
|
||||
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
|
||||
},
|
||||
}
|
||||
|
||||
for attempt := 0; attempt <= h.maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := h.initialBackoff * time.Duration(math.Pow(2, float64(attempt-1)))
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
message, err := h.client.Messages.New(ctx, params)
|
||||
|
||||
if err == nil {
|
||||
if len(message.Content) > 0 {
|
||||
content := message.Content[0]
|
||||
if content.Type == "text" {
|
||||
return content.Text, nil
|
||||
}
|
||||
return "", fmt.Errorf("unexpected response format: not a text block (type=%s)", content.Type)
|
||||
}
|
||||
return "", fmt.Errorf("unexpected response format: no content blocks")
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
|
||||
if !isRetryable(err) {
|
||||
return "", fmt.Errorf("non-retryable error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed after %d retries: %w", h.maxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func isRetryable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary()) {
|
||||
return true
|
||||
}
|
||||
|
||||
var apiErr *anthropic.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
statusCode := apiErr.StatusCode
|
||||
if statusCode == 429 || statusCode >= 500 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type tier1Data struct {
|
||||
Title string
|
||||
Description string
|
||||
Design string
|
||||
AcceptanceCriteria string
|
||||
Notes string
|
||||
}
|
||||
|
||||
func (h *HaikuClient) renderTier1Prompt(issue *types.Issue) (string, error) {
|
||||
var buf []byte
|
||||
w := &bytesWriter{buf: buf}
|
||||
|
||||
data := tier1Data{
|
||||
Title: issue.Title,
|
||||
Description: issue.Description,
|
||||
Design: issue.Design,
|
||||
AcceptanceCriteria: issue.AcceptanceCriteria,
|
||||
Notes: issue.Notes,
|
||||
}
|
||||
|
||||
if err := h.tier1Template.Execute(w, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(w.buf), nil
|
||||
}
|
||||
|
||||
type tier2Data struct {
|
||||
Title string
|
||||
CurrentDescription string
|
||||
}
|
||||
|
||||
func (h *HaikuClient) renderTier2Prompt(issue *types.Issue) (string, error) {
|
||||
var buf []byte
|
||||
w := &bytesWriter{buf: buf}
|
||||
|
||||
data := tier2Data{
|
||||
Title: issue.Title,
|
||||
CurrentDescription: issue.Description,
|
||||
}
|
||||
|
||||
if err := h.tier2Template.Execute(w, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(w.buf), nil
|
||||
}
|
||||
|
||||
type bytesWriter struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (w *bytesWriter) Write(p []byte) (n int, err error) {
|
||||
w.buf = append(w.buf, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
const tier1PromptTemplate = `You are summarizing a closed software issue for long-term storage. Compress the following issue into a concise summary that preserves key technical decisions and outcomes.
|
||||
|
||||
**Title:** {{.Title}}
|
||||
|
||||
**Description:**
|
||||
{{.Description}}
|
||||
|
||||
{{if .Design}}**Design:**
|
||||
{{.Design}}
|
||||
{{end}}
|
||||
|
||||
{{if .AcceptanceCriteria}}**Acceptance Criteria:**
|
||||
{{.AcceptanceCriteria}}
|
||||
{{end}}
|
||||
|
||||
{{if .Notes}}**Notes:**
|
||||
{{.Notes}}
|
||||
{{end}}
|
||||
|
||||
Provide a summary in this exact format:
|
||||
|
||||
**Summary:** [2-3 sentences covering what was done and why]
|
||||
|
||||
**Key Decisions:** [Bullet points of important technical choices or design decisions]
|
||||
|
||||
**Resolution:** [Final outcome and any lasting impact]`
|
||||
|
||||
const tier2PromptTemplate = `You are performing ultra-compression on a closed software issue. The issue has already been summarized once. Your task is to create a single concise paragraph (≤150 words) that captures the essence.
|
||||
|
||||
**Title:** {{.Title}}
|
||||
|
||||
**Current Summary:**
|
||||
{{.CurrentDescription}}
|
||||
|
||||
Provide a single paragraph that covers:
|
||||
- What was built/fixed
|
||||
- Why it mattered
|
||||
- Any lasting impact or decisions
|
||||
|
||||
Keep it under 150 words while retaining the most important context.`
|
||||
220
internal/compact/haiku_test.go
Normal file
220
internal/compact/haiku_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package compact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestNewHaikuClient_RequiresAPIKey(t *testing.T) {
|
||||
t.Setenv("ANTHROPIC_API_KEY", "")
|
||||
|
||||
_, err := NewHaikuClient("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when API key is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "API key required") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHaikuClient_EnvVarUsedWhenNoExplicitKey(t *testing.T) {
|
||||
t.Setenv("ANTHROPIC_API_KEY", "test-key-from-env")
|
||||
|
||||
client, err := NewHaikuClient("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHaikuClient_EnvVarOverridesExplicitKey(t *testing.T) {
|
||||
t.Setenv("ANTHROPIC_API_KEY", "test-key-from-env")
|
||||
|
||||
client, err := NewHaikuClient("test-key-explicit")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTier1Prompt(t *testing.T) {
|
||||
client, err := NewHaikuClient("test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
issue := &types.Issue{
|
||||
ID: "bd-1",
|
||||
Title: "Fix authentication bug",
|
||||
Description: "Users can't log in with OAuth",
|
||||
Design: "Add error handling to OAuth flow",
|
||||
AcceptanceCriteria: "Users can log in successfully",
|
||||
Notes: "Related to issue bd-2",
|
||||
Status: types.StatusClosed,
|
||||
}
|
||||
|
||||
prompt, err := client.renderTier1Prompt(issue)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to render prompt: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(prompt, "Fix authentication bug") {
|
||||
t.Error("prompt should contain title")
|
||||
}
|
||||
if !strings.Contains(prompt, "Users can't log in with OAuth") {
|
||||
t.Error("prompt should contain description")
|
||||
}
|
||||
if !strings.Contains(prompt, "Add error handling to OAuth flow") {
|
||||
t.Error("prompt should contain design")
|
||||
}
|
||||
if !strings.Contains(prompt, "Users can log in successfully") {
|
||||
t.Error("prompt should contain acceptance criteria")
|
||||
}
|
||||
if !strings.Contains(prompt, "Related to issue bd-2") {
|
||||
t.Error("prompt should contain notes")
|
||||
}
|
||||
if !strings.Contains(prompt, "**Summary:**") {
|
||||
t.Error("prompt should contain format instructions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTier2Prompt(t *testing.T) {
|
||||
client, err := NewHaikuClient("test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
issue := &types.Issue{
|
||||
ID: "bd-1",
|
||||
Title: "Fix authentication bug",
|
||||
Description: "**Summary:** Fixed OAuth login flow by adding proper error handling.",
|
||||
Status: types.StatusClosed,
|
||||
}
|
||||
|
||||
prompt, err := client.renderTier2Prompt(issue)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to render prompt: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(prompt, "Fix authentication bug") {
|
||||
t.Error("prompt should contain title")
|
||||
}
|
||||
if !strings.Contains(prompt, "Fixed OAuth login flow") {
|
||||
t.Error("prompt should contain current description")
|
||||
}
|
||||
if !strings.Contains(prompt, "150 words") {
|
||||
t.Error("prompt should contain word limit")
|
||||
}
|
||||
if !strings.Contains(prompt, "ultra-compression") {
|
||||
t.Error("prompt should indicate ultra-compression")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTier1Prompt_HandlesEmptyFields(t *testing.T) {
|
||||
client, err := NewHaikuClient("test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
issue := &types.Issue{
|
||||
ID: "bd-1",
|
||||
Title: "Simple task",
|
||||
Description: "Just a simple task",
|
||||
Status: types.StatusClosed,
|
||||
}
|
||||
|
||||
prompt, err := client.renderTier1Prompt(issue)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to render prompt: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(prompt, "Simple task") {
|
||||
t.Error("prompt should contain title")
|
||||
}
|
||||
if !strings.Contains(prompt, "Just a simple task") {
|
||||
t.Error("prompt should contain description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTier1Prompt_UTF8(t *testing.T) {
|
||||
client, err := NewHaikuClient("test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
issue := &types.Issue{
|
||||
ID: "bd-1",
|
||||
Title: "Fix bug with émojis 🎉",
|
||||
Description: "Handle UTF-8: café, 日本語, emoji 🚀",
|
||||
Status: types.StatusClosed,
|
||||
}
|
||||
|
||||
prompt, err := client.renderTier1Prompt(issue)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to render prompt: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(prompt, "🎉") {
|
||||
t.Error("prompt should preserve emoji in title")
|
||||
}
|
||||
if !strings.Contains(prompt, "café") {
|
||||
t.Error("prompt should preserve accented characters")
|
||||
}
|
||||
if !strings.Contains(prompt, "日本語") {
|
||||
t.Error("prompt should preserve unicode characters")
|
||||
}
|
||||
if !strings.Contains(prompt, "🚀") {
|
||||
t.Error("prompt should preserve emoji in description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallWithRetry_ContextCancellation(t *testing.T) {
|
||||
client, err := NewHaikuClient("test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
client.initialBackoff = 100 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err = client.callWithRetry(ctx, "test prompt")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when context is cancelled")
|
||||
}
|
||||
if err != context.Canceled {
|
||||
t.Errorf("expected context.Canceled error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{"nil error", nil, false},
|
||||
{"context canceled", context.Canceled, false},
|
||||
{"context deadline exceeded", context.DeadlineExceeded, false},
|
||||
{"generic error", errors.New("some error"), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isRetryable(tt.err)
|
||||
if got != tt.expected {
|
||||
t.Errorf("isRetryable(%v) = %v, want %v", tt.err, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user