1package server
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "shelley.exe.dev/db/generated"
15 "shelley.exe.dev/llm"
16)
17
18// TestWorkingDirectoryConfiguration tests that the working directory (cwd) setting
19// is properly passed through from HTTP requests to tool execution.
20func TestWorkingDirectoryConfiguration(t *testing.T) {
21 h := NewTestHarness(t)
22 defer h.Close()
23
24 t.Run("cwd_tmp", func(t *testing.T) {
25 h.NewConversation("bash: pwd", "/tmp")
26 result := strings.TrimSpace(h.WaitToolResult())
27 // Resolve symlinks for comparison (on macOS, /tmp -> /private/tmp)
28 expected, _ := filepath.EvalSymlinks("/tmp")
29 if result != expected {
30 t.Errorf("expected %q, got: %s", expected, result)
31 }
32 })
33
34 t.Run("cwd_root", func(t *testing.T) {
35 h.NewConversation("bash: pwd", "/")
36 result := strings.TrimSpace(h.WaitToolResult())
37 if result != "/" {
38 t.Errorf("expected '/', got: %s", result)
39 }
40 })
41}
42
43// TestListDirectory tests the list-directory API endpoint used by the directory picker.
44func TestListDirectory(t *testing.T) {
45 h := NewTestHarness(t)
46 defer h.Close()
47
48 t.Run("list_tmp", func(t *testing.T) {
49 req := httptest.NewRequest("GET", "/api/list-directory?path=/tmp", nil)
50 w := httptest.NewRecorder()
51 h.server.handleListDirectory(w, req)
52
53 if w.Code != http.StatusOK {
54 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
55 }
56
57 var resp ListDirectoryResponse
58 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
59 t.Fatalf("failed to parse response: %v", err)
60 }
61
62 if resp.Path != "/tmp" {
63 t.Errorf("expected path '/tmp', got: %s", resp.Path)
64 }
65
66 if resp.Parent != "/" {
67 t.Errorf("expected parent '/', got: %s", resp.Parent)
68 }
69 })
70
71 t.Run("list_root", func(t *testing.T) {
72 req := httptest.NewRequest("GET", "/api/list-directory?path=/", nil)
73 w := httptest.NewRecorder()
74 h.server.handleListDirectory(w, req)
75
76 if w.Code != http.StatusOK {
77 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
78 }
79
80 var resp ListDirectoryResponse
81 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
82 t.Fatalf("failed to parse response: %v", err)
83 }
84
85 if resp.Path != "/" {
86 t.Errorf("expected path '/', got: %s", resp.Path)
87 }
88
89 // Root should have no parent
90 if resp.Parent != "" {
91 t.Errorf("expected no parent, got: %s", resp.Parent)
92 }
93
94 // Root should have at least some directories (tmp, etc, home, etc.)
95 if len(resp.Entries) == 0 {
96 t.Error("expected at least some entries in root")
97 }
98 })
99
100 t.Run("list_default_path", func(t *testing.T) {
101 req := httptest.NewRequest("GET", "/api/list-directory", nil)
102 w := httptest.NewRecorder()
103 h.server.handleListDirectory(w, req)
104
105 if w.Code != http.StatusOK {
106 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
107 }
108
109 var resp ListDirectoryResponse
110 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
111 t.Fatalf("failed to parse response: %v", err)
112 }
113
114 // Should default to home directory
115 homeDir, _ := os.UserHomeDir()
116 if homeDir != "" && resp.Path != homeDir {
117 t.Errorf("expected path '%s', got: %s", homeDir, resp.Path)
118 }
119 })
120
121 t.Run("list_nonexistent", func(t *testing.T) {
122 req := httptest.NewRequest("GET", "/api/list-directory?path=/nonexistent/path/123456", nil)
123 w := httptest.NewRecorder()
124 h.server.handleListDirectory(w, req)
125
126 if w.Code != http.StatusOK {
127 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
128 }
129
130 var resp map[string]interface{}
131 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
132 t.Fatalf("failed to parse response: %v", err)
133 }
134
135 if resp["error"] == nil {
136 t.Error("expected error field in response")
137 }
138 })
139
140 t.Run("list_file_not_directory", func(t *testing.T) {
141 // Create a temp file
142 f, err := os.CreateTemp("", "test")
143 if err != nil {
144 t.Fatalf("failed to create temp file: %v", err)
145 }
146 defer os.Remove(f.Name())
147 f.Close()
148
149 req := httptest.NewRequest("GET", "/api/list-directory?path="+f.Name(), nil)
150 w := httptest.NewRecorder()
151 h.server.handleListDirectory(w, req)
152
153 if w.Code != http.StatusOK {
154 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
155 }
156
157 var resp map[string]interface{}
158 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
159 t.Fatalf("failed to parse response: %v", err)
160 }
161
162 errMsg, ok := resp["error"].(string)
163 if !ok || errMsg != "path is not a directory" {
164 t.Errorf("expected error 'path is not a directory', got: %v", resp["error"])
165 }
166 })
167
168 t.Run("only_directories_returned", func(t *testing.T) {
169 // Create a temp directory with both files and directories
170 tmpDir, err := os.MkdirTemp("", "listdir_test")
171 if err != nil {
172 t.Fatalf("failed to create temp dir: %v", err)
173 }
174 defer os.RemoveAll(tmpDir)
175
176 // Create a subdirectory
177 subDir := tmpDir + "/subdir"
178 if err := os.Mkdir(subDir, 0o755); err != nil {
179 t.Fatalf("failed to create subdir: %v", err)
180 }
181
182 // Create a file
183 file := tmpDir + "/file.txt"
184 if err := os.WriteFile(file, []byte("test"), 0o644); err != nil {
185 t.Fatalf("failed to create file: %v", err)
186 }
187
188 req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil)
189 w := httptest.NewRecorder()
190 h.server.handleListDirectory(w, req)
191
192 if w.Code != http.StatusOK {
193 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
194 }
195
196 var resp ListDirectoryResponse
197 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
198 t.Fatalf("failed to parse response: %v", err)
199 }
200
201 // Should only include the directory, not the file
202 if len(resp.Entries) != 1 {
203 t.Errorf("expected 1 entry, got: %d", len(resp.Entries))
204 }
205
206 if len(resp.Entries) > 0 && resp.Entries[0].Name != "subdir" {
207 t.Errorf("expected entry 'subdir', got: %s", resp.Entries[0].Name)
208 }
209 })
210
211 t.Run("hidden_directories_sorted_last", func(t *testing.T) {
212 // Create a temp directory with hidden and non-hidden directories
213 tmpDir, err := os.MkdirTemp("", "listdir_hidden_test")
214 if err != nil {
215 t.Fatalf("failed to create temp dir: %v", err)
216 }
217 defer os.RemoveAll(tmpDir)
218
219 for _, name := range []string{".alpha", "beta", ".gamma", "delta", "alpha"} {
220 if err := os.Mkdir(filepath.Join(tmpDir, name), 0o755); err != nil {
221 t.Fatalf("failed to create dir %s: %v", name, err)
222 }
223 }
224
225 req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil)
226 w := httptest.NewRecorder()
227 h.server.handleListDirectory(w, req)
228
229 if w.Code != http.StatusOK {
230 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
231 }
232
233 var resp ListDirectoryResponse
234 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
235 t.Fatalf("failed to parse response: %v", err)
236 }
237
238 if len(resp.Entries) != 5 {
239 t.Fatalf("expected 5 entries, got: %d", len(resp.Entries))
240 }
241
242 // Non-hidden sorted first, then hidden sorted
243 want := []string{"alpha", "beta", "delta", ".alpha", ".gamma"}
244 for i, e := range resp.Entries {
245 if e.Name != want[i] {
246 t.Errorf("entry[%d]: expected %q, got %q", i, want[i], e.Name)
247 }
248 }
249 })
250
251 t.Run("git_repo_head_subject", func(t *testing.T) {
252 // Create a temp directory containing a git repo
253 tmpDir, err := os.MkdirTemp("", "listdir_git_test")
254 if err != nil {
255 t.Fatalf("failed to create temp dir: %v", err)
256 }
257 defer os.RemoveAll(tmpDir)
258
259 // Create a subdirectory that will be a git repo
260 repoDir := tmpDir + "/myrepo"
261 if err := os.Mkdir(repoDir, 0o755); err != nil {
262 t.Fatalf("failed to create repo dir: %v", err)
263 }
264
265 // Initialize git repo and create a commit
266 cmd := exec.Command("git", "init")
267 cmd.Dir = repoDir
268 if err := cmd.Run(); err != nil {
269 t.Fatalf("failed to init git: %v", err)
270 }
271
272 cmd = exec.Command("git", "config", "user.email", "test@example.com")
273 cmd.Dir = repoDir
274 if err := cmd.Run(); err != nil {
275 t.Fatalf("failed to config git email: %v", err)
276 }
277
278 cmd = exec.Command("git", "config", "user.name", "Test User")
279 cmd.Dir = repoDir
280 if err := cmd.Run(); err != nil {
281 t.Fatalf("failed to config git name: %v", err)
282 }
283
284 // Create a file and commit it
285 if err := os.WriteFile(repoDir+"/README.md", []byte("# Hello"), 0o644); err != nil {
286 t.Fatalf("failed to write file: %v", err)
287 }
288
289 cmd = exec.Command("git", "add", "README.md")
290 cmd.Dir = repoDir
291 if err := cmd.Run(); err != nil {
292 t.Fatalf("failed to git add: %v", err)
293 }
294
295 cmd = exec.Command("git", "commit", "-m", "Test commit subject line\n\nPrompt: test")
296 cmd.Dir = repoDir
297 if err := cmd.Run(); err != nil {
298 t.Fatalf("failed to git commit: %v", err)
299 }
300
301 // Create another directory that is not a git repo
302 nonRepoDir := tmpDir + "/notarepo"
303 if err := os.Mkdir(nonRepoDir, 0o755); err != nil {
304 t.Fatalf("failed to create non-repo dir: %v", err)
305 }
306
307 req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil)
308 w := httptest.NewRecorder()
309 h.server.handleListDirectory(w, req)
310
311 if w.Code != http.StatusOK {
312 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
313 }
314
315 var resp ListDirectoryResponse
316 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
317 t.Fatalf("failed to parse response: %v", err)
318 }
319
320 if len(resp.Entries) != 2 {
321 t.Fatalf("expected 2 entries, got: %d", len(resp.Entries))
322 }
323
324 // Find the git repo entry and verify it has the commit subject
325 var gitEntry, nonGitEntry *DirectoryEntry
326 for i := range resp.Entries {
327 if resp.Entries[i].Name == "myrepo" {
328 gitEntry = &resp.Entries[i]
329 } else if resp.Entries[i].Name == "notarepo" {
330 nonGitEntry = &resp.Entries[i]
331 }
332 }
333
334 if gitEntry == nil {
335 t.Fatal("expected to find myrepo entry")
336 }
337 if nonGitEntry == nil {
338 t.Fatal("expected to find notarepo entry")
339 }
340
341 // Git repo should have the HEAD commit subject
342 if gitEntry.GitHeadSubject != "Test commit subject line" {
343 t.Errorf("expected git_head_subject 'Test commit subject line', got: %q", gitEntry.GitHeadSubject)
344 }
345
346 // Non-git dir should not have a subject
347 if nonGitEntry.GitHeadSubject != "" {
348 t.Errorf("expected empty git_head_subject for non-git dir, got: %q", nonGitEntry.GitHeadSubject)
349 }
350 })
351
352 t.Run("git_worktree_root", func(t *testing.T) {
353 // Create a main git repo and a worktree, then verify that
354 // listing the worktree returns git_worktree_root pointing to the main repo.
355 tmpDir, err := os.MkdirTemp("", "listdir_wtroot_test")
356 if err != nil {
357 t.Fatalf("failed to create temp dir: %v", err)
358 }
359 defer os.RemoveAll(tmpDir)
360
361 mainRepo := filepath.Join(tmpDir, "main-repo")
362 if err := os.Mkdir(mainRepo, 0o755); err != nil {
363 t.Fatalf("failed to create main repo dir: %v", err)
364 }
365
366 for _, args := range [][]string{
367 {"git", "init"},
368 {"git", "config", "user.email", "test@example.com"},
369 {"git", "config", "user.name", "Test User"},
370 } {
371 cmd := exec.Command(args[0], args[1:]...)
372 cmd.Dir = mainRepo
373 if err := cmd.Run(); err != nil {
374 t.Fatalf("%v failed: %v", args, err)
375 }
376 }
377
378 if err := os.WriteFile(filepath.Join(mainRepo, "README.md"), []byte("# Hi"), 0o644); err != nil {
379 t.Fatal(err)
380 }
381 cmd := exec.Command("git", "add", ".")
382 cmd.Dir = mainRepo
383 if err := cmd.Run(); err != nil {
384 t.Fatal(err)
385 }
386 cmd = exec.Command("git", "commit", "-m", "init\n\nPrompt: test")
387 cmd.Dir = mainRepo
388 if err := cmd.Run(); err != nil {
389 t.Fatal(err)
390 }
391
392 // Create a worktree
393 cmd = exec.Command("git", "branch", "wt-branch")
394 cmd.Dir = mainRepo
395 if err := cmd.Run(); err != nil {
396 t.Fatal(err)
397 }
398 worktreePath := filepath.Join(tmpDir, "my-worktree")
399 cmd = exec.Command("git", "worktree", "add", worktreePath, "wt-branch")
400 cmd.Dir = mainRepo
401 if err := cmd.Run(); err != nil {
402 t.Fatal(err)
403 }
404
405 // List the worktree directory itself - should have git_worktree_root
406 req := httptest.NewRequest("GET", "/api/list-directory?path="+worktreePath, nil)
407 w := httptest.NewRecorder()
408 h.server.handleListDirectory(w, req)
409
410 var resp ListDirectoryResponse
411 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
412 t.Fatalf("failed to parse response: %v", err)
413 }
414 if resp.GitWorktreeRoot != mainRepo {
415 t.Errorf("expected git_worktree_root=%q, got %q", mainRepo, resp.GitWorktreeRoot)
416 }
417
418 // List the main repo directory - should NOT have git_worktree_root
419 req = httptest.NewRequest("GET", "/api/list-directory?path="+mainRepo, nil)
420 w = httptest.NewRecorder()
421 h.server.handleListDirectory(w, req)
422
423 var resp2 ListDirectoryResponse
424 if err := json.Unmarshal(w.Body.Bytes(), &resp2); err != nil {
425 t.Fatalf("failed to parse response: %v", err)
426 }
427 if resp2.GitWorktreeRoot != "" {
428 t.Errorf("main repo should not have git_worktree_root, got %q", resp2.GitWorktreeRoot)
429 }
430 })
431
432 t.Run("git_worktree_head_subject", func(t *testing.T) {
433 // Create a temp directory containing a git repo and a worktree
434 tmpDir, err := os.MkdirTemp("", "listdir_worktree_test")
435 if err != nil {
436 t.Fatalf("failed to create temp dir: %v", err)
437 }
438 defer os.RemoveAll(tmpDir)
439
440 // Create a main git repo
441 mainRepo := tmpDir + "/main-repo"
442 if err := os.Mkdir(mainRepo, 0o755); err != nil {
443 t.Fatalf("failed to create main repo dir: %v", err)
444 }
445
446 // Initialize git repo and create a commit
447 cmd := exec.Command("git", "init")
448 cmd.Dir = mainRepo
449 if err := cmd.Run(); err != nil {
450 t.Fatalf("failed to init git: %v", err)
451 }
452
453 cmd = exec.Command("git", "config", "user.email", "test@example.com")
454 cmd.Dir = mainRepo
455 if err := cmd.Run(); err != nil {
456 t.Fatalf("failed to config git email: %v", err)
457 }
458
459 cmd = exec.Command("git", "config", "user.name", "Test User")
460 cmd.Dir = mainRepo
461 if err := cmd.Run(); err != nil {
462 t.Fatalf("failed to config git name: %v", err)
463 }
464
465 // Create a file and commit it
466 if err := os.WriteFile(mainRepo+"/README.md", []byte("# Hello"), 0o644); err != nil {
467 t.Fatalf("failed to write file: %v", err)
468 }
469
470 cmd = exec.Command("git", "add", "README.md")
471 cmd.Dir = mainRepo
472 if err := cmd.Run(); err != nil {
473 t.Fatalf("failed to git add: %v", err)
474 }
475
476 cmd = exec.Command("git", "commit", "-m", "Main repo commit\n\nPrompt: test")
477 cmd.Dir = mainRepo
478 if err := cmd.Run(); err != nil {
479 t.Fatalf("failed to git commit: %v", err)
480 }
481
482 // Create a branch and worktree
483 cmd = exec.Command("git", "branch", "feature-branch")
484 cmd.Dir = mainRepo
485 if err := cmd.Run(); err != nil {
486 t.Fatalf("failed to create branch: %v", err)
487 }
488
489 worktreePath := tmpDir + "/worktree-dir"
490 cmd = exec.Command("git", "worktree", "add", worktreePath, "feature-branch")
491 cmd.Dir = mainRepo
492 if err := cmd.Run(); err != nil {
493 t.Fatalf("failed to create worktree: %v", err)
494 }
495
496 // Verify the worktree has a .git file (not directory)
497 gitPath := worktreePath + "/.git"
498 fi, err := os.Stat(gitPath)
499 if err != nil {
500 t.Fatalf("failed to stat worktree .git: %v", err)
501 }
502 if fi.IsDir() {
503 t.Fatalf("expected .git to be a file for worktree, got directory")
504 }
505
506 req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil)
507 w := httptest.NewRecorder()
508 h.server.handleListDirectory(w, req)
509
510 if w.Code != http.StatusOK {
511 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
512 }
513
514 var resp ListDirectoryResponse
515 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
516 t.Fatalf("failed to parse response: %v", err)
517 }
518
519 // Find the worktree entry and verify it has the commit subject
520 var worktreeEntry *DirectoryEntry
521 for i := range resp.Entries {
522 if resp.Entries[i].Name == "worktree-dir" {
523 worktreeEntry = &resp.Entries[i]
524 }
525 }
526
527 if worktreeEntry == nil {
528 t.Fatal("expected to find worktree-dir entry")
529 }
530
531 // Worktree should have the HEAD commit subject
532 if worktreeEntry.GitHeadSubject != "Main repo commit" {
533 t.Errorf("expected git_head_subject 'Main repo commit', got: %q", worktreeEntry.GitHeadSubject)
534 }
535 })
536}
537
538// TestConversationCwdReturnedInList tests that CWD is returned in the conversations list.
539func TestConversationCwdReturnedInList(t *testing.T) {
540 h := NewTestHarness(t)
541 defer h.Close()
542
543 // Create a conversation with a specific CWD
544 h.NewConversation("bash: pwd", "/tmp")
545 h.WaitToolResult() // Wait for the conversation to complete
546
547 // Get the conversations list
548 req := httptest.NewRequest("GET", "/api/conversations", nil)
549 w := httptest.NewRecorder()
550 h.server.handleConversations(w, req)
551
552 if w.Code != http.StatusOK {
553 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
554 }
555
556 var convs []map[string]interface{}
557 if err := json.Unmarshal(w.Body.Bytes(), &convs); err != nil {
558 t.Fatalf("failed to parse response: %v", err)
559 }
560
561 if len(convs) == 0 {
562 t.Fatal("expected at least one conversation")
563 }
564
565 // Find our conversation
566 found := false
567 for _, conv := range convs {
568 if conv["conversation_id"] == h.ConversationID() {
569 found = true
570 cwd, ok := conv["cwd"].(string)
571 if !ok {
572 t.Errorf("expected cwd to be a string, got: %T", conv["cwd"])
573 }
574 if cwd != "/tmp" {
575 t.Errorf("expected cwd '/tmp', got: %s", cwd)
576 }
577 break
578 }
579 }
580
581 if !found {
582 t.Error("conversation not found in list")
583 }
584}
585
586// TestSystemPromptUsesCwdFromConversation verifies that when a conversation
587// is created with a specific cwd, the system prompt is generated using that
588// directory (not the server's working directory). This tests the fix for
589// https://github.com/boldsoftware/shelley/issues/30
590func TestSystemPromptUsesCwdFromConversation(t *testing.T) {
591 // Create a temp directory with an AGENTS.md file
592 tmpDir, err := os.MkdirTemp("", "shelley_cwd_test")
593 if err != nil {
594 t.Fatalf("failed to create temp dir: %v", err)
595 }
596 defer os.RemoveAll(tmpDir)
597
598 // Create an AGENTS.md file with unique content we can search for
599 agentsContent := "UNIQUE_MARKER_FOR_CWD_TEST_XYZ123: This is test guidance."
600 agentsFile := filepath.Join(tmpDir, "AGENTS.md")
601 if err := os.WriteFile(agentsFile, []byte(agentsContent), 0o644); err != nil {
602 t.Fatalf("failed to write AGENTS.md: %v", err)
603 }
604
605 h := NewTestHarness(t)
606 defer h.Close()
607
608 // Create a conversation with the temp directory as cwd
609 h.NewConversation("bash: echo hello", tmpDir)
610 h.WaitToolResult()
611
612 // Get the system prompt from the database
613 var messages []generated.Message
614 err = h.db.Queries(context.Background(), func(q *generated.Queries) error {
615 var qerr error
616 messages, qerr = q.ListMessages(context.Background(), h.ConversationID())
617 return qerr
618 })
619 if err != nil {
620 t.Fatalf("failed to get messages: %v", err)
621 }
622
623 // Find the system message
624 var systemPrompt string
625 for _, msg := range messages {
626 if msg.Type == "system" && msg.LlmData != nil {
627 var llmMsg llm.Message
628 if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err == nil {
629 for _, content := range llmMsg.Content {
630 if content.Type == llm.ContentTypeText {
631 systemPrompt = content.Text
632 break
633 }
634 }
635 }
636 break
637 }
638 }
639
640 if systemPrompt == "" {
641 t.Fatal("no system prompt found in messages")
642 }
643
644 // Verify the system prompt contains our unique marker from AGENTS.md
645 if !strings.Contains(systemPrompt, "UNIQUE_MARKER_FOR_CWD_TEST_XYZ123") {
646 t.Errorf("system prompt should contain content from AGENTS.md in the cwd directory")
647 // Log first 1000 chars to help debug
648 if len(systemPrompt) > 1000 {
649 t.Logf("system prompt (first 1000 chars): %s...", systemPrompt[:1000])
650 } else {
651 t.Logf("system prompt: %s", systemPrompt)
652 }
653 }
654
655 // Verify the working directory in the prompt is our temp directory
656 if !strings.Contains(systemPrompt, tmpDir) {
657 t.Errorf("system prompt should reference the cwd directory: %s", tmpDir)
658 }
659}