Files
gastown/internal/cmd/hook_slot_integration_test.go
nux c66dc4594c fix(hook): enable cross-prefix bead hooking with database routing
The `gt hook` command was failing to persist hooks for beads from different
prefixes (e.g., hooking an hq-* bead from a rig worker). The issue was that
`runHook` used `b.Update()` which always writes to the local beads database,
regardless of the bead's prefix.

Changes:
- Use `beads.ResolveHookDir()` to determine the correct database directory
  for the bead being hooked, based on its prefix
- Execute `bd update` with the correct working directory for cross-prefix routing
- Call `updateAgentHookBead()` to set the agent's hook_bead slot, enabling
  `gt hook status` to find cross-prefix hooked beads
- Add integration test for cross-prefix hooking scenarios

This matches the pattern already used by `gt sling` for cross-prefix dispatch.

Fixes: gt-rphsv

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 12:18:43 -08:00

606 lines
16 KiB
Go

//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")
}
}