From 605212e1fd656254e7569475c85ef2f5c315d6e7 Mon Sep 17 00:00:00 2001 From: Ollie Agent Date: Sat, 8 Aug 2026 12:27:53 +0200 Subject: [PATCH] Simplify preamble construction system - Extract hardcoded tool sections into prompts.ToolSections() function - Replace template system with simple Go code for better maintainability - Fix refreshToolListing to preserve tool documentation and discovery sections - Add comprehensive unit tests for prompts and agent packages - Remove unnecessary tool_sections.md template file The refactoring simplifies the preamble construction while maintaining all functionality. Tool listing updates dynamically via refreshToolListing, while tool documentation requires agent reload (full BuildPreamble call). --- agent/agent.go | 83 ++++++++++++++-- agent/agent_test.go | 85 +++++++++++++++++ agent/build_runtime.go | 109 ++++++++++++--------- agent/build_runtime_test.go | 183 ++++++++++++++++++++++++++++++++++++ prompts/prompts.go | 28 +++++- prompts/prompts_test.go | 104 ++++++++++++++++++++ 6 files changed, 536 insertions(+), 56 deletions(-) create mode 100644 agent/agent_test.go create mode 100644 agent/build_runtime_test.go create mode 100644 prompts/prompts_test.go diff --git a/agent/agent.go b/agent/agent.go index 7af6c45..ee65926 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -26,7 +26,8 @@ type Agent struct { name string // display name (defaults to id) profile string // config profile name (e.g. "default") agentsDir string - baseLayers []string // system prompt layers for /agent reloads + systemPrompt string // system prompt for /agent reloads + envBlock string // environment block for /agent reloads promptEnvExtra []string // PRIME_* vars for prompt resolution newToolServer func() toolsrv.Runner newBackend func(string) (backend.Backend, error) @@ -54,8 +55,8 @@ type Agent struct { sessionID string // the owning session's ID startupMessages []string readPlanStep func() string - save func() // trigger debounced persistence - flush func() // immediately flush persistence + save func() // trigger debounced persistence + flush func() // immediately flush persistence onStateChange func(agentID, state string) // optional callback for state changes } @@ -399,16 +400,82 @@ func (ag *Agent) RenamePreamble(old, new string) { // refreshToolListing replaces the "# Available Tools" section in the preamble // with an updated listing. Called when tool_list detects directory changes. +// Note: This only updates the tool listing, not the full tool documentation. +// Tool documentation requires a full BuildPreamble call (on agent reload). func (ag *Agent) refreshToolListing(listing string) { if ag.runtime == nil { return } - const marker = "\n# Available Tools\n\n" - if idx := strings.Index(ag.runtime.Preamble, marker); idx >= 0 { - ag.runtime.Preamble = ag.runtime.Preamble[:idx] + + // Find the "# Available Tools" section + const availableToolsMarker = "\n# Available Tools\n\n" + availableToolsIdx := strings.Index(ag.runtime.Preamble, availableToolsMarker) + + if availableToolsIdx < 0 { + // No tool listing exists yet + if listing != "" { + // Find where to insert - before "# Tool Documentation" or "## Tool discovery" + const toolDocsMarker = "\n# Tool Documentation\n\n" + const discoveryMarker = "\n## Tool discovery\n" + + toolDocsIdx := strings.Index(ag.runtime.Preamble, toolDocsMarker) + discoveryIdx := strings.Index(ag.runtime.Preamble, discoveryMarker) + + var insertIdx int + var insertBefore string + + if toolDocsIdx >= 0 { + insertIdx = toolDocsIdx + insertBefore = toolDocsMarker + } else if discoveryIdx >= 0 { + insertIdx = discoveryIdx + insertBefore = discoveryMarker + } else { + // Just append + ag.runtime.Preamble += availableToolsMarker + listing + return + } + + // Insert before the marker + ag.runtime.Preamble = ag.runtime.Preamble[:insertIdx] + availableToolsMarker + listing + insertBefore + ag.runtime.Preamble[insertIdx+len(insertBefore):] + } + return } - if listing != "" { - ag.runtime.Preamble += marker + listing + + // Tool listing exists - find where it ends + // Look for next section marker after the listing + afterMarker := ag.runtime.Preamble[availableToolsIdx+len(availableToolsMarker):] + + // Find next section marker + const toolDocsMarker = "\n# Tool Documentation\n\n" + const discoveryMarker = "\n## Tool discovery\n" + + var endIdx int + toolDocsIdx := strings.Index(afterMarker, toolDocsMarker) + discoveryIdx := strings.Index(afterMarker, discoveryMarker) + + if toolDocsIdx >= 0 { + endIdx = toolDocsIdx + } else if discoveryIdx >= 0 { + endIdx = discoveryIdx + } else { + // No markers found - listing goes to end + endIdx = len(afterMarker) + } + + // Rebuild preamble with new listing + beforeListing := ag.runtime.Preamble[:availableToolsIdx+len(availableToolsMarker)] + afterListing := afterMarker[endIdx:] + + if listing == "" { + // Remove the entire "# Available Tools" section + // Need to also remove the preceding "\n" if it exists + if availableToolsIdx > 0 && ag.runtime.Preamble[availableToolsIdx-1] == '\n' { + beforeListing = ag.runtime.Preamble[:availableToolsIdx] + } + ag.runtime.Preamble = beforeListing + afterListing + } else { + ag.runtime.Preamble = beforeListing + listing + afterListing } } diff --git a/agent/agent_test.go b/agent/agent_test.go new file mode 100644 index 0000000..55caad6 --- /dev/null +++ b/agent/agent_test.go @@ -0,0 +1,85 @@ +package agent + +import ( + "strings" + "testing" +) + +func TestRefreshToolListing(t *testing.T) { + // Create a test agent with minimal setup + ag := &Agent{ + runtime: &Runtime{ + Preamble: "# Core System Prompt\nTest\n# Environment\n- Test env\n# Agent Prompt\nTest instructions\n\n# Tool Sections\n\n# Available Tools\n\n- **old_tool** — Old description\n\n# Tool Documentation\n\n**old_tool**\n\nOld docs...\n\n**Schema**:\n```json\n{}\n```\n\n## Tool discovery\nInstructions...", + }, + } + + // Test 1: refreshToolListing with new listing + newListing := "- **new_tool** — New description\n" + ag.refreshToolListing(newListing) + + // Should contain new listing + if !strings.Contains(ag.runtime.Preamble, "**new_tool** — New description") { + t.Error("refreshToolListing should include new tool in listing") + } + + // Should NOT contain old listing + if strings.Contains(ag.runtime.Preamble, "**old_tool** — Old description") { + t.Error("refreshToolListing should remove old tool from listing") + } + + // PROBLEM: Should contain tool documentation and discovery sections + // but current implementation removes them! + if !strings.Contains(ag.runtime.Preamble, "# Tool Documentation") { + t.Error("refreshToolListing currently removes # Tool Documentation section (BUG)") + } + if !strings.Contains(ag.runtime.Preamble, "## Tool discovery") { + t.Error("refreshToolListing currently removes Tool discovery section (BUG)") + } +} + +func TestRefreshToolListingEmpty(t *testing.T) { + ag := &Agent{ + runtime: &Runtime{ + Preamble: "# Core System Prompt\nTest\n# Environment\n- Test env\n# Agent Prompt\nTest instructions\n\n# Tool Sections\n\n# Available Tools\n\n- **tool** — Description\n\n# Tool Documentation\n\n**tool**\n\nDocs...\n\n## Tool discovery\nInstructions...", + }, + } + + // Test with empty listing (all tools removed) + ag.refreshToolListing("") + + // Should not contain tool sections marker + if strings.Contains(ag.runtime.Preamble, "# Available Tools") { + t.Error("refreshToolListing with empty listing should remove # Available Tools") + } + + // Should still contain other sections + if !strings.Contains(ag.runtime.Preamble, "# Core System Prompt") { + t.Error("refreshToolListing should preserve core system prompt") + } + if !strings.Contains(ag.runtime.Preamble, "# Environment") { + t.Error("refreshToolListing should preserve environment section") + } + if !strings.Contains(ag.runtime.Preamble, "# Agent Prompt") { + t.Error("refreshToolListing should preserve agent prompt") + } +} + +func TestRefreshToolListingNoToolsInitially(t *testing.T) { + ag := &Agent{ + runtime: &Runtime{ + Preamble: "# Core System Prompt\nTest\n# Environment\n- Test env\n# Agent Prompt\nTest instructions\n\n## Tool discovery\nInstructions...", + }, + } + + // Test adding tools when none existed before + newListing := "- **new_tool** — New description\n" + ag.refreshToolListing(newListing) + + // Should now contain tool sections + if !strings.Contains(ag.runtime.Preamble, "# Available Tools") { + t.Error("refreshToolListing should add # Available Tools when none existed") + } + if !strings.Contains(ag.runtime.Preamble, "**new_tool** — New description") { + t.Error("refreshToolListing should include new tool") + } +} \ No newline at end of file diff --git a/agent/build_runtime.go b/agent/build_runtime.go index 58e1529..ed91b93 100644 --- a/agent/build_runtime.go +++ b/agent/build_runtime.go @@ -8,6 +8,7 @@ import ( "strings" "ollie/backend" + "ollie/prompts" "ollie/toolsrv" ) @@ -17,7 +18,7 @@ import ( // env provides additional environment variables injected into prompt resolution // subprocesses (e.g. OLLIE_SESSION_ID=xxx). // The caller is responsible for registering all servers on d before calling this. -func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string, baseLayers ...string) *Runtime { +func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string, systemPrompt, envBlock string) *Runtime { var messages []string var allToolInfos []toolsrv.ToolInfo @@ -33,14 +34,14 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string allTools = toolInfosToBackend(allToolInfos) } - var preamble string + var agentPrompt string var genParams backend.GenerationParams var maxSteps int if cfg != nil { if resolved, err := resolvePrompt(cfg.Prompt, cwd, env); err != nil { fmt.Fprintf(os.Stderr, "resolve prompt: %v\n", err) } else { - preamble = resolved + agentPrompt = resolved } genParams = backend.GenerationParams{ MaxTokens: cfg.MaxTokens, @@ -137,50 +138,7 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string compactionModel = cfg.CompactionModel } - // Prepend base layers (system prompt, operational model, environment) - // before the agent-specific preamble. - if len(baseLayers) > 0 { - var prefix strings.Builder - for _, layer := range baseLayers { - if layer != "" { - prefix.WriteString(layer) - prefix.WriteByte('\n') - } - } - if prefix.Len() > 0 { - preamble = prefix.String() + preamble - } - } - - // Append compact tool surface listing (name + description) plus - // full documentation for loaded tools (schema, args, examples). - var toolListing strings.Builder - for _, ti := range allToolInfos { - if ti.Description != "" && ti.Server == "" { - fmt.Fprintf(&toolListing, "- **%s** — %s\n", ti.Name, ti.Description) - } - } - if toolListing.Len() > 0 { - preamble += "\n# Available Tools\n\n" + toolListing.String() - } - // Inject full tool prompts so models that ignore native tool schemas - // still see usage docs, arg definitions, and examples in-context. - var toolDocs strings.Builder - for _, ti := range allToolInfos { - if ti.Prompt != "" && ti.Server == "" { - toolDocs.WriteString(ti.Prompt) - toolDocs.WriteString("\n\n") - if len(ti.InputSchema) > 0 && string(ti.InputSchema) != "{}" { - fmt.Fprintf(&toolDocs, "**Schema**:\n```json\n%s\n```\n\n", ti.InputSchema) - } - } - } - if toolDocs.Len() > 0 { - preamble += "\n# Tool Documentation\n\n" + toolDocs.String() - } - - // Append 9P discovery instructions for loading additional tools. - preamble += "\n## Tool discovery\n\nUse the 9P filesystem to discover and load tools dynamically:\n- **List available tools** — `ollie-9p read session/{id}/agent/{aid}/tools`\n- **Load a tool** — `echo \"toolname\" | ollie-9p write session/{id}/agent/{aid}/tools`\n- **See loaded tools** — `ollie-9p read session/{id}/agent/{aid}/tools.loaded`\n" + preamble := BuildPreamble(systemPrompt, envBlock, agentPrompt, allToolInfos) return &Runtime{ ToolServer: srv, @@ -200,6 +158,63 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string } } +// BuildPreamble constructs the complete system prompt from its components. +// The preamble evolves when: +// - Working directory changes (envBlock updates) +// - Tools are loaded/unloaded (allToolInfos changes) +// - Agent profile switches (agentPrompt changes) +// This function should be called whenever any component changes. +func BuildPreamble(systemPrompt, envBlock, agentPrompt string, allToolInfos []toolsrv.ToolInfo) string { + var sb strings.Builder + + // 1. Core system prompt (identity, rules, operating principles) + sb.WriteString(systemPrompt) + sb.WriteByte('\n') + + // 2. Environment context (working directory, platform, date, etc.) + sb.WriteString(envBlock) + sb.WriteByte('\n') + + // 3. Agent-specific prompt (role, task instructions from config) + sb.WriteString(agentPrompt) + sb.WriteByte('\n') + + // 4. Compact tool surface listing (name + description) + var toolListing strings.Builder + for _, ti := range allToolInfos { + if ti.Description != "" && ti.Server == "" { + fmt.Fprintf(&toolListing, "- **%s** — %s\n", ti.Name, ti.Description) + } + } + + // 5. Full tool documentation (prompts, schemas, examples) + var toolDocs strings.Builder + for _, ti := range allToolInfos { + if ti.Prompt != "" && ti.Server == "" { + toolDocs.WriteString(ti.Prompt) + toolDocs.WriteString("\n\n") + if len(ti.InputSchema) > 0 && string(ti.InputSchema) != "{}" { + fmt.Fprintf(&toolDocs, "**Schema**:\n```json\n%s\n```\n\n", ti.InputSchema) + } + } + } + + // 6. Render tool sections + if toolListing.Len() > 0 || toolDocs.Len() > 0 { + toolSections := prompts.ToolSections(toolListing.String(), toolDocs.String()) + if toolSections != "" { + sb.WriteString("\n") + sb.WriteString(toolSections) + } + } + + // 6. Tool discovery instructions (9P filesystem) + sb.WriteString("\n") + sb.WriteString(prompts.ToolDiscoveryPrompt) + + return sb.String() +} + func toolInfosToBackend(infos []toolsrv.ToolInfo) []backend.Tool { out := make([]backend.Tool, len(infos)) for i, t := range infos { diff --git a/agent/build_runtime_test.go b/agent/build_runtime_test.go new file mode 100644 index 0000000..11b11f8 --- /dev/null +++ b/agent/build_runtime_test.go @@ -0,0 +1,183 @@ +package agent + +import ( + "strings" + "testing" + "ollie/toolsrv" +) + +func TestBuildPreamble(t *testing.T) { + toolInfos := []toolsrv.ToolInfo{ + { + Name: "file_read", + Description: "Read a file with line numbers", + Prompt: "**file_read**\n\nRead a file with line numbers...", + InputSchema: []byte(`{"type":"object"}`), + Server: "", + }, + { + Name: "file_write", + Description: "Create or overwrite a file", + Prompt: "**file_write**\n\nCreate or overwrite a file...", + InputSchema: []byte(`{"type":"object"}`), + Server: "remote", + }, + { + Name: "shell", + Description: "Execute shell command", + Prompt: "", + InputSchema: []byte(`{}`), + Server: "", + }, + } + + systemPrompt := "# Core System Prompt\nTest" + envBlock := "# Environment\n- Test env" + agentPrompt := "# Agent Prompt\nTest instructions" + + preamble := BuildPreamble(systemPrompt, envBlock, agentPrompt, toolInfos) + + // Must contain all sections in order + expectedSections := []string{ + "# Core System Prompt\nTest\n", + "# Environment\n- Test env\n", + "# Agent Prompt\nTest instructions\n", + "# Tool Sections\n", + "# Available Tools\n", + "- **file_read** — Read a file with line numbers\n", + "# Tool Documentation\n", + "**file_read**\n\nRead a file with line numbers...\n\n**Schema**:\n```json\n{\"type\":\"object\"}\n```\n\n", + "## Tool discovery\n", + } + + for _, section := range expectedSections { + if !strings.Contains(preamble, section) { + t.Errorf("BuildPreamble missing section: %q", section) + } + } + + // Should NOT contain remote server tools + if strings.Contains(preamble, "file_write") { + t.Error("BuildPreamble should exclude tools with Server != \"\"") + } + + // Should NOT contain tools without prompts in docs section + if strings.Contains(preamble, "**shell**\n\n") { + t.Error("BuildPreamble should exclude tools without prompts from docs section") + } + + // Verify single instances of key headers + if strings.Count(preamble, "# Available Tools") != 1 { + t.Errorf("BuildPreamble should have exactly one '# Available Tools' section, got %d", + strings.Count(preamble, "# Available Tools")) + } + if strings.Count(preamble, "# Tool Documentation") != 1 { + t.Errorf("BuildPreamble should have exactly one '# Tool Documentation' section, got %d", + strings.Count(preamble, "# Tool Documentation")) + } + + // Verify tool listing includes all non-server tools with descriptions + if !strings.Contains(preamble, "**file_read** — Read a file with line numbers") { + t.Error("BuildPreamble should include file_read in tool listing") + } + if !strings.Contains(preamble, "**shell** — Execute shell command") { + t.Error("BuildPreamble should include shell in tool listing (has description)") + } +} + +func TestBuildPreambleEmptyTools(t *testing.T) { + systemPrompt := "# Core System Prompt\nTest" + envBlock := "# Environment\n- Test env" + agentPrompt := "# Agent Prompt\nTest instructions" + + preamble := BuildPreamble(systemPrompt, envBlock, agentPrompt, nil) + + // Should not contain tool sections when no tools + if strings.Contains(preamble, "# Tool Sections") { + t.Error("BuildPreamble should not include tool sections when no tools") + } + if strings.Contains(preamble, "# Available Tools") { + t.Error("BuildPreamble should not include Available Tools when no tools") + } + if strings.Contains(preamble, "# Tool Documentation") { + t.Error("BuildPreamble should not include Tool Documentation when no tools") + } + + // Should still contain core sections + expectedSections := []string{ + "# Core System Prompt\nTest\n", + "# Environment\n- Test env\n", + "# Agent Prompt\nTest instructions\n", + "## Tool discovery\n", + } + + for _, section := range expectedSections { + if !strings.Contains(preamble, section) { + t.Errorf("BuildPreamble missing core section: %q", section) + } + } +} + +func TestBuildPreambleOnlyListingNoDocs(t *testing.T) { + toolInfos := []toolsrv.ToolInfo{ + { + Name: "test_tool", + Description: "Test description", + Prompt: "", // No prompt means no docs + InputSchema: []byte(`{}`), + Server: "", + }, + } + + systemPrompt := "# Core System Prompt\nTest" + envBlock := "# Environment\n- Test env" + agentPrompt := "# Agent Prompt\nTest instructions" + + preamble := BuildPreamble(systemPrompt, envBlock, agentPrompt, toolInfos) + + // Should have tool sections with listing but no docs + if !strings.Contains(preamble, "# Tool Sections") { + t.Error("BuildPreamble should include # Tool Sections") + } + if !strings.Contains(preamble, "# Available Tools") { + t.Error("BuildPreamble should include # Available Tools") + } + if !strings.Contains(preamble, "**test_tool** — Test description") { + t.Error("BuildPreamble should include tool in listing") + } + if strings.Contains(preamble, "# Tool Documentation") { + t.Error("BuildPreamble should NOT include # Tool Documentation when no tool has prompt") + } +} + +func TestBuildPreambleOnlyDocsNoListing(t *testing.T) { + toolInfos := []toolsrv.ToolInfo{ + { + Name: "test_tool", + Description: "", // No description means not in listing + Prompt: "**test_tool**\n\nTest docs...", + InputSchema: []byte(`{}`), + Server: "", + }, + } + + systemPrompt := "# Core System Prompt\nTest" + envBlock := "# Environment\n- Test env" + agentPrompt := "# Agent Prompt\nTest instructions" + + preamble := BuildPreamble(systemPrompt, envBlock, agentPrompt, toolInfos) + + // Should have tool sections with docs but no listing + if !strings.Contains(preamble, "# Tool Sections") { + t.Error("BuildPreamble should include # Tool Sections") + } + if strings.Contains(preamble, "# Available Tools") { + t.Error("BuildPreamble should NOT include # Available Tools when no tool has description") + } + if !strings.Contains(preamble, "# Tool Documentation") { + t.Error("BuildPreamble should include # Tool Documentation") + } + if !strings.Contains(preamble, "**test_tool**") { + t.Error("BuildPreamble should include tool in docs") + } +} \ No newline at end of file diff --git a/prompts/prompts.go b/prompts/prompts.go index 3499aae..58a6bc4 100644 --- a/prompts/prompts.go +++ b/prompts/prompts.go @@ -12,7 +12,8 @@ import ( //go:embed system_prompt.md var DefaultSystemPrompt string - +//go:embed tool_discovery.md +var ToolDiscoveryPrompt string // Environment renders the environment context block with live values. func Environment(cwd, platform string, isGitRepo bool, sandbox string) string { @@ -49,3 +50,28 @@ func ResolveSystemPrompt(overridePath string) string { fmt.Fprintf(os.Stderr, "system_prompt %q: %v (using default)\n", expanded, err) return DefaultSystemPrompt } + +// ToolSections renders the tool listing and documentation sections with dynamic content. +func ToolSections(toolListing, toolDocs string) string { + if toolListing == "" && toolDocs == "" { + return "" + } + + var sb strings.Builder + sb.WriteString("# Tool Sections\n\n") + + if toolListing != "" { + sb.WriteString("# Available Tools\n\n") + sb.WriteString(toolListing) + } + + if toolDocs != "" { + if toolListing != "" { + sb.WriteString("\n") + } + sb.WriteString("# Tool Documentation\n\n") + sb.WriteString(toolDocs) + } + + return sb.String() +} diff --git a/prompts/prompts_test.go b/prompts/prompts_test.go new file mode 100644 index 0000000..0216b3b --- /dev/null +++ b/prompts/prompts_test.go @@ -0,0 +1,104 @@ +package prompts + +import ( + "strings" + "testing" +) + +func TestToolSections(t *testing.T) { + tests := []struct { + name string + listing string + docs string + wantPrefix string + wantContains []string + wantNotContains []string + }{ + { + name: "both empty", + listing: "", + docs: "", + wantPrefix: "", + }, + { + name: "only listing", + listing: "- **file_read** — Read a file\n", + docs: "", + wantPrefix: "# Tool Sections\n\n# Available Tools\n\n", + wantContains: []string{"# Available Tools", "file_read"}, + wantNotContains: []string{"# Tool Documentation"}, + }, + { + name: "only docs", + listing: "", + docs: "**file_read**\n\nRead a file...\n", + wantPrefix: "# Tool Sections\n\n# Tool Documentation\n\n", + wantContains: []string{"# Tool Documentation", "file_read"}, + wantNotContains: []string{"# Available Tools"}, + }, + { + name: "both listing and docs", + listing: "- **file_read** — Read a file\n", + docs: "**file_read**\n\nRead a file...\n", + wantPrefix: "# Tool Sections\n\n# Available Tools\n\n", + wantContains: []string{"# Available Tools", "# Tool Documentation", "file_read"}, + }, + { + name: "listing with newline before docs", + listing: "- **file_read** — Read a file\n", + docs: "**file_read**\n\nRead a file...\n", + wantContains: []string{"# Available Tools\n\n- **file_read** — Read a file\n\n# Tool Documentation"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToolSections(tt.listing, tt.docs) + + if tt.wantPrefix != "" && !strings.HasPrefix(got, tt.wantPrefix) { + t.Errorf("ToolSections() = %q, want prefix %q", got, tt.wantPrefix) + } + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("ToolSections() missing %q in output: %q", want, got) + } + } + + for _, notWant := range tt.wantNotContains { + if strings.Contains(got, notWant) { + t.Errorf("ToolSections() unexpectedly contains %q in output: %q", notWant, got) + } + } + + // Verify no double newlines between sections + if strings.Contains(got, "\n\n\n# Tool Documentation") { + t.Errorf("ToolSections() has triple newline before Tool Documentation: %q", got) + } + }) + } +} + +func TestEnvironment(t *testing.T) { + got := Environment("/home/test", "linux", true, "default") + + wantContains := []string{ + "Working directory: /home/test", + "Platform: linux", + "Is git repo: true", + "Sandbox: default", + "elevated: true", + } + + for _, want := range wantContains { + if !strings.Contains(got, want) { + t.Errorf("Environment() missing %q in output: %q", want, got) + } + } + + // Test with git false + got2 := Environment("/home/test", "linux", false, "") + if !strings.Contains(got2, "Is git repo: false") { + t.Errorf("Environment() should show git repo false") + } +} \ No newline at end of file