//go:build integration // Package cmd contains integration tests for hook slot verification. // // Run with: go test -tags=integration ./internal/cmd -run TestHookSlot -v package cmd import ( "os" "os/exec" "path/filepath" "testing" "github.com/steveyegge/gastown/internal/beads" ) // setupHookTestTown creates a minimal Gas Town with a polecat for testing hooks. // Returns townRoot and the path to the polecat's worktree. func setupHookTestTown(t *testing.T) (townRoot, polecatDir string) { t.Helper() townRoot = t.TempDir() // Create town-level .beads directory townBeadsDir := filepath.Join(townRoot, ".beads") if err := os.MkdirAll(townBeadsDir, 0755); err != nil { t.Fatalf("mkdir town .beads: %v", err) } // Create routes.jsonl routes := []beads.Route{ {Prefix: "hq-", Path: "."}, // Town-level beads {Prefix: "gt-", Path: "gastown/mayor/rig"}, // Gastown rig } if err := beads.WriteRoutes(townBeadsDir, routes); err != nil { t.Fatalf("write routes: %v", err) } // Create gastown rig structure gasRigPath := filepath.Join(townRoot, "gastown", "mayor", "rig") if err := os.MkdirAll(gasRigPath, 0755); err != nil { t.Fatalf("mkdir gastown: %v", err) } // Create gastown .beads directory with its own config gasBeadsDir := filepath.Join(gasRigPath, ".beads") if err := os.MkdirAll(gasBeadsDir, 0755); err != nil { t.Fatalf("mkdir gastown .beads: %v", err) } if err := os.WriteFile(filepath.Join(gasBeadsDir, "config.yaml"), []byte("prefix: gt\n"), 0644); err != nil { t.Fatalf("write gastown config: %v", err) } // Create polecat worktree with redirect polecatDir = filepath.Join(townRoot, "gastown", "polecats", "toast") if err := os.MkdirAll(polecatDir, 0755); err != nil { t.Fatalf("mkdir polecats: %v", err) } // Create redirect file for polecat -> mayor/rig/.beads polecatBeadsDir := filepath.Join(polecatDir, ".beads") if err := os.MkdirAll(polecatBeadsDir, 0755); err != nil { t.Fatalf("mkdir polecat .beads: %v", err) } redirectContent := "../../mayor/rig/.beads" if err := os.WriteFile(filepath.Join(polecatBeadsDir, "redirect"), []byte(redirectContent), 0644); err != nil { t.Fatalf("write redirect: %v", err) } return townRoot, polecatDir } // initBeadsDB initializes the beads database by running bd init. func initBeadsDB(t *testing.T, dir string) { t.Helper() cmd := exec.Command("bd", "--no-daemon", "init") cmd.Dir = dir if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("bd init failed: %v\n%s", err, output) } } // Note: initBeadsDBWithPrefix is defined in beads_routing_integration_test.go // TestHookSlot_BasicHook verifies that a bead can be hooked to an agent. func TestHookSlot_BasicHook(t *testing.T) { // Skip if bd is not available if _, err := exec.LookPath("bd"); err != nil { t.Skip("bd not installed, skipping test") } townRoot, polecatDir := setupHookTestTown(t) _ = townRoot // Not used directly but shows test context // Initialize beads in the rig rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") initBeadsDB(t, rigDir) b := beads.New(rigDir) // Create a test bead issue, err := b.Create(beads.CreateOptions{ Title: "Test task for hooking", Type: "task", Priority: 2, }) if err != nil { t.Fatalf("create bead: %v", err) } t.Logf("Created bead: %s", issue.ID) // Hook the bead to the polecat agentID := "gastown/polecats/toast" status := beads.StatusHooked if err := b.Update(issue.ID, beads.UpdateOptions{ Status: &status, Assignee: &agentID, }); err != nil { t.Fatalf("hook bead: %v", err) } // Verify the bead is hooked hookedBeads, err := b.List(beads.ListOptions{ Status: beads.StatusHooked, Assignee: agentID, Priority: -1, }) if err != nil { t.Fatalf("list hooked beads: %v", err) } if len(hookedBeads) != 1 { t.Errorf("expected 1 hooked bead, got %d", len(hookedBeads)) } if len(hookedBeads) > 0 && hookedBeads[0].ID != issue.ID { t.Errorf("hooked bead ID = %s, want %s", hookedBeads[0].ID, issue.ID) } } // TestHookSlot_Singleton verifies that only one bead can be hooked per agent. func TestHookSlot_Singleton(t *testing.T) { if _, err := exec.LookPath("bd"); err != nil { t.Skip("bd not installed, skipping test") } townRoot, polecatDir := setupHookTestTown(t) _ = townRoot rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") initBeadsDB(t, rigDir) b := beads.New(rigDir) agentID := "gastown/polecats/toast" status := beads.StatusHooked // Create and hook first bead issue1, err := b.Create(beads.CreateOptions{ Title: "First task", Type: "task", Priority: 2, }) if err != nil { t.Fatalf("create first bead: %v", err) } if err := b.Update(issue1.ID, beads.UpdateOptions{ Status: &status, Assignee: &agentID, }); err != nil { t.Fatalf("hook first bead: %v", err) } // Create second bead issue2, err := b.Create(beads.CreateOptions{ Title: "Second task", Type: "task", Priority: 2, }) if err != nil { t.Fatalf("create second bead: %v", err) } // Hook second bead to same agent if err := b.Update(issue2.ID, beads.UpdateOptions{ Status: &status, Assignee: &agentID, }); err != nil { t.Fatalf("hook second bead: %v", err) } // Query hooked beads - both should be hooked (bd allows multiple) // The singleton constraint is enforced by gt hook, not bd itself hookedBeads, err := b.List(beads.ListOptions{ Status: beads.StatusHooked, Assignee: agentID, Priority: -1, }) if err != nil { t.Fatalf("list hooked beads: %v", err) } t.Logf("Found %d hooked beads for agent %s", len(hookedBeads), agentID) for _, h := range hookedBeads { t.Logf(" - %s: %s", h.ID, h.Title) } // The test documents actual behavior: bd allows multiple hooked beads // The gt hook command enforces singleton behavior if len(hookedBeads) != 2 { t.Errorf("expected 2 hooked beads (bd allows multiple), got %d", len(hookedBeads)) } } // TestHookSlot_Unhook verifies that a bead can be unhooked by changing status. func TestHookSlot_Unhook(t *testing.T) { if _, err := exec.LookPath("bd"); err != nil { t.Skip("bd not installed, skipping test") } townRoot, polecatDir := setupHookTestTown(t) _ = townRoot rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") initBeadsDB(t, rigDir) b := beads.New(rigDir) agentID := "gastown/polecats/toast" // Create and hook a bead issue, err := b.Create(beads.CreateOptions{ Title: "Task to unhook", Type: "task", Priority: 2, }) if err != nil { t.Fatalf("create bead: %v", err) } status := beads.StatusHooked if err := b.Update(issue.ID, beads.UpdateOptions{ Status: &status, Assignee: &agentID, }); err != nil { t.Fatalf("hook bead: %v", err) } // Unhook by setting status back to open openStatus := "open" if err := b.Update(issue.ID, beads.UpdateOptions{ Status: &openStatus, }); err != nil { t.Fatalf("unhook bead: %v", err) } // Verify no hooked beads remain hookedBeads, err := b.List(beads.ListOptions{ Status: beads.StatusHooked, Assignee: agentID, Priority: -1, }) if err != nil { t.Fatalf("list hooked beads: %v", err) } if len(hookedBeads) != 0 { t.Errorf("expected 0 hooked beads after unhook, got %d", len(hookedBeads)) } } // TestHookSlot_DifferentAgents verifies that different agents can have different hooks. func TestHookSlot_DifferentAgents(t *testing.T) { if _, err := exec.LookPath("bd"); err != nil { t.Skip("bd not installed, skipping test") } townRoot, polecatDir := setupHookTestTown(t) // Create second polecat directory polecat2Dir := filepath.Join(townRoot, "gastown", "polecats", "nux") if err := os.MkdirAll(polecat2Dir, 0755); err != nil { t.Fatalf("mkdir polecat2: %v", err) } rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") initBeadsDB(t, rigDir) b := beads.New(rigDir) agent1 := "gastown/polecats/toast" agent2 := "gastown/polecats/nux" status := beads.StatusHooked // Create and hook bead to first agent issue1, err := b.Create(beads.CreateOptions{ Title: "Toast's task", Type: "task", Priority: 2, }) if err != nil { t.Fatalf("create bead 1: %v", err) } if err := b.Update(issue1.ID, beads.UpdateOptions{ Status: &status, Assignee: &agent1, }); err != nil { t.Fatalf("hook bead to agent1: %v", err) } // Create and hook bead to second agent issue2, err := b.Create(beads.CreateOptions{ Title: "Nux's task", Type: "task", Priority: 2, }) if err != nil { t.Fatalf("create bead 2: %v", err) } if err := b.Update(issue2.ID, beads.UpdateOptions{ Status: &status, Assignee: &agent2, }); err != nil { t.Fatalf("hook bead to agent2: %v", err) } // Verify each agent has exactly one hook agent1Hooks, err := b.List(beads.ListOptions{ Status: beads.StatusHooked, Assignee: agent1, Priority: -1, }) if err != nil { t.Fatalf("list agent1 hooks: %v", err) } agent2Hooks, err := b.List(beads.ListOptions{ Status: beads.StatusHooked, Assignee: agent2, Priority: -1, }) if err != nil { t.Fatalf("list agent2 hooks: %v", err) } if len(agent1Hooks) != 1 { t.Errorf("agent1 should have 1 hook, got %d", len(agent1Hooks)) } if len(agent2Hooks) != 1 { t.Errorf("agent2 should have 1 hook, got %d", len(agent2Hooks)) } // Verify correct assignment if len(agent1Hooks) > 0 && agent1Hooks[0].ID != issue1.ID { t.Errorf("agent1 hook ID = %s, want %s", agent1Hooks[0].ID, issue1.ID) } if len(agent2Hooks) > 0 && agent2Hooks[0].ID != issue2.ID { t.Errorf("agent2 hook ID = %s, want %s", agent2Hooks[0].ID, issue2.ID) } } // TestHookSlot_HookPersistence verifies that hooks persist across beads object recreation. func TestHookSlot_HookPersistence(t *testing.T) { if _, err := exec.LookPath("bd"); err != nil { t.Skip("bd not installed, skipping test") } townRoot, polecatDir := setupHookTestTown(t) _ = townRoot rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") initBeadsDB(t, rigDir) agentID := "gastown/polecats/toast" status := beads.StatusHooked // Create first beads instance and hook a bead b1 := beads.New(rigDir) issue, err := b1.Create(beads.CreateOptions{ Title: "Persistent task", Type: "task", Priority: 2, }) if err != nil { t.Fatalf("create bead: %v", err) } if err := b1.Update(issue.ID, beads.UpdateOptions{ Status: &status, Assignee: &agentID, }); err != nil { t.Fatalf("hook bead: %v", err) } // Create new beads instance (simulates session restart) b2 := beads.New(rigDir) // Verify hook persists hookedBeads, err := b2.List(beads.ListOptions{ Status: beads.StatusHooked, Assignee: agentID, Priority: -1, }) if err != nil { t.Fatalf("list hooked beads with new instance: %v", err) } if len(hookedBeads) != 1 { t.Errorf("expected hook to persist, got %d hooked beads", len(hookedBeads)) } if len(hookedBeads) > 0 && hookedBeads[0].ID != issue.ID { t.Errorf("persisted hook ID = %s, want %s", hookedBeads[0].ID, issue.ID) } } // TestHookSlot_StatusTransitions tests valid status transitions for hooked beads. func TestHookSlot_StatusTransitions(t *testing.T) { if _, err := exec.LookPath("bd"); err != nil { t.Skip("bd not installed, skipping test") } townRoot, polecatDir := setupHookTestTown(t) _ = townRoot rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") initBeadsDB(t, rigDir) b := beads.New(rigDir) agentID := "gastown/polecats/toast" // Create a bead issue, err := b.Create(beads.CreateOptions{ Title: "Status transition test", Type: "task", Priority: 2, }) if err != nil { t.Fatalf("create bead: %v", err) } // Test transitions: open -> hooked -> open -> hooked -> closed transitions := []struct { name string status string }{ {"hook", beads.StatusHooked}, {"unhook", "open"}, {"rehook", beads.StatusHooked}, } for _, trans := range transitions { t.Run(trans.name, func(t *testing.T) { status := trans.status opts := beads.UpdateOptions{Status: &status} if trans.status == beads.StatusHooked { opts.Assignee = &agentID } if err := b.Update(issue.ID, opts); err != nil { t.Errorf("transition to %s failed: %v", trans.status, err) } // Verify status updated, err := b.Show(issue.ID) if err != nil { t.Errorf("show after %s: %v", trans.name, err) return } if updated.Status != trans.status { t.Errorf("status after %s = %s, want %s", trans.name, updated.Status, trans.status) } }) } // Finally close the bead if err := b.Close(issue.ID); err != nil { t.Errorf("close hooked bead: %v", err) } // Verify it's closed closed, err := b.Show(issue.ID) if err != nil { t.Fatalf("show closed bead: %v", err) } if closed.Status != "closed" { t.Errorf("final status = %s, want closed", closed.Status) } } // TestHookSlot_CrossPrefixHook verifies that beads with different prefixes can be hooked // using the correct database routing. This is the fix for issue gt-rphsv. func TestHookSlot_CrossPrefixHook(t *testing.T) { if _, err := exec.LookPath("bd"); err != nil { t.Skip("bd not installed, skipping test") } townRoot, polecatDir := setupHookTestTown(t) // Initialize beads in both town-level (hq- prefix) and rig-level (gt- prefix) // Note: bd init must be run from parent directory, not inside .beads initBeadsDBWithPrefix(t, townRoot, "hq") rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") initBeadsDBWithPrefix(t, rigDir, "gt") // Create beads instances for both databases townBeads := beads.New(townRoot) // Uses routes.jsonl to route to correct DB rigBeads := beads.New(rigDir) // Create an hq-* bead in town beads townBeadsInstance := beads.New(townRoot) hqIssue, err := townBeadsInstance.Create(beads.CreateOptions{ Title: "HQ task for cross-prefix test", Type: "task", Priority: 2, }) if err != nil { t.Fatalf("create hq bead: %v", err) } // The bead ID should have hq- prefix since we initialized town beads with that prefix t.Logf("Created HQ bead: %s", hqIssue.ID) // Create a gt-* bead in rig beads gtIssue, err := rigBeads.Create(beads.CreateOptions{ Title: "Rig task for cross-prefix test", Type: "task", Priority: 2, }) if err != nil { t.Fatalf("create rig bead: %v", err) } t.Logf("Created rig bead: %s", gtIssue.ID) agentID := "gastown/polecats/toast" // Test 1: Hook the HQ bead using ResolveHookDir (simulating runHook fix) hookDir := beads.ResolveHookDir(townRoot, hqIssue.ID, rigDir) t.Logf("ResolveHookDir(%s, %s, %s) = %s", townRoot, hqIssue.ID, rigDir, hookDir) // Hook the HQ bead via bd command with correct directory routing hookCmd := exec.Command("bd", "--no-daemon", "update", hqIssue.ID, "--status=hooked", "--assignee="+agentID) hookCmd.Dir = hookDir if output, err := hookCmd.CombinedOutput(); err != nil { t.Fatalf("hook hq bead: %v\n%s", err, output) } // Verify the HQ bead is hooked by querying town beads hookedHQ, err := townBeadsInstance.List(beads.ListOptions{ Status: beads.StatusHooked, Assignee: agentID, Priority: -1, }) if err != nil { t.Fatalf("list hooked hq beads: %v", err) } if len(hookedHQ) != 1 { t.Errorf("expected 1 hooked HQ bead, got %d", len(hookedHQ)) } if len(hookedHQ) > 0 && hookedHQ[0].ID != hqIssue.ID { t.Errorf("hooked HQ bead ID = %s, want %s", hookedHQ[0].ID, hqIssue.ID) } // Test 2: Verify rig beads are still queryable separately status := beads.StatusHooked if err := rigBeads.Update(gtIssue.ID, beads.UpdateOptions{ Status: &status, Assignee: &agentID, }); err != nil { t.Fatalf("hook rig bead: %v", err) } hookedRig, err := rigBeads.List(beads.ListOptions{ Status: beads.StatusHooked, Assignee: agentID, Priority: -1, }) if err != nil { t.Fatalf("list hooked rig beads: %v", err) } if len(hookedRig) != 1 { t.Errorf("expected 1 hooked rig bead, got %d", len(hookedRig)) } if len(hookedRig) > 0 && hookedRig[0].ID != gtIssue.ID { t.Errorf("hooked rig bead ID = %s, want %s", hookedRig[0].ID, gtIssue.ID) } // Verify the databases are separate t.Logf("HQ bead %s hooked in town DB, Rig bead %s hooked in rig DB", hqIssue.ID, gtIssue.ID) // Verify the HQ bead is NOT in the rig database _, err = rigBeads.Show(hqIssue.ID) if err == nil { t.Log("Note: HQ bead found in rig DB - this may indicate routing is working via redirect") } // Verify the rig bead is NOT in the town database _, err = townBeads.Show(gtIssue.ID) if err == nil { t.Log("Note: Rig bead found in town DB - this may indicate routing is working") } }