shelley: read AGENTS.md from context directory at conversation start

Philip Zeyliger and Shelley created

Prompt: fix https://github.com/boldsoftware/shelley/issues/30

Fix issue where the system prompt was generated before loading the cwd
from the conversation record, causing it to use the server's working
directory instead of the user's specified context directory.

The cwd is now loaded from the conversation before generating the
system prompt, ensuring that AGENTS.md and other guidance files from
the context directory are properly included in the system prompt.

Fixes https://github.com/boldsoftware/shelley/issues/30

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

server/convo.go              | 15 +++---
server/cwd_test.go           | 79 ++++++++++++++++++++++++++++++++++++++
server/system_prompt_test.go | 72 ++++++++++++++++++++++++++++++++++
3 files changed, 159 insertions(+), 7 deletions(-)

Detailed changes

server/convo.go 🔗

@@ -124,6 +124,14 @@ func (cm *ConversationManager) Hydrate(ctx context.Context) error {
 		return fmt.Errorf("failed to get conversation history: %w", err)
 	}
 
+	// Load cwd from conversation if available - must happen before generating system prompt
+	// so that the system prompt includes guidance files from the context directory
+	cwd := ""
+	if conversation.Cwd != nil {
+		cwd = *conversation.Cwd
+	}
+	cm.cwd = cwd
+
 	// Generate system prompt if missing:
 	// - For user-initiated conversations: full system prompt
 	// - For subagent conversations (has parent): minimal subagent prompt
@@ -147,19 +155,12 @@ func (cm *ConversationManager) Hydrate(ctx context.Context) error {
 
 	history, system := cm.partitionMessages(messages)
 
-	// Load cwd from conversation if available
-	cwd := ""
-	if conversation.Cwd != nil {
-		cwd = *conversation.Cwd
-	}
-
 	cm.mu.Lock()
 	cm.history = history
 	cm.system = system
 	cm.hasConversationEvents = len(history) > 0
 	cm.lastActivity = time.Now()
 	cm.hydrated = true
-	cm.cwd = cwd
 	cm.mu.Unlock()
 
 	cm.logSystemPromptState(system, len(messages))

server/cwd_test.go 🔗

@@ -1,6 +1,7 @@
 package server
 
 import (
+	"context"
 	"encoding/json"
 	"net/http"
 	"net/http/httptest"
@@ -8,6 +9,9 @@ import (
 	"path/filepath"
 	"strings"
 	"testing"
+
+	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/llm"
 )
 
 // TestWorkingDirectoryConfiguration tests that the working directory (cwd) setting
@@ -294,3 +298,78 @@ func TestConversationCwdReturnedInList(t *testing.T) {
 		t.Error("conversation not found in list")
 	}
 }
+
+// TestSystemPromptUsesCwdFromConversation verifies that when a conversation
+// is created with a specific cwd, the system prompt is generated using that
+// directory (not the server's working directory). This tests the fix for
+// https://github.com/boldsoftware/shelley/issues/30
+func TestSystemPromptUsesCwdFromConversation(t *testing.T) {
+	// Create a temp directory with an AGENTS.md file
+	tmpDir, err := os.MkdirTemp("", "shelley_cwd_test")
+	if err != nil {
+		t.Fatalf("failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// Create an AGENTS.md file with unique content we can search for
+	agentsContent := "UNIQUE_MARKER_FOR_CWD_TEST_XYZ123: This is test guidance."
+	agentsFile := filepath.Join(tmpDir, "AGENTS.md")
+	if err := os.WriteFile(agentsFile, []byte(agentsContent), 0o644); err != nil {
+		t.Fatalf("failed to write AGENTS.md: %v", err)
+	}
+
+	h := NewTestHarness(t)
+	defer h.Close()
+
+	// Create a conversation with the temp directory as cwd
+	h.NewConversation("bash: echo hello", tmpDir)
+	h.WaitToolResult()
+
+	// Get the system prompt from the database
+	var messages []generated.Message
+	err = h.db.Queries(context.Background(), func(q *generated.Queries) error {
+		var qerr error
+		messages, qerr = q.ListMessages(context.Background(), h.ConversationID())
+		return qerr
+	})
+	if err != nil {
+		t.Fatalf("failed to get messages: %v", err)
+	}
+
+	// Find the system message
+	var systemPrompt string
+	for _, msg := range messages {
+		if msg.Type == "system" && msg.LlmData != nil {
+			var llmMsg llm.Message
+			if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err == nil {
+				for _, content := range llmMsg.Content {
+					if content.Type == llm.ContentTypeText {
+						systemPrompt = content.Text
+						break
+					}
+				}
+			}
+			break
+		}
+	}
+
+	if systemPrompt == "" {
+		t.Fatal("no system prompt found in messages")
+	}
+
+	// Verify the system prompt contains our unique marker from AGENTS.md
+	if !strings.Contains(systemPrompt, "UNIQUE_MARKER_FOR_CWD_TEST_XYZ123") {
+		t.Errorf("system prompt should contain content from AGENTS.md in the cwd directory")
+		// Log first 1000 chars to help debug
+		if len(systemPrompt) > 1000 {
+			t.Logf("system prompt (first 1000 chars): %s...", systemPrompt[:1000])
+		} else {
+			t.Logf("system prompt: %s", systemPrompt)
+		}
+	}
+
+	// Verify the working directory in the prompt is our temp directory
+	if !strings.Contains(systemPrompt, tmpDir) {
+		t.Errorf("system prompt should reference the cwd directory: %s", tmpDir)
+	}
+}

server/system_prompt_test.go 🔗

@@ -0,0 +1,72 @@
+package server
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// TestSystemPromptIncludesCwdGuidanceFiles verifies that AGENTS.md from the working directory
+// is included in the generated system prompt.
+func TestSystemPromptIncludesCwdGuidanceFiles(t *testing.T) {
+	// Create a temp directory to serve as our "context directory"
+	tmpDir, err := os.MkdirTemp("", "shelley_test")
+	if err != nil {
+		t.Fatalf("failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// Create an AGENTS.md file in the temp directory
+	agentsContent := "TEST_UNIQUE_CONTENT_12345: Always use Go for everything."
+	agentsFile := filepath.Join(tmpDir, "AGENTS.md")
+	if err := os.WriteFile(agentsFile, []byte(agentsContent), 0o644); err != nil {
+		t.Fatalf("failed to write AGENTS.md: %v", err)
+	}
+
+	// Generate system prompt for this directory
+	prompt, err := GenerateSystemPrompt(tmpDir)
+	if err != nil {
+		t.Fatalf("GenerateSystemPrompt failed: %v", err)
+	}
+
+	// Verify the unique content from AGENTS.md is included in the prompt
+	if !strings.Contains(prompt, "TEST_UNIQUE_CONTENT_12345") {
+		t.Errorf("system prompt should contain content from AGENTS.md in the working directory")
+		t.Logf("AGENTS.md content: %s", agentsContent)
+		t.Logf("Generated prompt (first 2000 chars): %s", prompt[:min(len(prompt), 2000)])
+	}
+
+	// Verify the file path is mentioned in guidance section
+	if !strings.Contains(prompt, agentsFile) {
+		t.Errorf("system prompt should reference the AGENTS.md file path")
+	}
+}
+
+// TestSystemPromptEmptyCwdFallsBackToCurrentDir verifies that an empty workingDir
+// causes GenerateSystemPrompt to use the current directory.
+func TestSystemPromptEmptyCwdFallsBackToCurrentDir(t *testing.T) {
+	// Get current directory for comparison
+	currentDir, err := os.Getwd()
+	if err != nil {
+		t.Fatalf("failed to get current directory: %v", err)
+	}
+
+	// Generate system prompt with empty workingDir
+	prompt, err := GenerateSystemPrompt("")
+	if err != nil {
+		t.Fatalf("GenerateSystemPrompt failed: %v", err)
+	}
+
+	// Verify the current directory is mentioned in the prompt
+	if !strings.Contains(prompt, currentDir) {
+		t.Errorf("system prompt should contain current directory when cwd is empty")
+	}
+}
+
+func min(a, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}