Files
gastown/internal/beads/beads_test.go
Steve Yegge f9e820985d feat: Filter agent session molecule noise from activity feed
Agent session molecules (gt-gastown-crew-joe, gt-gastown-witness, etc.)
update frequently for status tracking, creating noisy entries in the
activity feed. This change:

- Adds IsAgentSessionBead() to identify agent session beads
- Filters out "update" events for agent sessions from the event feed
- Still updates the agent tree so status is visible there
- Still shows create/complete/fail/delete events for agents

The filtering happens in addEvent() in the TUI feed model. Agent session
updates are identified by parsing the bead ID pattern and checking for
known agent roles (mayor, deacon, witness, refinery, crew, polecat).

Resolves: gt-sb6m4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 23:42:57 -08:00

1048 lines
25 KiB
Go

package beads
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestNew verifies the constructor.
func TestNew(t *testing.T) {
b := New("/some/path")
if b == nil {
t.Fatal("New returned nil")
}
if b.workDir != "/some/path" {
t.Errorf("workDir = %q, want /some/path", b.workDir)
}
}
// TestListOptions verifies ListOptions defaults.
func TestListOptions(t *testing.T) {
opts := ListOptions{
Status: "open",
Type: "task",
Priority: 1,
}
if opts.Status != "open" {
t.Errorf("Status = %q, want open", opts.Status)
}
}
// TestCreateOptions verifies CreateOptions fields.
func TestCreateOptions(t *testing.T) {
opts := CreateOptions{
Title: "Test issue",
Type: "task",
Priority: 2,
Description: "A test description",
Parent: "gt-abc",
}
if opts.Title != "Test issue" {
t.Errorf("Title = %q, want 'Test issue'", opts.Title)
}
if opts.Parent != "gt-abc" {
t.Errorf("Parent = %q, want gt-abc", opts.Parent)
}
}
// TestUpdateOptions verifies UpdateOptions pointer fields.
func TestUpdateOptions(t *testing.T) {
status := "in_progress"
priority := 1
opts := UpdateOptions{
Status: &status,
Priority: &priority,
}
if *opts.Status != "in_progress" {
t.Errorf("Status = %q, want in_progress", *opts.Status)
}
if *opts.Priority != 1 {
t.Errorf("Priority = %d, want 1", *opts.Priority)
}
}
// TestIsBeadsRepo tests repository detection.
func TestIsBeadsRepo(t *testing.T) {
// Test with a non-beads directory
tmpDir, err := os.MkdirTemp("", "beads-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
b := New(tmpDir)
// This should return false since there's no .beads directory
// and bd list will fail
if b.IsBeadsRepo() {
// This might pass if bd handles missing .beads gracefully
t.Log("IsBeadsRepo returned true for non-beads directory (bd might initialize)")
}
}
// TestWrapError tests error wrapping.
func TestWrapError(t *testing.T) {
b := New("/test")
tests := []struct {
stderr string
wantErr error
wantNil bool
}{
{"not a beads repository", ErrNotARepo, false},
{"No .beads directory found", ErrNotARepo, false},
{".beads directory not found", ErrNotARepo, false},
{"sync conflict detected", ErrSyncConflict, false},
{"CONFLICT in file.md", ErrSyncConflict, false},
{"Issue not found: gt-xyz", ErrNotFound, false},
{"gt-xyz not found", ErrNotFound, false},
}
for _, tt := range tests {
err := b.wrapError(nil, tt.stderr, []string{"test"})
if tt.wantNil {
if err != nil {
t.Errorf("wrapError(%q) = %v, want nil", tt.stderr, err)
}
} else {
if err != tt.wantErr {
t.Errorf("wrapError(%q) = %v, want %v", tt.stderr, err, tt.wantErr)
}
}
}
}
// Integration test that runs against real bd if available
func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Find a beads repo (use current directory if it has .beads)
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
// Walk up to find .beads
dir := cwd
for {
if _, err := os.Stat(filepath.Join(dir, ".beads")); err == nil {
break
}
parent := filepath.Dir(dir)
if parent == dir {
t.Skip("no .beads directory found in path")
}
dir = parent
}
b := New(dir)
// Test List
t.Run("List", func(t *testing.T) {
issues, err := b.List(ListOptions{Status: "open"})
if err != nil {
t.Fatalf("List failed: %v", err)
}
t.Logf("Found %d open issues", len(issues))
})
// Test Ready
t.Run("Ready", func(t *testing.T) {
issues, err := b.Ready()
if err != nil {
t.Fatalf("Ready failed: %v", err)
}
t.Logf("Found %d ready issues", len(issues))
})
// Test Blocked
t.Run("Blocked", func(t *testing.T) {
issues, err := b.Blocked()
if err != nil {
t.Fatalf("Blocked failed: %v", err)
}
t.Logf("Found %d blocked issues", len(issues))
})
// Test Show (if we have issues)
t.Run("Show", func(t *testing.T) {
issues, err := b.List(ListOptions{})
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(issues) == 0 {
t.Skip("no issues to show")
}
issue, err := b.Show(issues[0].ID)
if err != nil {
t.Fatalf("Show(%s) failed: %v", issues[0].ID, err)
}
t.Logf("Showed issue: %s - %s", issue.ID, issue.Title)
})
}
// TestParseMRFields tests parsing MR fields from issue descriptions.
func TestParseMRFields(t *testing.T) {
tests := []struct {
name string
issue *Issue
wantNil bool
wantFields *MRFields
}{
{
name: "nil issue",
issue: nil,
wantNil: true,
},
{
name: "empty description",
issue: &Issue{Description: ""},
wantNil: true,
},
{
name: "no MR fields",
issue: &Issue{Description: "This is just plain text\nwith no field markers"},
wantNil: true,
},
{
name: "all fields",
issue: &Issue{
Description: `branch: polecat/Nux/gt-xyz
target: main
source_issue: gt-xyz
worker: Nux
rig: gastown
merge_commit: abc123def
close_reason: merged`,
},
wantFields: &MRFields{
Branch: "polecat/Nux/gt-xyz",
Target: "main",
SourceIssue: "gt-xyz",
Worker: "Nux",
Rig: "gastown",
MergeCommit: "abc123def",
CloseReason: "merged",
},
},
{
name: "partial fields",
issue: &Issue{
Description: `branch: polecat/Toast/gt-abc
target: integration/gt-epic
source_issue: gt-abc
worker: Toast`,
},
wantFields: &MRFields{
Branch: "polecat/Toast/gt-abc",
Target: "integration/gt-epic",
SourceIssue: "gt-abc",
Worker: "Toast",
},
},
{
name: "mixed with prose",
issue: &Issue{
Description: `branch: polecat/Capable/gt-def
target: main
source_issue: gt-def
This MR fixes a critical bug in the authentication system.
Please review carefully.
worker: Capable
rig: wasteland`,
},
wantFields: &MRFields{
Branch: "polecat/Capable/gt-def",
Target: "main",
SourceIssue: "gt-def",
Worker: "Capable",
Rig: "wasteland",
},
},
{
name: "alternate key formats",
issue: &Issue{
Description: `branch: polecat/Max/gt-ghi
source-issue: gt-ghi
merge-commit: 789xyz`,
},
wantFields: &MRFields{
Branch: "polecat/Max/gt-ghi",
SourceIssue: "gt-ghi",
MergeCommit: "789xyz",
},
},
{
name: "case insensitive keys",
issue: &Issue{
Description: `Branch: polecat/Furiosa/gt-jkl
TARGET: main
Worker: Furiosa
RIG: gastown`,
},
wantFields: &MRFields{
Branch: "polecat/Furiosa/gt-jkl",
Target: "main",
Worker: "Furiosa",
Rig: "gastown",
},
},
{
name: "extra whitespace",
issue: &Issue{
Description: ` branch: polecat/Nux/gt-mno
target:main
worker: Nux `,
},
wantFields: &MRFields{
Branch: "polecat/Nux/gt-mno",
Target: "main",
Worker: "Nux",
},
},
{
name: "ignores empty values",
issue: &Issue{
Description: `branch: polecat/Nux/gt-pqr
target:
source_issue: gt-pqr`,
},
wantFields: &MRFields{
Branch: "polecat/Nux/gt-pqr",
SourceIssue: "gt-pqr",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fields := ParseMRFields(tt.issue)
if tt.wantNil {
if fields != nil {
t.Errorf("ParseMRFields() = %+v, want nil", fields)
}
return
}
if fields == nil {
t.Fatal("ParseMRFields() = nil, want non-nil")
}
if fields.Branch != tt.wantFields.Branch {
t.Errorf("Branch = %q, want %q", fields.Branch, tt.wantFields.Branch)
}
if fields.Target != tt.wantFields.Target {
t.Errorf("Target = %q, want %q", fields.Target, tt.wantFields.Target)
}
if fields.SourceIssue != tt.wantFields.SourceIssue {
t.Errorf("SourceIssue = %q, want %q", fields.SourceIssue, tt.wantFields.SourceIssue)
}
if fields.Worker != tt.wantFields.Worker {
t.Errorf("Worker = %q, want %q", fields.Worker, tt.wantFields.Worker)
}
if fields.Rig != tt.wantFields.Rig {
t.Errorf("Rig = %q, want %q", fields.Rig, tt.wantFields.Rig)
}
if fields.MergeCommit != tt.wantFields.MergeCommit {
t.Errorf("MergeCommit = %q, want %q", fields.MergeCommit, tt.wantFields.MergeCommit)
}
if fields.CloseReason != tt.wantFields.CloseReason {
t.Errorf("CloseReason = %q, want %q", fields.CloseReason, tt.wantFields.CloseReason)
}
})
}
}
// TestFormatMRFields tests formatting MR fields to string.
func TestFormatMRFields(t *testing.T) {
tests := []struct {
name string
fields *MRFields
want string
}{
{
name: "nil fields",
fields: nil,
want: "",
},
{
name: "empty fields",
fields: &MRFields{},
want: "",
},
{
name: "all fields",
fields: &MRFields{
Branch: "polecat/Nux/gt-xyz",
Target: "main",
SourceIssue: "gt-xyz",
Worker: "Nux",
Rig: "gastown",
MergeCommit: "abc123def",
CloseReason: "merged",
},
want: `branch: polecat/Nux/gt-xyz
target: main
source_issue: gt-xyz
worker: Nux
rig: gastown
merge_commit: abc123def
close_reason: merged`,
},
{
name: "partial fields",
fields: &MRFields{
Branch: "polecat/Toast/gt-abc",
Target: "main",
SourceIssue: "gt-abc",
Worker: "Toast",
},
want: `branch: polecat/Toast/gt-abc
target: main
source_issue: gt-abc
worker: Toast`,
},
{
name: "only close fields",
fields: &MRFields{
MergeCommit: "deadbeef",
CloseReason: "rejected",
},
want: `merge_commit: deadbeef
close_reason: rejected`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatMRFields(tt.fields)
if got != tt.want {
t.Errorf("FormatMRFields() =\n%q\nwant\n%q", got, tt.want)
}
})
}
}
// TestSetMRFields tests updating issue descriptions with MR fields.
func TestSetMRFields(t *testing.T) {
tests := []struct {
name string
issue *Issue
fields *MRFields
want string
}{
{
name: "nil issue",
issue: nil,
fields: &MRFields{
Branch: "polecat/Nux/gt-xyz",
Target: "main",
},
want: `branch: polecat/Nux/gt-xyz
target: main`,
},
{
name: "empty description",
issue: &Issue{Description: ""},
fields: &MRFields{
Branch: "polecat/Nux/gt-xyz",
Target: "main",
SourceIssue: "gt-xyz",
},
want: `branch: polecat/Nux/gt-xyz
target: main
source_issue: gt-xyz`,
},
{
name: "preserve prose content",
issue: &Issue{Description: "This is a description of the work.\n\nIt spans multiple lines."},
fields: &MRFields{
Branch: "polecat/Toast/gt-abc",
Worker: "Toast",
},
want: `branch: polecat/Toast/gt-abc
worker: Toast
This is a description of the work.
It spans multiple lines.`,
},
{
name: "replace existing fields",
issue: &Issue{
Description: `branch: polecat/Nux/gt-old
target: develop
source_issue: gt-old
worker: Nux
Some existing prose content.`,
},
fields: &MRFields{
Branch: "polecat/Nux/gt-new",
Target: "main",
SourceIssue: "gt-new",
Worker: "Nux",
MergeCommit: "abc123",
},
want: `branch: polecat/Nux/gt-new
target: main
source_issue: gt-new
worker: Nux
merge_commit: abc123
Some existing prose content.`,
},
{
name: "preserve non-MR key-value lines",
issue: &Issue{
Description: `branch: polecat/Capable/gt-def
custom_field: some value
author: someone
target: main`,
},
fields: &MRFields{
Branch: "polecat/Capable/gt-ghi",
Target: "integration/epic",
CloseReason: "merged",
},
want: `branch: polecat/Capable/gt-ghi
target: integration/epic
close_reason: merged
custom_field: some value
author: someone`,
},
{
name: "empty fields clears MR data",
issue: &Issue{Description: "branch: old\ntarget: old\n\nKeep this text."},
fields: &MRFields{},
want: "Keep this text.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SetMRFields(tt.issue, tt.fields)
if got != tt.want {
t.Errorf("SetMRFields() =\n%q\nwant\n%q", got, tt.want)
}
})
}
}
// TestMRFieldsRoundTrip tests that parse/format round-trips correctly.
func TestMRFieldsRoundTrip(t *testing.T) {
original := &MRFields{
Branch: "polecat/Nux/gt-xyz",
Target: "main",
SourceIssue: "gt-xyz",
Worker: "Nux",
Rig: "gastown",
MergeCommit: "abc123def789",
CloseReason: "merged",
}
// Format to string
formatted := FormatMRFields(original)
// Parse back
issue := &Issue{Description: formatted}
parsed := ParseMRFields(issue)
if parsed == nil {
t.Fatal("round-trip parse returned nil")
}
if *parsed != *original {
t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original)
}
}
// TestParseMRFieldsFromDesignDoc tests the example from the design doc.
func TestParseMRFieldsFromDesignDoc(t *testing.T) {
// Example from docs/merge-queue-design.md
description := `branch: polecat/Nux/gt-xyz
target: main
source_issue: gt-xyz
worker: Nux
rig: gastown`
issue := &Issue{Description: description}
fields := ParseMRFields(issue)
if fields == nil {
t.Fatal("ParseMRFields returned nil for design doc example")
}
// Verify all fields match the design doc
if fields.Branch != "polecat/Nux/gt-xyz" {
t.Errorf("Branch = %q, want polecat/Nux/gt-xyz", fields.Branch)
}
if fields.Target != "main" {
t.Errorf("Target = %q, want main", fields.Target)
}
if fields.SourceIssue != "gt-xyz" {
t.Errorf("SourceIssue = %q, want gt-xyz", fields.SourceIssue)
}
if fields.Worker != "Nux" {
t.Errorf("Worker = %q, want Nux", fields.Worker)
}
if fields.Rig != "gastown" {
t.Errorf("Rig = %q, want gastown", fields.Rig)
}
}
// TestSetMRFieldsPreservesURL tests that URLs in prose are preserved.
func TestSetMRFieldsPreservesURL(t *testing.T) {
// URLs contain colons which could be confused with key: value
issue := &Issue{
Description: `branch: old-branch
Check out https://example.com/path for more info.
Also see http://localhost:8080/api`,
}
fields := &MRFields{
Branch: "new-branch",
Target: "main",
}
result := SetMRFields(issue, fields)
// URLs should be preserved
if !strings.Contains(result, "https://example.com/path") {
t.Error("HTTPS URL was not preserved")
}
if !strings.Contains(result, "http://localhost:8080/api") {
t.Error("HTTP URL was not preserved")
}
if !strings.Contains(result, "branch: new-branch") {
t.Error("branch field was not set")
}
}
// TestParseAttachmentFields tests parsing attachment fields from issue descriptions.
func TestParseAttachmentFields(t *testing.T) {
tests := []struct {
name string
issue *Issue
wantNil bool
wantFields *AttachmentFields
}{
{
name: "nil issue",
issue: nil,
wantNil: true,
},
{
name: "empty description",
issue: &Issue{Description: ""},
wantNil: true,
},
{
name: "no attachment fields",
issue: &Issue{Description: "This is just plain text\nwith no attachment markers"},
wantNil: true,
},
{
name: "both fields",
issue: &Issue{
Description: `attached_molecule: mol-xyz
attached_at: 2025-12-21T15:30:00Z`,
},
wantFields: &AttachmentFields{
AttachedMolecule: "mol-xyz",
AttachedAt: "2025-12-21T15:30:00Z",
},
},
{
name: "only molecule",
issue: &Issue{
Description: `attached_molecule: mol-abc`,
},
wantFields: &AttachmentFields{
AttachedMolecule: "mol-abc",
},
},
{
name: "mixed with other content",
issue: &Issue{
Description: `attached_molecule: mol-def
attached_at: 2025-12-21T10:00:00Z
This is a handoff bead for the polecat.
Keep working on the current task.`,
},
wantFields: &AttachmentFields{
AttachedMolecule: "mol-def",
AttachedAt: "2025-12-21T10:00:00Z",
},
},
{
name: "alternate key formats (hyphen)",
issue: &Issue{
Description: `attached-molecule: mol-ghi
attached-at: 2025-12-21T12:00:00Z`,
},
wantFields: &AttachmentFields{
AttachedMolecule: "mol-ghi",
AttachedAt: "2025-12-21T12:00:00Z",
},
},
{
name: "case insensitive",
issue: &Issue{
Description: `Attached_Molecule: mol-jkl
ATTACHED_AT: 2025-12-21T14:00:00Z`,
},
wantFields: &AttachmentFields{
AttachedMolecule: "mol-jkl",
AttachedAt: "2025-12-21T14:00:00Z",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fields := ParseAttachmentFields(tt.issue)
if tt.wantNil {
if fields != nil {
t.Errorf("ParseAttachmentFields() = %+v, want nil", fields)
}
return
}
if fields == nil {
t.Fatal("ParseAttachmentFields() = nil, want non-nil")
}
if fields.AttachedMolecule != tt.wantFields.AttachedMolecule {
t.Errorf("AttachedMolecule = %q, want %q", fields.AttachedMolecule, tt.wantFields.AttachedMolecule)
}
if fields.AttachedAt != tt.wantFields.AttachedAt {
t.Errorf("AttachedAt = %q, want %q", fields.AttachedAt, tt.wantFields.AttachedAt)
}
})
}
}
// TestFormatAttachmentFields tests formatting attachment fields to string.
func TestFormatAttachmentFields(t *testing.T) {
tests := []struct {
name string
fields *AttachmentFields
want string
}{
{
name: "nil fields",
fields: nil,
want: "",
},
{
name: "empty fields",
fields: &AttachmentFields{},
want: "",
},
{
name: "both fields",
fields: &AttachmentFields{
AttachedMolecule: "mol-xyz",
AttachedAt: "2025-12-21T15:30:00Z",
},
want: `attached_molecule: mol-xyz
attached_at: 2025-12-21T15:30:00Z`,
},
{
name: "only molecule",
fields: &AttachmentFields{
AttachedMolecule: "mol-abc",
},
want: "attached_molecule: mol-abc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatAttachmentFields(tt.fields)
if got != tt.want {
t.Errorf("FormatAttachmentFields() =\n%q\nwant\n%q", got, tt.want)
}
})
}
}
// TestSetAttachmentFields tests updating issue descriptions with attachment fields.
func TestSetAttachmentFields(t *testing.T) {
tests := []struct {
name string
issue *Issue
fields *AttachmentFields
want string
}{
{
name: "nil issue",
issue: nil,
fields: &AttachmentFields{
AttachedMolecule: "mol-xyz",
AttachedAt: "2025-12-21T15:30:00Z",
},
want: `attached_molecule: mol-xyz
attached_at: 2025-12-21T15:30:00Z`,
},
{
name: "empty description",
issue: &Issue{Description: ""},
fields: &AttachmentFields{
AttachedMolecule: "mol-abc",
AttachedAt: "2025-12-21T10:00:00Z",
},
want: `attached_molecule: mol-abc
attached_at: 2025-12-21T10:00:00Z`,
},
{
name: "preserve prose content",
issue: &Issue{Description: "This is a handoff bead description.\n\nKeep working on the task."},
fields: &AttachmentFields{
AttachedMolecule: "mol-def",
},
want: `attached_molecule: mol-def
This is a handoff bead description.
Keep working on the task.`,
},
{
name: "replace existing fields",
issue: &Issue{
Description: `attached_molecule: mol-old
attached_at: 2025-12-20T10:00:00Z
Some existing prose content.`,
},
fields: &AttachmentFields{
AttachedMolecule: "mol-new",
AttachedAt: "2025-12-21T15:30:00Z",
},
want: `attached_molecule: mol-new
attached_at: 2025-12-21T15:30:00Z
Some existing prose content.`,
},
{
name: "nil fields clears attachment",
issue: &Issue{Description: "attached_molecule: mol-old\nattached_at: 2025-12-20T10:00:00Z\n\nKeep this text."},
fields: nil,
want: "Keep this text.",
},
{
name: "empty fields clears attachment",
issue: &Issue{Description: "attached_molecule: mol-old\n\nKeep this text."},
fields: &AttachmentFields{},
want: "Keep this text.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SetAttachmentFields(tt.issue, tt.fields)
if got != tt.want {
t.Errorf("SetAttachmentFields() =\n%q\nwant\n%q", got, tt.want)
}
})
}
}
// TestAttachmentFieldsRoundTrip tests that parse/format round-trips correctly.
func TestAttachmentFieldsRoundTrip(t *testing.T) {
original := &AttachmentFields{
AttachedMolecule: "mol-roundtrip",
AttachedAt: "2025-12-21T15:30:00Z",
}
// Format to string
formatted := FormatAttachmentFields(original)
// Parse back
issue := &Issue{Description: formatted}
parsed := ParseAttachmentFields(issue)
if parsed == nil {
t.Fatal("round-trip parse returned nil")
}
if *parsed != *original {
t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original)
}
}
// TestResolveBeadsDir tests the redirect following logic.
func TestResolveBeadsDir(t *testing.T) {
// Create temp directory structure
tmpDir, err := os.MkdirTemp("", "beads-redirect-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
t.Run("no redirect", func(t *testing.T) {
// Create a simple .beads directory without redirect
workDir := filepath.Join(tmpDir, "no-redirect")
beadsDir := filepath.Join(workDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
got := ResolveBeadsDir(workDir)
want := beadsDir
if got != want {
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
}
})
t.Run("with redirect", func(t *testing.T) {
// Create structure like: crew/max/.beads/redirect -> ../../mayor/rig/.beads
workDir := filepath.Join(tmpDir, "crew", "max")
localBeadsDir := filepath.Join(workDir, ".beads")
targetBeadsDir := filepath.Join(tmpDir, "mayor", "rig", ".beads")
// Create both directories
if err := os.MkdirAll(localBeadsDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(targetBeadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create redirect file
redirectPath := filepath.Join(localBeadsDir, "redirect")
if err := os.WriteFile(redirectPath, []byte("../../mayor/rig/.beads\n"), 0644); err != nil {
t.Fatal(err)
}
got := ResolveBeadsDir(workDir)
want := targetBeadsDir
if got != want {
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
}
})
t.Run("no beads directory", func(t *testing.T) {
// Directory with no .beads at all
workDir := filepath.Join(tmpDir, "empty")
if err := os.MkdirAll(workDir, 0755); err != nil {
t.Fatal(err)
}
got := ResolveBeadsDir(workDir)
want := filepath.Join(workDir, ".beads")
if got != want {
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
}
})
t.Run("empty redirect file", func(t *testing.T) {
// Redirect file exists but is empty - should fall back to local
workDir := filepath.Join(tmpDir, "empty-redirect")
beadsDir := filepath.Join(workDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
redirectPath := filepath.Join(beadsDir, "redirect")
if err := os.WriteFile(redirectPath, []byte(" \n"), 0644); err != nil {
t.Fatal(err)
}
got := ResolveBeadsDir(workDir)
want := beadsDir
if got != want {
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
}
})
}
func TestParseAgentBeadID(t *testing.T) {
tests := []struct {
input string
wantRig string
wantRole string
wantName string
wantOK bool
}{
// Town-level agents
{"gt-mayor", "", "mayor", "", true},
{"gt-deacon", "", "deacon", "", true},
// Rig-level singletons
{"gt-gastown-witness", "gastown", "witness", "", true},
{"gt-gastown-refinery", "gastown", "refinery", "", true},
// Rig-level named agents
{"gt-gastown-crew-joe", "gastown", "crew", "joe", true},
{"gt-gastown-crew-max", "gastown", "crew", "max", true},
{"gt-gastown-polecat-capable", "gastown", "polecat", "capable", true},
// Names with hyphens
{"gt-gastown-polecat-my-agent", "gastown", "polecat", "my-agent", true},
// Parseable but not valid agent roles (IsAgentSessionBead will reject)
{"gt-abc123", "", "abc123", "", true}, // Parses as town-level but not valid role
// Truly invalid patterns
{"bd-gastown-crew-joe", "", "", "", false}, // Wrong prefix
{"", "", "", "", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
rig, role, name, ok := ParseAgentBeadID(tt.input)
if ok != tt.wantOK {
t.Errorf("ParseAgentBeadID(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
return
}
if rig != tt.wantRig {
t.Errorf("ParseAgentBeadID(%q) rig = %q, want %q", tt.input, rig, tt.wantRig)
}
if role != tt.wantRole {
t.Errorf("ParseAgentBeadID(%q) role = %q, want %q", tt.input, role, tt.wantRole)
}
if name != tt.wantName {
t.Errorf("ParseAgentBeadID(%q) name = %q, want %q", tt.input, name, tt.wantName)
}
})
}
}
func TestIsAgentSessionBead(t *testing.T) {
tests := []struct {
beadID string
want bool
}{
// Agent session beads (should return true)
{"gt-mayor", true},
{"gt-deacon", true},
{"gt-gastown-witness", true},
{"gt-gastown-refinery", true},
{"gt-gastown-crew-joe", true},
{"gt-gastown-polecat-capable", true},
// Regular work beads (should return false)
{"gt-abc123", false},
{"gt-sb6m4", false},
{"gt-u7dxq", false},
// Invalid beads
{"bd-abc123", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.beadID, func(t *testing.T) {
got := IsAgentSessionBead(tt.beadID)
if got != tt.want {
t.Errorf("IsAgentSessionBead(%q) = %v, want %v", tt.beadID, got, tt.want)
}
})
}
}