package cmd import ( "fmt" "os" "path/filepath" "strings" "testing" ) // TestSlingFormulaOnBeadHooksBaseBead verifies that when using // "gt sling --on ", the BASE bead is hooked (not the wisp). // // Current bug: The code hooks the wisp (compound root) instead of the base bead. // This causes lifecycle issues: // - Base bead stays open after wisp completes // - gt done closes wisp, not the actual work item // - Orphaned base beads accumulate // // Expected behavior: Hook the base bead, store attached_molecule pointing to wisp. // gt hook/gt prime can follow attached_molecule to find the workflow steps. func TestSlingFormulaOnBeadHooksBaseBead(t *testing.T) { townRoot := t.TempDir() // Minimal workspace marker if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil { t.Fatalf("mkdir mayor/rig: %v", err) } // Create routes if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil { t.Fatalf("mkdir .beads: %v", err) } rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig") if err := os.MkdirAll(rigDir, 0755); err != nil { t.Fatalf("mkdir rigDir: %v", err) } routes := strings.Join([]string{ `{"prefix":"gt-","path":"gastown/mayor/rig"}`, `{"prefix":"hq-","path":"."}`, "", }, "\n") if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil { t.Fatalf("write routes.jsonl: %v", err) } // Stub bd to track which bead gets hooked binDir := filepath.Join(townRoot, "bin") if err := os.MkdirAll(binDir, 0755); err != nil { t.Fatalf("mkdir binDir: %v", err) } logPath := filepath.Join(townRoot, "bd.log") bdScript := `#!/bin/sh set -e echo "$*" >> "${BD_LOG}" if [ "$1" = "--no-daemon" ]; then shift fi if [ "$1" = "--allow-stale" ]; then shift fi cmd="$1" shift || true case "$cmd" in show) # Return the base bead info echo '[{"id":"gt-abc123","title":"Bug to fix","status":"open","assignee":"","description":""}]' ;; formula) echo '{"name":"mol-polecat-work"}' ;; cook) exit 0 ;; mol) sub="$1" shift || true case "$sub" in wisp) echo '{"new_epic_id":"gt-wisp-xyz"}' ;; bond) echo '{"root_id":"gt-wisp-xyz"}' ;; esac ;; update) # Just succeed exit 0 ;; esac exit 0 ` bdPath := filepath.Join(binDir, "bd") if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { t.Fatalf("write bd stub: %v", err) } t.Setenv("BD_LOG", logPath) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv(EnvGTRole, "mayor") t.Setenv("GT_POLECAT", "") t.Setenv("GT_CREW", "") t.Setenv("TMUX_PANE", "") t.Setenv("GT_TEST_NO_NUDGE", "1") cwd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } t.Cleanup(func() { _ = os.Chdir(cwd) }) if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil { t.Fatalf("chdir: %v", err) } // Save and restore global flag state prevOn := slingOnTarget prevVars := slingVars prevDryRun := slingDryRun prevNoConvoy := slingNoConvoy t.Cleanup(func() { slingOnTarget = prevOn slingVars = prevVars slingDryRun = prevDryRun slingNoConvoy = prevNoConvoy }) slingDryRun = false slingNoConvoy = true slingVars = nil slingOnTarget = "gt-abc123" // The base bead if err := runSling(nil, []string{"mol-polecat-work"}); err != nil { t.Fatalf("runSling: %v", err) } logBytes, err := os.ReadFile(logPath) if err != nil { t.Fatalf("read bd log: %v", err) } // Find the update command that sets status=hooked // Expected: should hook gt-abc123 (base bead) // Current bug: hooks gt-wisp-xyz (wisp) logLines := strings.Split(string(logBytes), "\n") var hookedBeadID string for _, line := range logLines { if strings.Contains(line, "update") && strings.Contains(line, "--status=hooked") { // Extract the bead ID being hooked // Format: "update --status=hooked ..." parts := strings.Fields(line) for i, part := range parts { if part == "update" && i+1 < len(parts) { hookedBeadID = parts[i+1] break } } break } } if hookedBeadID == "" { t.Fatalf("no hooked bead found in log:\n%s", string(logBytes)) } // The BASE bead (gt-abc123) should be hooked, not the wisp (gt-wisp-xyz) if hookedBeadID != "gt-abc123" { t.Errorf("wrong bead hooked: got %q, want %q (base bead)\n"+ "Current behavior hooks the wisp instead of the base bead.\n"+ "This causes orphaned base beads when gt done closes only the wisp.\n"+ "Log:\n%s", hookedBeadID, "gt-abc123", string(logBytes)) } } // TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead verifies that when using // "gt sling --on ", the attached_molecule field is set in the // BASE bead's description (pointing to the wisp), not in the wisp itself. // // Current bug: attached_molecule is stored as a self-reference in the wisp. // This is semantically meaningless (wisp points to itself) and breaks // compound resolution from the base bead. // // Expected behavior: Store attached_molecule in the base bead pointing to wisp. // This enables: // - Compound resolution: base bead -> attached_molecule -> wisp // - gt hook/gt prime: read base bead, follow attached_molecule to show wisp steps func TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead(t *testing.T) { townRoot := t.TempDir() // Minimal workspace marker if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil { t.Fatalf("mkdir mayor/rig: %v", err) } // Create routes if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil { t.Fatalf("mkdir .beads: %v", err) } rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig") if err := os.MkdirAll(rigDir, 0755); err != nil { t.Fatalf("mkdir rigDir: %v", err) } routes := strings.Join([]string{ `{"prefix":"gt-","path":"gastown/mayor/rig"}`, `{"prefix":"hq-","path":"."}`, "", }, "\n") if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil { t.Fatalf("write routes.jsonl: %v", err) } // Stub bd to track which bead gets attached_molecule set binDir := filepath.Join(townRoot, "bin") if err := os.MkdirAll(binDir, 0755); err != nil { t.Fatalf("mkdir binDir: %v", err) } logPath := filepath.Join(townRoot, "bd.log") bdScript := `#!/bin/sh set -e echo "$*" >> "${BD_LOG}" if [ "$1" = "--no-daemon" ]; then shift fi if [ "$1" = "--allow-stale" ]; then shift fi cmd="$1" shift || true case "$cmd" in show) # Return bead info without attached_molecule initially echo '[{"id":"gt-abc123","title":"Bug to fix","status":"open","assignee":"","description":""}]' ;; formula) echo '{"name":"mol-polecat-work"}' ;; cook) exit 0 ;; mol) sub="$1" shift || true case "$sub" in wisp) echo '{"new_epic_id":"gt-wisp-xyz"}' ;; bond) echo '{"root_id":"gt-wisp-xyz"}' ;; esac ;; update) # Just succeed exit 0 ;; esac exit 0 ` bdPath := filepath.Join(binDir, "bd") if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { t.Fatalf("write bd stub: %v", err) } t.Setenv("BD_LOG", logPath) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv(EnvGTRole, "mayor") t.Setenv("GT_POLECAT", "") t.Setenv("GT_CREW", "") t.Setenv("TMUX_PANE", "") t.Setenv("GT_TEST_NO_NUDGE", "1") cwd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } t.Cleanup(func() { _ = os.Chdir(cwd) }) if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil { t.Fatalf("chdir: %v", err) } // Save and restore global flag state prevOn := slingOnTarget prevVars := slingVars prevDryRun := slingDryRun prevNoConvoy := slingNoConvoy t.Cleanup(func() { slingOnTarget = prevOn slingVars = prevVars slingDryRun = prevDryRun slingNoConvoy = prevNoConvoy }) slingDryRun = false slingNoConvoy = true slingVars = nil slingOnTarget = "gt-abc123" // The base bead if err := runSling(nil, []string{"mol-polecat-work"}); err != nil { t.Fatalf("runSling: %v", err) } logBytes, err := os.ReadFile(logPath) if err != nil { t.Fatalf("read bd log: %v", err) } // Find update commands that set attached_molecule // Expected: "update gt-abc123 --description=...attached_molecule: gt-wisp-xyz..." // Current bug: "update gt-wisp-xyz --description=...attached_molecule: gt-wisp-xyz..." logLines := strings.Split(string(logBytes), "\n") var attachedMoleculeTarget string for _, line := range logLines { if strings.Contains(line, "update") && strings.Contains(line, "attached_molecule") { // Extract the bead ID being updated parts := strings.Fields(line) for i, part := range parts { if part == "update" && i+1 < len(parts) { attachedMoleculeTarget = parts[i+1] break } } break } } if attachedMoleculeTarget == "" { t.Fatalf("no attached_molecule update found in log:\n%s", string(logBytes)) } // attached_molecule should be set on the BASE bead, not the wisp if attachedMoleculeTarget != "gt-abc123" { t.Errorf("attached_molecule set on wrong bead: got %q, want %q (base bead)\n"+ "Current behavior stores attached_molecule in the wisp as a self-reference.\n"+ "This breaks compound resolution (base bead has no pointer to wisp).\n"+ "Log:\n%s", attachedMoleculeTarget, "gt-abc123", string(logBytes)) } } // TestDoneClosesAttachedMolecule verifies that gt done closes both the hooked // bead AND its attached molecule (wisp). // // Current bug: gt done only closes the hooked bead. If base bead is hooked // with attached_molecule pointing to wisp, the wisp becomes orphaned. // // Expected behavior: gt done should: // 1. Check for attached_molecule in hooked bead // 2. Close the attached molecule (wisp) first // 3. Close the hooked bead (base bead) // // This ensures no orphaned wisps remain after work completes. func TestDoneClosesAttachedMolecule(t *testing.T) { townRoot := t.TempDir() // Create rig structure - use simple rig name that matches routes lookup rigPath := filepath.Join(townRoot, "gastown") if err := os.MkdirAll(rigPath, 0755); err != nil { t.Fatalf("mkdir rig: %v", err) } if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil { t.Fatalf("mkdir .beads: %v", err) } // Create routes - path first part must match GT_RIG for prefix lookup routes := strings.Join([]string{ `{"prefix":"gt-","path":"gastown"}`, "", }, "\n") if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil { t.Fatalf("write routes.jsonl: %v", err) } // Stub bd to track close calls binDir := filepath.Join(townRoot, "bin") if err := os.MkdirAll(binDir, 0755); err != nil { t.Fatalf("mkdir binDir: %v", err) } closesPath := filepath.Join(townRoot, "closes.log") // The stub simulates: // - Agent bead gt-agent-nux with hook_bead = gt-abc123 (base bead) // - Base bead gt-abc123 with attached_molecule: gt-wisp-xyz, status=hooked // - Wisp gt-wisp-xyz (the attached molecule) bdScript := fmt.Sprintf(`#!/bin/sh echo "$*" >> "%s/bd.log" # Strip --no-daemon and --allow-stale while [ "$1" = "--no-daemon" ] || [ "$1" = "--allow-stale" ]; do shift done cmd="$1" shift || true case "$cmd" in show) beadID="$1" case "$beadID" in gt-gastown-polecat-nux) echo '[{"id":"gt-gastown-polecat-nux","title":"Polecat nux","status":"open","hook_bead":"gt-abc123","agent_state":"working"}]' ;; gt-abc123) echo '[{"id":"gt-abc123","title":"Bug to fix","status":"hooked","description":"attached_molecule: gt-wisp-xyz"}]' ;; gt-wisp-xyz) echo '[{"id":"gt-wisp-xyz","title":"mol-polecat-work","status":"open","ephemeral":true}]' ;; *) echo '[]' ;; esac ;; close) echo "$1" >> "%s" ;; agent|update|slot) exit 0 ;; esac exit 0 `, townRoot, closesPath) bdPath := filepath.Join(binDir, "bd") if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { t.Fatalf("write bd stub: %v", err) } t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("GT_ROLE", "polecat") t.Setenv("GT_RIG", "gastown") t.Setenv("GT_POLECAT", "nux") t.Setenv("GT_CREW", "") t.Setenv("TMUX_PANE", "") cwd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } t.Cleanup(func() { _ = os.Chdir(cwd) }) if err := os.Chdir(rigPath); err != nil { t.Fatalf("chdir: %v", err) } // Call the unexported function directly (same package) // updateAgentStateOnDone(cwd, townRoot, exitType, issueID) updateAgentStateOnDone(rigPath, townRoot, ExitCompleted, "") // Read the close log to see what got closed closesBytes, err := os.ReadFile(closesPath) if err != nil { // No closes happened at all - that's a failure t.Fatalf("no beads were closed (closes.log doesn't exist)") } closes := string(closesBytes) closeLines := strings.Split(strings.TrimSpace(closes), "\n") // Check that attached molecule gt-wisp-xyz was closed foundWisp := false foundBase := false for _, line := range closeLines { if strings.Contains(line, "gt-wisp-xyz") { foundWisp = true } if strings.Contains(line, "gt-abc123") { foundBase = true } } if !foundWisp { t.Errorf("attached molecule gt-wisp-xyz was NOT closed\n"+ "gt done should close the attached_molecule before closing the hooked bead.\n"+ "This leaves orphaned wisps after work completes.\n"+ "Beads closed: %v", closeLines) } if !foundBase { t.Errorf("hooked bead gt-abc123 was NOT closed\n"+ "Beads closed: %v", closeLines) } }