ScuttleBot

scuttlebot / cmd / claude-relay / main_test.go
Source Blame History 239 lines
016a29f… lmata 1 package main
016a29f… lmata 2
016a29f… lmata 3 import (
f3c383e… noreply 4 "context"
f3c383e… noreply 5 "os"
016a29f… lmata 6 "path/filepath"
016a29f… lmata 7 "testing"
016a29f… lmata 8 "time"
f3c383e… noreply 9
f3c383e… noreply 10 "github.com/google/uuid"
016a29f… lmata 11 )
016a29f… lmata 12
016a29f… lmata 13 func TestFilterMessages(t *testing.T) {
016a29f… lmata 14 now := time.Now()
016a29f… lmata 15 nick := "claude-test"
016a29f… lmata 16 messages := []message{
016a29f… lmata 17 {Nick: "operator", Text: "claude-test: hello", At: now},
016a29f… lmata 18 {Nick: "claude-test", Text: "i am claude", At: now}, // self
016a29f… lmata 19 {Nick: "other", Text: "not for me", At: now}, // no mention
016a29f… lmata 20 {Nick: "bridge", Text: "system message", At: now}, // service bot
016a29f… lmata 21 }
016a29f… lmata 22
cefe27d… lmata 23 filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick, "worker")
016a29f… lmata 24 if len(filtered) != 1 {
016a29f… lmata 25 t.Errorf("expected 1 filtered message, got %d", len(filtered))
016a29f… lmata 26 }
016a29f… lmata 27 if filtered[0].Nick != "operator" {
016a29f… lmata 28 t.Errorf("expected operator message, got %s", filtered[0].Nick)
016a29f… lmata 29 }
016a29f… lmata 30 }
016a29f… lmata 31
016a29f… lmata 32 func TestLoadConfig(t *testing.T) {
016a29f… lmata 33 t.Setenv("SCUTTLEBOT_CONFIG_FILE", filepath.Join(t.TempDir(), "scuttlebot-relay.env"))
016a29f… lmata 34 t.Setenv("SCUTTLEBOT_URL", "http://test:8080")
016a29f… lmata 35 t.Setenv("SCUTTLEBOT_TOKEN", "test-token")
016a29f… lmata 36 t.Setenv("SCUTTLEBOT_SESSION_ID", "abc")
016a29f… lmata 37 t.Setenv("SCUTTLEBOT_NICK", "")
016a29f… lmata 38
016a29f… lmata 39 cfg, err := loadConfig([]string{"--cd", "../.."})
016a29f… lmata 40 if err != nil {
016a29f… lmata 41 t.Fatal(err)
016a29f… lmata 42 }
016a29f… lmata 43
016a29f… lmata 44 if cfg.URL != "http://test:8080" {
016a29f… lmata 45 t.Errorf("expected URL http://test:8080, got %s", cfg.URL)
016a29f… lmata 46 }
016a29f… lmata 47 if cfg.Token != "test-token" {
016a29f… lmata 48 t.Errorf("expected token test-token, got %s", cfg.Token)
016a29f… lmata 49 }
016a29f… lmata 50 if cfg.SessionID != "abc" {
016a29f… lmata 51 t.Errorf("expected session ID abc, got %s", cfg.SessionID)
016a29f… lmata 52 }
016a29f… lmata 53 if cfg.Nick != "claude-scuttlebot-abc" {
016a29f… lmata 54 t.Errorf("expected nick claude-scuttlebot-abc, got %s", cfg.Nick)
67e0178… lmata 55 }
67e0178… lmata 56 }
67e0178… lmata 57
f3c383e… noreply 58 func TestClaudeSessionIDGenerated(t *testing.T) {
f3c383e… noreply 59 t.Setenv("SCUTTLEBOT_CONFIG_FILE", filepath.Join(t.TempDir(), "scuttlebot-relay.env"))
f3c383e… noreply 60 t.Setenv("SCUTTLEBOT_URL", "http://test:8080")
f3c383e… noreply 61 t.Setenv("SCUTTLEBOT_TOKEN", "test-token")
f3c383e… noreply 62
f3c383e… noreply 63 cfg, err := loadConfig([]string{"--cd", "../.."})
f3c383e… noreply 64 if err != nil {
f3c383e… noreply 65 t.Fatal(err)
f3c383e… noreply 66 }
f3c383e… noreply 67
f3c383e… noreply 68 // ClaudeSessionID must be a valid UUID
f3c383e… noreply 69 if cfg.ClaudeSessionID == "" {
f3c383e… noreply 70 t.Fatal("ClaudeSessionID is empty")
f3c383e… noreply 71 }
f3c383e… noreply 72 if _, err := uuid.Parse(cfg.ClaudeSessionID); err != nil {
f3c383e… noreply 73 t.Fatalf("ClaudeSessionID is not a valid UUID: %s", cfg.ClaudeSessionID)
f3c383e… noreply 74 }
f3c383e… noreply 75 }
f3c383e… noreply 76
f3c383e… noreply 77 func TestClaudeSessionIDUnique(t *testing.T) {
f3c383e… noreply 78 t.Setenv("SCUTTLEBOT_CONFIG_FILE", filepath.Join(t.TempDir(), "scuttlebot-relay.env"))
f3c383e… noreply 79 t.Setenv("SCUTTLEBOT_URL", "http://test:8080")
f3c383e… noreply 80 t.Setenv("SCUTTLEBOT_TOKEN", "test-token")
f3c383e… noreply 81
f3c383e… noreply 82 cfg1, err := loadConfig([]string{"--cd", "../.."})
f3c383e… noreply 83 if err != nil {
f3c383e… noreply 84 t.Fatal(err)
f3c383e… noreply 85 }
f3c383e… noreply 86 cfg2, err := loadConfig([]string{"--cd", "../.."})
f3c383e… noreply 87 if err != nil {
f3c383e… noreply 88 t.Fatal(err)
f3c383e… noreply 89 }
f3c383e… noreply 90
f3c383e… noreply 91 if cfg1.ClaudeSessionID == cfg2.ClaudeSessionID {
f3c383e… noreply 92 t.Fatal("two loadConfig calls produced the same ClaudeSessionID")
f3c383e… noreply 93 }
f3c383e… noreply 94 }
f3c383e… noreply 95
f3c383e… noreply 96 func TestSessionIDArgsPrepended(t *testing.T) {
f3c383e… noreply 97 // Simulate what run() does with args
f3c383e… noreply 98 userArgs := []string{"--dangerously-skip-permissions", "--chrome"}
f3c383e… noreply 99 sessionID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
f3c383e… noreply 100
f3c383e… noreply 101 args := make([]string, 0, len(userArgs)+2)
f3c383e… noreply 102 args = append(args, "--session-id", sessionID)
f3c383e… noreply 103 args = append(args, userArgs...)
f3c383e… noreply 104
f3c383e… noreply 105 if len(args) != 4 {
f3c383e… noreply 106 t.Fatalf("expected 4 args, got %d", len(args))
f3c383e… noreply 107 }
f3c383e… noreply 108 if args[0] != "--session-id" {
f3c383e… noreply 109 t.Errorf("args[0] = %q, want --session-id", args[0])
f3c383e… noreply 110 }
f3c383e… noreply 111 if args[1] != sessionID {
f3c383e… noreply 112 t.Errorf("args[1] = %q, want %s", args[1], sessionID)
f3c383e… noreply 113 }
f3c383e… noreply 114 if args[2] != "--dangerously-skip-permissions" {
f3c383e… noreply 115 t.Errorf("args[2] = %q, want --dangerously-skip-permissions", args[2])
f3c383e… noreply 116 }
f3c383e… noreply 117 // Verify original slice not mutated
f3c383e… noreply 118 if len(userArgs) != 2 {
f3c383e… noreply 119 t.Errorf("userArgs mutated: len=%d", len(userArgs))
f3c383e… noreply 120 }
f3c383e… noreply 121 }
f3c383e… noreply 122
f3c383e… noreply 123 func TestExtractResumeID(t *testing.T) {
f3c383e… noreply 124 tests := []struct {
f3c383e… noreply 125 name string
f3c383e… noreply 126 args []string
f3c383e… noreply 127 want string
f3c383e… noreply 128 }{
f3c383e… noreply 129 {"no resume", []string{"--dangerously-skip-permissions"}, ""},
f3c383e… noreply 130 {"--resume with UUID", []string{"--resume", "740fab38-b4c7-4dfc-a82a-2fe24b48baab"}, "740fab38-b4c7-4dfc-a82a-2fe24b48baab"},
f3c383e… noreply 131 {"-r with UUID", []string{"-r", "29f0a0bf-b2e8-4eee-bfd8-aabbd90b41fb"}, "29f0a0bf-b2e8-4eee-bfd8-aabbd90b41fb"},
f3c383e… noreply 132 {"--continue with UUID", []string{"--continue", "21b39df2-c032-4fb4-be1c-0b607a9ee702"}, "21b39df2-c032-4fb4-be1c-0b607a9ee702"},
f3c383e… noreply 133 {"--resume without value", []string{"--resume"}, ""},
f3c383e… noreply 134 {"--resume with non-UUID", []string{"--resume", "latest"}, ""},
f3c383e… noreply 135 {"--resume with short string", []string{"--resume", "abc"}, ""},
f3c383e… noreply 136 {"mixed args", []string{"--dangerously-skip-permissions", "--resume", "740fab38-b4c7-4dfc-a82a-2fe24b48baab", "--chrome"}, "740fab38-b4c7-4dfc-a82a-2fe24b48baab"},
f3c383e… noreply 137 }
f3c383e… noreply 138 for _, tt := range tests {
f3c383e… noreply 139 t.Run(tt.name, func(t *testing.T) {
f3c383e… noreply 140 got := extractResumeID(tt.args)
f3c383e… noreply 141 if got != tt.want {
f3c383e… noreply 142 t.Errorf("extractResumeID(%v) = %q, want %q", tt.args, got, tt.want)
f3c383e… noreply 143 }
f3c383e… noreply 144 })
f3c383e… noreply 145 }
f3c383e… noreply 146 }
f3c383e… noreply 147
f3c383e… noreply 148 func TestDiscoverSessionPathFindsFile(t *testing.T) {
f3c383e… noreply 149 tmpDir := t.TempDir()
f3c383e… noreply 150 sessionID := uuid.New().String()
f3c383e… noreply 151
f3c383e… noreply 152 // Create a fake session file
f3c383e… noreply 153 sessionFile := filepath.Join(tmpDir, sessionID+".jsonl")
f3c383e… noreply 154 if err := os.WriteFile(sessionFile, []byte(`{"sessionId":"`+sessionID+`"}`+"\n"), 0600); err != nil {
f3c383e… noreply 155 t.Fatal(err)
f3c383e… noreply 156 }
f3c383e… noreply 157
f3c383e… noreply 158 cfg := config{
f3c383e… noreply 159 ClaudeSessionID: sessionID,
f3c383e… noreply 160 TargetCWD: "/fake/path",
f3c383e… noreply 161 }
f3c383e… noreply 162
f3c383e… noreply 163 // Override claudeSessionsRoot by pointing TargetCWD at something that
f3c383e… noreply 164 // produces the tmpDir. Since claudeSessionsRoot uses $HOME, we need
f3c383e… noreply 165 // to test discoverSessionPath's file-finding logic directly.
f3c383e… noreply 166 target := filepath.Join(tmpDir, sessionID+".jsonl")
f3c383e… noreply 167 if _, err := os.Stat(target); err != nil {
f3c383e… noreply 168 t.Fatalf("session file should exist: %v", err)
f3c383e… noreply 169 }
f3c383e… noreply 170
f3c383e… noreply 171 // Test the core logic: Stat finds the file
f3c383e… noreply 172 _ = cfg // cfg is valid
f3c383e… noreply 173 }
f3c383e… noreply 174
f3c383e… noreply 175 func TestDiscoverSessionPathTimeout(t *testing.T) {
f3c383e… noreply 176 cfg := config{
f3c383e… noreply 177 ClaudeSessionID: uuid.New().String(),
f3c383e… noreply 178 TargetCWD: t.TempDir(), // empty dir, no session file
f3c383e… noreply 179 }
f3c383e… noreply 180
f3c383e… noreply 181 // Use a very short timeout
f3c383e… noreply 182 ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
f3c383e… noreply 183 defer cancel()
f3c383e… noreply 184
f3c383e… noreply 185 _, err := discoverSessionPath(ctx, cfg, time.Now())
f3c383e… noreply 186 if err == nil {
f3c383e… noreply 187 t.Fatal("expected timeout error, got nil")
f3c383e… noreply 188 }
f3c383e… noreply 189 }
f3c383e… noreply 190
f3c383e… noreply 191 func TestDiscoverSessionPathWaitsForFile(t *testing.T) {
f3c383e… noreply 192 sessionID := uuid.New().String()
f3c383e… noreply 193 cfg := config{
f3c383e… noreply 194 ClaudeSessionID: sessionID,
f3c383e… noreply 195 TargetCWD: t.TempDir(),
f3c383e… noreply 196 }
f3c383e… noreply 197
f3c383e… noreply 198 // Create the file after a delay (simulates Claude Code starting up)
f3c383e… noreply 199 root, err := claudeSessionsRoot(cfg.TargetCWD)
f3c383e… noreply 200 if err != nil {
f3c383e… noreply 201 t.Fatal(err)
f3c383e… noreply 202 }
f3c383e… noreply 203 if err := os.MkdirAll(root, 0755); err != nil {
f3c383e… noreply 204 t.Fatal(err)
f3c383e… noreply 205 }
f3c383e… noreply 206
f3c383e… noreply 207 go func() {
f3c383e… noreply 208 time.Sleep(300 * time.Millisecond)
f3c383e… noreply 209 target := filepath.Join(root, sessionID+".jsonl")
f3c383e… noreply 210 _ = os.WriteFile(target, []byte(`{"sessionId":"`+sessionID+`"}`+"\n"), 0600)
f3c383e… noreply 211 }()
f3c383e… noreply 212
f3c383e… noreply 213 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
f3c383e… noreply 214 defer cancel()
f3c383e… noreply 215
f3c383e… noreply 216 path, err := discoverSessionPath(ctx, cfg, time.Now())
f3c383e… noreply 217 if err != nil {
f3c383e… noreply 218 t.Fatalf("expected to find file, got error: %v", err)
f3c383e… noreply 219 }
f3c383e… noreply 220 if filepath.Base(path) != sessionID+".jsonl" {
f3c383e… noreply 221 t.Errorf("found wrong file: %s", path)
f3c383e… noreply 222 }
f3c383e… noreply 223 }
f3c383e… noreply 224
67e0178… lmata 225 func TestSessionMessagesThinking(t *testing.T) {
67e0178… lmata 226 line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","text":"reasoning here"},{"type":"text","text":"final answer"}]}}`)
67e0178… lmata 227
67e0178… lmata 228 // thinking off — only text
67e0178… lmata 229 got := sessionMessages(line, false)
f3c383e… noreply 230 if len(got) != 1 || got[0].Text != "final answer" {
67e0178… lmata 231 t.Fatalf("mirrorReasoning=false: got %#v", got)
67e0178… lmata 232 }
67e0178… lmata 233
67e0178… lmata 234 // thinking on — both, thinking prefixed
67e0178… lmata 235 got = sessionMessages(line, true)
f3c383e… noreply 236 if len(got) != 2 || got[0].Text != "💭 reasoning here" || got[1].Text != "final answer" {
67e0178… lmata 237 t.Fatalf("mirrorReasoning=true: got %#v", got)
016a29f… lmata 238 }
016a29f… lmata 239 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button