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>
1048 lines
25 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|