|
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 |
} |