Detailed changes
@@ -31,6 +31,8 @@ func CoderAgentTools(
tools.NewGlobTool(),
tools.NewGrepTool(),
tools.NewLsTool(),
+ // TODO: see if we want to use this tool
+ // tools.NewPatchTool(lspClients, permissions, history),
tools.NewSourcegraphTool(),
tools.NewViewTool(lspClients),
tools.NewWriteTool(lspClients, permissions, history),
@@ -25,44 +25,63 @@ func CoderPrompt(provider models.ModelProvider) string {
}
const baseOpenAICoderPrompt = `
-You are **OpenCode**, an autonomous CLI assistant for software‑engineering tasks.
-
-### ── INTERNAL REFLECTION ──
-• Silently think step‑by‑step about the user request, directory layout, and tool calls (never reveal this).
-• Formulate a plan, then execute without further approval unless a blocker triggers the Ask‑Only‑If rules.
-
-### ── PUBLIC RESPONSE RULES ──
-• Visible reply ≤ 4 lines; no fluff, preamble, or postamble.
-• Use GitHub‑flavored Markdown.
-• When running a non‑trivial shell command, add ≤ 1 brief purpose sentence.
-
-### ── CONTEXT & MEMORY ──
-• Infer file intent from directory structure before editing.
-• Auto‑load 'OpenCode.md'; ask once before writing new reusable commands or style notes.
-
-### ── AUTONOMY PRIORITY ──
-**Ask‑Only‑If Decision Tree:**
-1. **Safety risk?** (e.g., destructive command, secret exposure) → ask.
-2. **Critical unknown?** (no docs/tests; cannot infer) → ask.
-3. **Tool failure after two self‑attempts?** → ask.
-Otherwise, proceed autonomously.
-
-### ── SAFETY & STYLE ──
-• Mimic existing code style; verify libraries exist before import.
-• Never commit unless explicitly told.
-• After edits, run lint & type‑check (ask for commands once, then offer to store in 'OpenCode.md').
-• Protect secrets; follow standard security practices :contentReference[oaicite:2]{index=2}.
-
-### ── TOOL USAGE ──
-• Batch independent Agent search/file calls in one block for efficiency :contentReference[oaicite:3]{index=3}.
-• Communicate with the user only via visible text; do not expose tool output or internal reasoning.
-
-### ── EXAMPLES ──
-user: list files
-assistant: ls
-
-user: write tests for new feature
-assistant: [searches & edits autonomously, no extra chit‑chat]
+# OpenCode CLI Agent Prompt
+
+You are operating within the **OpenCode CLI**, a terminal-based, agentic coding assistant that interfaces with local codebases through natural language. Your primary objectives are to be precise, safe, and helpful.
+
+## Capabilities
+
+- Receive user prompts, project context, and files.
+- Stream responses and emit function calls (e.g., shell commands, code edits).
+- Apply patches, run commands, and manage user approvals based on policy.
+- Operate within a sandboxed, git-backed workspace with rollback support.
+- Log telemetry for session replay or inspection.
+- Access detailed functionality via the help command.
+
+## Operational Guidelines
+
+### 1. Task Resolution
+
+- Continue processing until the user's query is fully resolved.
+- Only conclude your turn when confident the problem is solved.
+- If uncertain about file content or codebase structure, utilize available tools to gather necessary information—avoid assumptions.
+
+### 2. Code Modification & Testing
+
+- Edit and test code files within your current execution session.
+- Work on the local repositories, even if proprietary.
+- Analyze code for vulnerabilities when applicable.
+- Display user code and tool call details transparently.
+
+### 3. Coding Guidelines
+
+- Address root causes rather than applying superficial fixes.
+- Avoid unnecessary complexity; focus on the task at hand.
+- Update documentation as needed.
+- Maintain consistency with the existing codebase style.
+- Utilize version control tools for additional context; note that internet access is disabled.
+- Refrain from adding copyright or license headers unless explicitly requested.
+- No need to perform commit operations; this will be handled automatically.
+- If a pre-commit configuration file exists, run the appropriate checks to ensure changes pass. Do not fix pre-existing errors on untouched lines.
+- If pre-commit checks fail after retries, inform the user that the setup may be broken.
+
+### 4. Post-Modification Checks
+
+- Use version control status commands to verify changes; revert any unintended modifications.
+- Remove all added inline comments unless they are essential for understanding.
+- Ensure no accidental addition of copyright or license headers.
+- Attempt to run pre-commit checks if available.
+- For smaller tasks, provide brief bullet points summarizing changes.
+- For complex tasks, include a high-level description, bullet points, and relevant details for code reviewers.
+
+### 5. Non-Code Modification Tasks
+
+- Respond in a friendly, collaborative tone, akin to a knowledgeable remote teammate eager to assist with coding inquiries.
+
+### 6. File Handling
+
+- Do not instruct the user to save or copy code into files if modifications have already been made using the editing tools.
+- Avoid displaying full contents of large files unless explicitly requested by the user.
`
const baseAnthropicCoderPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
@@ -1,340 +0,0 @@
-package tools
-
-import (
- "context"
- "encoding/json"
- "os"
- "strings"
- "testing"
- "time"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestBashTool_Info(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(true))
- info := tool.Info()
-
- assert.Equal(t, BashToolName, info.Name)
- assert.NotEmpty(t, info.Description)
- assert.Contains(t, info.Parameters, "command")
- assert.Contains(t, info.Parameters, "timeout")
- assert.Contains(t, info.Required, "command")
-}
-
-func TestBashTool_Run(t *testing.T) {
- // Save original working directory
- origWd, err := os.Getwd()
- require.NoError(t, err)
- defer func() {
- os.Chdir(origWd)
- }()
-
- t.Run("executes command successfully", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(true))
- params := BashParams{
- Command: "echo 'Hello World'",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: BashToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Equal(t, "Hello World\n", response.Content)
- })
-
- t.Run("handles invalid parameters", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(true))
- call := ToolCall{
- Name: BashToolName,
- Input: "invalid json",
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "invalid parameters")
- })
-
- t.Run("handles missing command", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(true))
- params := BashParams{
- Command: "",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: BashToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "missing command")
- })
-
- t.Run("handles banned commands", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(true))
-
- for _, bannedCmd := range bannedCommands {
- params := BashParams{
- Command: bannedCmd + " arg1 arg2",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: BashToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "not allowed", "Command %s should be blocked", bannedCmd)
- }
- })
-
- t.Run("handles multi-word safe commands without permission check", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(false))
-
- // Test with multi-word safe commands
- multiWordCommands := []string{
- "go env",
- }
-
- for _, cmd := range multiWordCommands {
- params := BashParams{
- Command: cmd,
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: BashToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.NotContains(t, response.Content, "permission denied",
- "Command %s should be allowed without permission", cmd)
- }
- })
-
- t.Run("handles permission denied", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(false))
-
- // Test with a command that requires permission
- params := BashParams{
- Command: "mkdir test_dir",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: BashToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "permission denied")
- })
-
- t.Run("handles command timeout", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(true))
- params := BashParams{
- Command: "sleep 2",
- Timeout: 100, // 100ms timeout
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: BashToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "aborted")
- })
-
- t.Run("handles command with stderr output", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(true))
- params := BashParams{
- Command: "echo 'error message' >&2",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: BashToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "error message")
- })
-
- t.Run("handles command with both stdout and stderr", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(true))
- params := BashParams{
- Command: "echo 'stdout message' && echo 'stderr message' >&2",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: BashToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "stdout message")
- assert.Contains(t, response.Content, "stderr message")
- })
-
- t.Run("handles context cancellation", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(true))
- params := BashParams{
- Command: "sleep 5",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: BashToolName,
- Input: string(paramsJSON),
- }
-
- ctx, cancel := context.WithCancel(context.Background())
-
- // Cancel the context after a short delay
- go func() {
- time.Sleep(100 * time.Millisecond)
- cancel()
- }()
-
- response, err := tool.Run(ctx, call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "aborted")
- })
-
- t.Run("respects max timeout", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(true))
- params := BashParams{
- Command: "echo 'test'",
- Timeout: MaxTimeout + 1000, // Exceeds max timeout
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: BashToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Equal(t, "test\n", response.Content)
- })
-
- t.Run("uses default timeout for zero or negative timeout", func(t *testing.T) {
- tool := NewBashTool(newMockPermissionService(true))
- params := BashParams{
- Command: "echo 'test'",
- Timeout: -100, // Negative timeout
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: BashToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Equal(t, "test\n", response.Content)
- })
-}
-
-func TestTruncateOutput(t *testing.T) {
- t.Run("does not truncate short output", func(t *testing.T) {
- output := "short output"
- result := truncateOutput(output)
- assert.Equal(t, output, result)
- })
-
- t.Run("truncates long output", func(t *testing.T) {
- // Create a string longer than MaxOutputLength
- longOutput := strings.Repeat("a\n", MaxOutputLength)
- result := truncateOutput(longOutput)
-
- // Check that the result is shorter than the original
- assert.Less(t, len(result), len(longOutput))
-
- // Check that the truncation message is included
- assert.Contains(t, result, "lines truncated")
-
- // Check that we have the beginning and end of the original string
- assert.True(t, strings.HasPrefix(result, "a\n"))
- assert.True(t, strings.HasSuffix(result, "a\n"))
- })
-}
-
-func TestCountLines(t *testing.T) {
- testCases := []struct {
- name string
- input string
- expected int
- }{
- {
- name: "empty string",
- input: "",
- expected: 0,
- },
- {
- name: "single line",
- input: "line1",
- expected: 1,
- },
- {
- name: "multiple lines",
- input: "line1\nline2\nline3",
- expected: 3,
- },
- {
- name: "trailing newline",
- input: "line1\nline2\n",
- expected: 3, // Empty string after last newline counts as a line
- },
- }
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- result := countLines(tc.input)
- assert.Equal(t, tc.expected, result)
- })
- }
-}
@@ -1,461 +0,0 @@
-package tools
-
-import (
- "context"
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/kujtimiihoxha/opencode/internal/lsp"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestEditTool_Info(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
- info := tool.Info()
-
- assert.Equal(t, EditToolName, info.Name)
- assert.NotEmpty(t, info.Description)
- assert.Contains(t, info.Parameters, "file_path")
- assert.Contains(t, info.Parameters, "old_string")
- assert.Contains(t, info.Parameters, "new_string")
- assert.Contains(t, info.Required, "file_path")
- assert.Contains(t, info.Required, "old_string")
- assert.Contains(t, info.Required, "new_string")
-}
-
-func TestEditTool_Run(t *testing.T) {
- // Create a temporary directory for testing
- tempDir, err := os.MkdirTemp("", "edit_tool_test")
- require.NoError(t, err)
- defer os.RemoveAll(tempDir)
-
- t.Run("creates a new file successfully", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- filePath := filepath.Join(tempDir, "new_file.txt")
- content := "This is a test content"
-
- params := EditParams{
- FilePath: filePath,
- OldString: "",
- NewString: content,
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "File created")
-
- // Verify file was created with correct content
- fileContent, err := os.ReadFile(filePath)
- require.NoError(t, err)
- assert.Equal(t, content, string(fileContent))
- })
-
- t.Run("creates file with nested directories", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
- content := "Content in nested directory"
-
- params := EditParams{
- FilePath: filePath,
- OldString: "",
- NewString: content,
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "File created")
-
- // Verify file was created with correct content
- fileContent, err := os.ReadFile(filePath)
- require.NoError(t, err)
- assert.Equal(t, content, string(fileContent))
- })
-
- t.Run("fails to create file that already exists", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a file first
- filePath := filepath.Join(tempDir, "existing_file.txt")
- initialContent := "Initial content"
- err := os.WriteFile(filePath, []byte(initialContent), 0o644)
- require.NoError(t, err)
-
- // Try to create the same file
- params := EditParams{
- FilePath: filePath,
- OldString: "",
- NewString: "New content",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "file already exists")
- })
-
- t.Run("fails to create file when path is a directory", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a directory
- dirPath := filepath.Join(tempDir, "test_dir")
- err := os.Mkdir(dirPath, 0o755)
- require.NoError(t, err)
-
- // Try to create a file with the same path as the directory
- params := EditParams{
- FilePath: dirPath,
- OldString: "",
- NewString: "Some content",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "path is a directory")
- })
-
- t.Run("replaces content successfully", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a file first
- filePath := filepath.Join(tempDir, "replace_content.txt")
- initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
- err := os.WriteFile(filePath, []byte(initialContent), 0o644)
- require.NoError(t, err)
-
- // Record the file read to avoid modification time check failure
- recordFileRead(filePath)
-
- // Replace content
- oldString := "Line 2\nLine 3"
- newString := "Line 2 modified\nLine 3 modified"
- params := EditParams{
- FilePath: filePath,
- OldString: oldString,
- NewString: newString,
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "Content replaced")
-
- // Verify file was updated with correct content
- expectedContent := "Line 1\nLine 2 modified\nLine 3 modified\nLine 4\nLine 5"
- fileContent, err := os.ReadFile(filePath)
- require.NoError(t, err)
- assert.Equal(t, expectedContent, string(fileContent))
- })
-
- t.Run("deletes content successfully", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a file first
- filePath := filepath.Join(tempDir, "delete_content.txt")
- initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
- err := os.WriteFile(filePath, []byte(initialContent), 0o644)
- require.NoError(t, err)
-
- // Record the file read to avoid modification time check failure
- recordFileRead(filePath)
-
- // Delete content
- oldString := "Line 2\nLine 3\n"
- params := EditParams{
- FilePath: filePath,
- OldString: oldString,
- NewString: "",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "Content deleted")
-
- // Verify file was updated with correct content
- expectedContent := "Line 1\nLine 4\nLine 5"
- fileContent, err := os.ReadFile(filePath)
- require.NoError(t, err)
- assert.Equal(t, expectedContent, string(fileContent))
- })
-
- t.Run("handles invalid parameters", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- call := ToolCall{
- Name: EditToolName,
- Input: "invalid json",
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "invalid parameters")
- })
-
- t.Run("handles missing file_path", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- params := EditParams{
- FilePath: "",
- OldString: "old",
- NewString: "new",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "file_path is required")
- })
-
- t.Run("handles file not found", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- filePath := filepath.Join(tempDir, "non_existent_file.txt")
- params := EditParams{
- FilePath: filePath,
- OldString: "old content",
- NewString: "new content",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "file not found")
- })
-
- t.Run("handles old_string not found in file", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a file first
- filePath := filepath.Join(tempDir, "content_not_found.txt")
- initialContent := "Line 1\nLine 2\nLine 3"
- err := os.WriteFile(filePath, []byte(initialContent), 0o644)
- require.NoError(t, err)
-
- // Record the file read to avoid modification time check failure
- recordFileRead(filePath)
-
- // Try to replace content that doesn't exist
- params := EditParams{
- FilePath: filePath,
- OldString: "This content does not exist",
- NewString: "new content",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "old_string not found in file")
- })
-
- t.Run("handles multiple occurrences of old_string", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a file with duplicate content
- filePath := filepath.Join(tempDir, "duplicate_content.txt")
- initialContent := "Line 1\nDuplicate\nLine 3\nDuplicate\nLine 5"
- err := os.WriteFile(filePath, []byte(initialContent), 0o644)
- require.NoError(t, err)
-
- // Record the file read to avoid modification time check failure
- recordFileRead(filePath)
-
- // Try to replace content that appears multiple times
- params := EditParams{
- FilePath: filePath,
- OldString: "Duplicate",
- NewString: "Replaced",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "appears multiple times")
- })
-
- t.Run("handles file modified since last read", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a file
- filePath := filepath.Join(tempDir, "modified_file.txt")
- initialContent := "Initial content"
- err := os.WriteFile(filePath, []byte(initialContent), 0o644)
- require.NoError(t, err)
-
- // Record an old read time
- fileRecordMutex.Lock()
- fileRecords[filePath] = fileRecord{
- path: filePath,
- readTime: time.Now().Add(-1 * time.Hour),
- }
- fileRecordMutex.Unlock()
-
- // Try to update the file
- params := EditParams{
- FilePath: filePath,
- OldString: "Initial",
- NewString: "Updated",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "has been modified since it was last read")
-
- // Verify file was not modified
- fileContent, err := os.ReadFile(filePath)
- require.NoError(t, err)
- assert.Equal(t, initialContent, string(fileContent))
- })
-
- t.Run("handles file not read before editing", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a file
- filePath := filepath.Join(tempDir, "not_read_file.txt")
- initialContent := "Initial content"
- err := os.WriteFile(filePath, []byte(initialContent), 0o644)
- require.NoError(t, err)
-
- // Try to update the file without reading it first
- params := EditParams{
- FilePath: filePath,
- OldString: "Initial",
- NewString: "Updated",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "you must read the file before editing it")
- })
-
- t.Run("handles permission denied", func(t *testing.T) {
- tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false), newMockFileHistoryService())
-
- // Create a file
- filePath := filepath.Join(tempDir, "permission_denied.txt")
- initialContent := "Initial content"
- err := os.WriteFile(filePath, []byte(initialContent), 0o644)
- require.NoError(t, err)
-
- // Record the file read to avoid modification time check failure
- recordFileRead(filePath)
-
- // Try to update the file
- params := EditParams{
- FilePath: filePath,
- OldString: "Initial",
- NewString: "Updated",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: EditToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "permission denied")
-
- // Verify file was not modified
- fileContent, err := os.ReadFile(filePath)
- require.NoError(t, err)
- assert.Equal(t, initialContent, string(fileContent))
- })
-}
@@ -1,246 +0,0 @@
-package tools
-
-import (
- "context"
- "fmt"
- "sort"
- "strconv"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/kujtimiihoxha/opencode/internal/history"
- "github.com/kujtimiihoxha/opencode/internal/permission"
- "github.com/kujtimiihoxha/opencode/internal/pubsub"
-)
-
-// Mock permission service for testing
-type mockPermissionService struct {
- *pubsub.Broker[permission.PermissionRequest]
- allow bool
-}
-
-func (m *mockPermissionService) GrantPersistant(permission permission.PermissionRequest) {
- // Not needed for tests
-}
-
-func (m *mockPermissionService) Grant(permission permission.PermissionRequest) {
- // Not needed for tests
-}
-
-func (m *mockPermissionService) Deny(permission permission.PermissionRequest) {
- // Not needed for tests
-}
-
-func (m *mockPermissionService) Request(opts permission.CreatePermissionRequest) bool {
- return m.allow
-}
-
-func newMockPermissionService(allow bool) permission.Service {
- return &mockPermissionService{
- Broker: pubsub.NewBroker[permission.PermissionRequest](),
- allow: allow,
- }
-}
-
-type mockFileHistoryService struct {
- *pubsub.Broker[history.File]
- files map[string]history.File // ID -> File
- timeNow func() int64
-}
-
-// Create implements history.Service.
-func (m *mockFileHistoryService) Create(ctx context.Context, sessionID string, path string, content string) (history.File, error) {
- return m.createWithVersion(ctx, sessionID, path, content, history.InitialVersion)
-}
-
-// CreateVersion implements history.Service.
-func (m *mockFileHistoryService) CreateVersion(ctx context.Context, sessionID string, path string, content string) (history.File, error) {
- var files []history.File
- for _, file := range m.files {
- if file.Path == path {
- files = append(files, file)
- }
- }
-
- if len(files) == 0 {
- // No previous versions, create initial
- return m.Create(ctx, sessionID, path, content)
- }
-
- // Sort files by CreatedAt in descending order
- sort.Slice(files, func(i, j int) bool {
- return files[i].CreatedAt > files[j].CreatedAt
- })
-
- // Get the latest version
- latestFile := files[0]
- latestVersion := latestFile.Version
-
- // Generate the next version
- var nextVersion string
- if latestVersion == history.InitialVersion {
- nextVersion = "v1"
- } else if strings.HasPrefix(latestVersion, "v") {
- versionNum, err := strconv.Atoi(latestVersion[1:])
- if err != nil {
- // If we can't parse the version, just use a timestamp-based version
- nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
- } else {
- nextVersion = fmt.Sprintf("v%d", versionNum+1)
- }
- } else {
- // If the version format is unexpected, use a timestamp-based version
- nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
- }
-
- return m.createWithVersion(ctx, sessionID, path, content, nextVersion)
-}
-
-func (m *mockFileHistoryService) createWithVersion(_ context.Context, sessionID, path, content, version string) (history.File, error) {
- now := m.timeNow()
- file := history.File{
- ID: uuid.New().String(),
- SessionID: sessionID,
- Path: path,
- Content: content,
- Version: version,
- CreatedAt: now,
- UpdatedAt: now,
- }
-
- m.files[file.ID] = file
- m.Publish(pubsub.CreatedEvent, file)
- return file, nil
-}
-
-// Delete implements history.Service.
-func (m *mockFileHistoryService) Delete(ctx context.Context, id string) error {
- file, ok := m.files[id]
- if !ok {
- return fmt.Errorf("file not found: %s", id)
- }
-
- delete(m.files, id)
- m.Publish(pubsub.DeletedEvent, file)
- return nil
-}
-
-// DeleteSessionFiles implements history.Service.
-func (m *mockFileHistoryService) DeleteSessionFiles(ctx context.Context, sessionID string) error {
- files, err := m.ListBySession(ctx, sessionID)
- if err != nil {
- return err
- }
-
- for _, file := range files {
- err = m.Delete(ctx, file.ID)
- if err != nil {
- return err
- }
- }
-
- return nil
-}
-
-// Get implements history.Service.
-func (m *mockFileHistoryService) Get(ctx context.Context, id string) (history.File, error) {
- file, ok := m.files[id]
- if !ok {
- return history.File{}, fmt.Errorf("file not found: %s", id)
- }
- return file, nil
-}
-
-// GetByPathAndSession implements history.Service.
-func (m *mockFileHistoryService) GetByPathAndSession(ctx context.Context, path string, sessionID string) (history.File, error) {
- var latestFile history.File
- var found bool
- var latestTime int64
-
- for _, file := range m.files {
- if file.Path == path && file.SessionID == sessionID {
- if !found || file.CreatedAt > latestTime {
- latestFile = file
- latestTime = file.CreatedAt
- found = true
- }
- }
- }
-
- if !found {
- return history.File{}, fmt.Errorf("file not found: %s for session %s", path, sessionID)
- }
- return latestFile, nil
-}
-
-// ListBySession implements history.Service.
-func (m *mockFileHistoryService) ListBySession(ctx context.Context, sessionID string) ([]history.File, error) {
- var files []history.File
- for _, file := range m.files {
- if file.SessionID == sessionID {
- files = append(files, file)
- }
- }
-
- // Sort by CreatedAt in descending order
- sort.Slice(files, func(i, j int) bool {
- return files[i].CreatedAt > files[j].CreatedAt
- })
-
- return files, nil
-}
-
-// ListLatestSessionFiles implements history.Service.
-func (m *mockFileHistoryService) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]history.File, error) {
- // Map to track the latest file for each path
- latestFiles := make(map[string]history.File)
-
- for _, file := range m.files {
- if file.SessionID == sessionID {
- existing, ok := latestFiles[file.Path]
- if !ok || file.CreatedAt > existing.CreatedAt {
- latestFiles[file.Path] = file
- }
- }
- }
-
- // Convert map to slice
- var result []history.File
- for _, file := range latestFiles {
- result = append(result, file)
- }
-
- // Sort by CreatedAt in descending order
- sort.Slice(result, func(i, j int) bool {
- return result[i].CreatedAt > result[j].CreatedAt
- })
-
- return result, nil
-}
-
-// Subscribe implements history.Service.
-func (m *mockFileHistoryService) Subscribe(ctx context.Context) <-chan pubsub.Event[history.File] {
- return m.Broker.Subscribe(ctx)
-}
-
-// Update implements history.Service.
-func (m *mockFileHistoryService) Update(ctx context.Context, file history.File) (history.File, error) {
- _, ok := m.files[file.ID]
- if !ok {
- return history.File{}, fmt.Errorf("file not found: %s", file.ID)
- }
-
- file.UpdatedAt = m.timeNow()
- m.files[file.ID] = file
- m.Publish(pubsub.UpdatedEvent, file)
- return file, nil
-}
-
-func newMockFileHistoryService() history.Service {
- return &mockFileHistoryService{
- Broker: pubsub.NewBroker[history.File](),
- files: make(map[string]history.File),
- timeNow: func() int64 { return time.Now().Unix() },
- }
-}
@@ -0,0 +1,300 @@
+package tools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/kujtimiihoxha/opencode/internal/config"
+ "github.com/kujtimiihoxha/opencode/internal/diff"
+ "github.com/kujtimiihoxha/opencode/internal/history"
+ "github.com/kujtimiihoxha/opencode/internal/lsp"
+ "github.com/kujtimiihoxha/opencode/internal/permission"
+)
+
+type PatchParams struct {
+ FilePath string `json:"file_path"`
+ Patch string `json:"patch"`
+}
+
+type PatchPermissionsParams struct {
+ FilePath string `json:"file_path"`
+ Diff string `json:"diff"`
+}
+
+type PatchResponseMetadata struct {
+ Diff string `json:"diff"`
+ Additions int `json:"additions"`
+ Removals int `json:"removals"`
+}
+
+type patchTool struct {
+ lspClients map[string]*lsp.Client
+ permissions permission.Service
+ files history.Service
+}
+
+const (
+ // TODO: test if this works as expected
+ PatchToolName = "patch"
+ patchDescription = `Applies a patch to a file. This tool is similar to the edit tool but accepts a unified diff patch instead of old/new strings.
+
+Before using this tool:
+
+1. Use the FileRead tool to understand the file's contents and context
+
+2. Verify the directory path is correct:
+ - Use the LS tool to verify the parent directory exists and is the correct location
+
+To apply a patch, provide the following:
+1. file_path: The absolute path to the file to modify (must be absolute, not relative)
+2. patch: A unified diff patch to apply to the file
+
+The tool will apply the patch to the specified file. The patch must be in unified diff format.
+
+CRITICAL REQUIREMENTS FOR USING THIS TOOL:
+
+1. PATCH FORMAT: The patch must be in unified diff format, which includes:
+ - File headers (--- a/file_path, +++ b/file_path)
+ - Hunk headers (@@ -start,count +start,count @@)
+ - Added lines (prefixed with +)
+ - Removed lines (prefixed with -)
+
+2. CONTEXT: The patch must include sufficient context around the changes to ensure it applies correctly.
+
+3. VERIFICATION: Before using this tool:
+ - Ensure the patch applies cleanly to the current state of the file
+ - Check that the file exists and you have read it first
+
+WARNING: If you do not follow these requirements:
+ - The tool will fail if the patch doesn't apply cleanly
+ - You may change the wrong parts of the file if the context is insufficient
+
+When applying patches:
+ - Ensure the patch results in idiomatic, correct code
+ - Do not leave the code in a broken state
+ - Always use absolute file paths (starting with /)
+
+Remember: patches are a powerful way to make multiple related changes at once, but they require careful preparation.`
+)
+
+func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
+ return &patchTool{
+ lspClients: lspClients,
+ permissions: permissions,
+ files: files,
+ }
+}
+
+func (p *patchTool) Info() ToolInfo {
+ return ToolInfo{
+ Name: PatchToolName,
+ Description: patchDescription,
+ Parameters: map[string]any{
+ "file_path": map[string]any{
+ "type": "string",
+ "description": "The absolute path to the file to modify",
+ },
+ "patch": map[string]any{
+ "type": "string",
+ "description": "The unified diff patch to apply",
+ },
+ },
+ Required: []string{"file_path", "patch"},
+ }
+}
+
+func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
+ var params PatchParams
+ if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
+ return NewTextErrorResponse("invalid parameters"), nil
+ }
+
+ if params.FilePath == "" {
+ return NewTextErrorResponse("file_path is required"), nil
+ }
+
+ if params.Patch == "" {
+ return NewTextErrorResponse("patch is required"), nil
+ }
+
+ if !filepath.IsAbs(params.FilePath) {
+ wd := config.WorkingDirectory()
+ params.FilePath = filepath.Join(wd, params.FilePath)
+ }
+
+ // Check if file exists
+ fileInfo, err := os.Stat(params.FilePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
+ }
+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+ }
+
+ if fileInfo.IsDir() {
+ return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
+ }
+
+ if getLastReadTime(params.FilePath).IsZero() {
+ return NewTextErrorResponse("you must read the file before patching it. Use the View tool first"), nil
+ }
+
+ modTime := fileInfo.ModTime()
+ lastRead := getLastReadTime(params.FilePath)
+ if modTime.After(lastRead) {
+ return NewTextErrorResponse(
+ fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
+ params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
+ )), nil
+ }
+
+ // Read the current file content
+ content, err := os.ReadFile(params.FilePath)
+ if err != nil {
+ return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
+ }
+
+ oldContent := string(content)
+
+ // Parse and apply the patch
+ diffResult, err := diff.ParseUnifiedDiff(params.Patch)
+ if err != nil {
+ return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %v", err)), nil
+ }
+
+ // Apply the patch to get the new content
+ newContent, err := applyPatch(oldContent, diffResult)
+ if err != nil {
+ return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %v", err)), nil
+ }
+
+ if oldContent == newContent {
+ return NewTextErrorResponse("patch did not result in any changes to the file"), nil
+ }
+
+ sessionID, messageID := GetContextValues(ctx)
+ if sessionID == "" || messageID == "" {
+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for patching a file")
+ }
+
+ // Generate a diff for permission request and metadata
+ diffText, additions, removals := diff.GenerateDiff(
+ oldContent,
+ newContent,
+ params.FilePath,
+ )
+
+ // Request permission to apply the patch
+ p.permissions.Request(
+ permission.CreatePermissionRequest{
+ Path: filepath.Dir(params.FilePath),
+ ToolName: PatchToolName,
+ Action: "patch",
+ Description: fmt.Sprintf("Apply patch to file %s", params.FilePath),
+ Params: PatchPermissionsParams{
+ FilePath: params.FilePath,
+ Diff: diffText,
+ },
+ },
+ )
+
+ // Write the new content to the file
+ err = os.WriteFile(params.FilePath, []byte(newContent), 0o644)
+ if err != nil {
+ return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+ }
+
+ // Update file history
+ file, err := p.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
+ if err != nil {
+ _, err = p.files.Create(ctx, sessionID, params.FilePath, oldContent)
+ if err != nil {
+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+ }
+ }
+ if file.Content != oldContent {
+ // User manually changed the content, store an intermediate version
+ _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
+ if err != nil {
+ fmt.Printf("Error creating file history version: %v\n", err)
+ }
+ }
+ // Store the new version
+ _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, newContent)
+ if err != nil {
+ fmt.Printf("Error creating file history version: %v\n", err)
+ }
+
+ recordFileWrite(params.FilePath)
+ recordFileRead(params.FilePath)
+
+ // Wait for LSP diagnostics and include them in the response
+ waitForLspDiagnostics(ctx, params.FilePath, p.lspClients)
+ text := fmt.Sprintf("<r>\nPatch applied to file: %s\n</r>\n", params.FilePath)
+ text += getDiagnostics(params.FilePath, p.lspClients)
+
+ return WithResponseMetadata(
+ NewTextResponse(text),
+ PatchResponseMetadata{
+ Diff: diffText,
+ Additions: additions,
+ Removals: removals,
+ }), nil
+}
+
+// applyPatch applies a parsed diff to a string and returns the resulting content
+func applyPatch(content string, diffResult diff.DiffResult) (string, error) {
+ lines := strings.Split(content, "\n")
+
+ // Process each hunk in the diff
+ for _, hunk := range diffResult.Hunks {
+ // Parse the hunk header to get line numbers
+ var oldStart, oldCount, newStart, newCount int
+ _, err := fmt.Sscanf(hunk.Header, "@@ -%d,%d +%d,%d @@", &oldStart, &oldCount, &newStart, &newCount)
+ if err != nil {
+ // Try alternative format with single line counts
+ _, err = fmt.Sscanf(hunk.Header, "@@ -%d +%d @@", &oldStart, &newStart)
+ if err != nil {
+ return "", fmt.Errorf("invalid hunk header format: %s", hunk.Header)
+ }
+ oldCount = 1
+ newCount = 1
+ }
+
+ // Adjust for 0-based array indexing
+ oldStart--
+ newStart--
+
+ // Apply the changes
+ newLines := make([]string, 0)
+ newLines = append(newLines, lines[:oldStart]...)
+
+ // Process the hunk lines in order
+ currentOldLine := oldStart
+ for _, line := range hunk.Lines {
+ switch line.Kind {
+ case diff.LineContext:
+ newLines = append(newLines, line.Content)
+ currentOldLine++
+ case diff.LineRemoved:
+ // Skip this line in the output (it's being removed)
+ currentOldLine++
+ case diff.LineAdded:
+ // Add the new line
+ newLines = append(newLines, line.Content)
+ }
+ }
+
+ // Append the rest of the file
+ newLines = append(newLines, lines[currentOldLine:]...)
+ lines = newLines
+ }
+
+ return strings.Join(lines, "\n"), nil
+}
+
@@ -1,86 +0,0 @@
-package tools
-
-import (
- "context"
- "encoding/json"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestSourcegraphTool_Info(t *testing.T) {
- tool := NewSourcegraphTool()
- info := tool.Info()
-
- assert.Equal(t, SourcegraphToolName, info.Name)
- assert.NotEmpty(t, info.Description)
- assert.Contains(t, info.Parameters, "query")
- assert.Contains(t, info.Parameters, "count")
- assert.Contains(t, info.Parameters, "timeout")
- assert.Contains(t, info.Required, "query")
-}
-
-func TestSourcegraphTool_Run(t *testing.T) {
- t.Run("handles missing query parameter", func(t *testing.T) {
- tool := NewSourcegraphTool()
- params := SourcegraphParams{
- Query: "",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: SourcegraphToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "Query parameter is required")
- })
-
- t.Run("handles invalid parameters", func(t *testing.T) {
- tool := NewSourcegraphTool()
- call := ToolCall{
- Name: SourcegraphToolName,
- Input: "invalid json",
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "Failed to parse sourcegraph parameters")
- })
-
- t.Run("normalizes count parameter", func(t *testing.T) {
- // Test cases for count normalization
- testCases := []struct {
- name string
- inputCount int
- expectedCount int
- }{
- {"negative count", -5, 10}, // Should use default (10)
- {"zero count", 0, 10}, // Should use default (10)
- {"valid count", 50, 50}, // Should keep as is
- {"excessive count", 150, 100}, // Should cap at 100
- }
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- // Verify count normalization logic directly
- assert.NotPanics(t, func() {
- // Apply the same normalization logic as in the tool
- normalizedCount := tc.inputCount
- if normalizedCount <= 0 {
- normalizedCount = 10
- } else if normalizedCount > 100 {
- normalizedCount = 100
- }
-
- assert.Equal(t, tc.expectedCount, normalizedCount)
- })
- })
- }
- })
-}
@@ -1,307 +0,0 @@
-package tools
-
-import (
- "context"
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/kujtimiihoxha/opencode/internal/lsp"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestWriteTool_Info(t *testing.T) {
- tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
- info := tool.Info()
-
- assert.Equal(t, WriteToolName, info.Name)
- assert.NotEmpty(t, info.Description)
- assert.Contains(t, info.Parameters, "file_path")
- assert.Contains(t, info.Parameters, "content")
- assert.Contains(t, info.Required, "file_path")
- assert.Contains(t, info.Required, "content")
-}
-
-func TestWriteTool_Run(t *testing.T) {
- // Create a temporary directory for testing
- tempDir, err := os.MkdirTemp("", "write_tool_test")
- require.NoError(t, err)
- defer os.RemoveAll(tempDir)
-
- t.Run("creates a new file successfully", func(t *testing.T) {
- tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- filePath := filepath.Join(tempDir, "new_file.txt")
- content := "This is a test content"
-
- params := WriteParams{
- FilePath: filePath,
- Content: content,
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: WriteToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "successfully written")
-
- // Verify file was created with correct content
- fileContent, err := os.ReadFile(filePath)
- require.NoError(t, err)
- assert.Equal(t, content, string(fileContent))
- })
-
- t.Run("creates file with nested directories", func(t *testing.T) {
- tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
- content := "Content in nested directory"
-
- params := WriteParams{
- FilePath: filePath,
- Content: content,
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: WriteToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "successfully written")
-
- // Verify file was created with correct content
- fileContent, err := os.ReadFile(filePath)
- require.NoError(t, err)
- assert.Equal(t, content, string(fileContent))
- })
-
- t.Run("updates existing file", func(t *testing.T) {
- tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a file first
- filePath := filepath.Join(tempDir, "existing_file.txt")
- initialContent := "Initial content"
- err := os.WriteFile(filePath, []byte(initialContent), 0o644)
- require.NoError(t, err)
-
- // Record the file read to avoid modification time check failure
- recordFileRead(filePath)
-
- // Update the file
- updatedContent := "Updated content"
- params := WriteParams{
- FilePath: filePath,
- Content: updatedContent,
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: WriteToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "successfully written")
-
- // Verify file was updated with correct content
- fileContent, err := os.ReadFile(filePath)
- require.NoError(t, err)
- assert.Equal(t, updatedContent, string(fileContent))
- })
-
- t.Run("handles invalid parameters", func(t *testing.T) {
- tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- call := ToolCall{
- Name: WriteToolName,
- Input: "invalid json",
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "error parsing parameters")
- })
-
- t.Run("handles missing file_path", func(t *testing.T) {
- tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- params := WriteParams{
- FilePath: "",
- Content: "Some content",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: WriteToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "file_path is required")
- })
-
- t.Run("handles missing content", func(t *testing.T) {
- tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- params := WriteParams{
- FilePath: filepath.Join(tempDir, "file.txt"),
- Content: "",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: WriteToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "content is required")
- })
-
- t.Run("handles writing to a directory path", func(t *testing.T) {
- tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a directory
- dirPath := filepath.Join(tempDir, "test_dir")
- err := os.Mkdir(dirPath, 0o755)
- require.NoError(t, err)
-
- params := WriteParams{
- FilePath: dirPath,
- Content: "Some content",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: WriteToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "Path is a directory")
- })
-
- t.Run("handles permission denied", func(t *testing.T) {
- tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(false), newMockFileHistoryService())
-
- filePath := filepath.Join(tempDir, "permission_denied.txt")
- params := WriteParams{
- FilePath: filePath,
- Content: "Content that should not be written",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: WriteToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "Permission denied")
-
- // Verify file was not created
- _, err = os.Stat(filePath)
- assert.True(t, os.IsNotExist(err))
- })
-
- t.Run("detects file modified since last read", func(t *testing.T) {
- tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a file
- filePath := filepath.Join(tempDir, "modified_file.txt")
- initialContent := "Initial content"
- err := os.WriteFile(filePath, []byte(initialContent), 0o644)
- require.NoError(t, err)
-
- // Record an old read time
- fileRecordMutex.Lock()
- fileRecords[filePath] = fileRecord{
- path: filePath,
- readTime: time.Now().Add(-1 * time.Hour),
- }
- fileRecordMutex.Unlock()
-
- // Try to update the file
- params := WriteParams{
- FilePath: filePath,
- Content: "Updated content",
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: WriteToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "has been modified since it was last read")
-
- // Verify file was not modified
- fileContent, err := os.ReadFile(filePath)
- require.NoError(t, err)
- assert.Equal(t, initialContent, string(fileContent))
- })
-
- t.Run("skips writing when content is identical", func(t *testing.T) {
- tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
- // Create a file
- filePath := filepath.Join(tempDir, "identical_content.txt")
- content := "Content that won't change"
- err := os.WriteFile(filePath, []byte(content), 0o644)
- require.NoError(t, err)
-
- // Record a read time
- recordFileRead(filePath)
-
- // Try to write the same content
- params := WriteParams{
- FilePath: filePath,
- Content: content,
- }
-
- paramsJSON, err := json.Marshal(params)
- require.NoError(t, err)
-
- call := ToolCall{
- Name: WriteToolName,
- Input: string(paramsJSON),
- }
-
- response, err := tool.Run(context.Background(), call)
- require.NoError(t, err)
- assert.Contains(t, response.Content, "already contains the exact content")
- })
-}
@@ -3,6 +3,7 @@ package chat
import (
"context"
"fmt"
+ "sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -141,8 +142,17 @@ func (m *sidebarCmp) modifiedFiles() string {
)
}
+ // Sort file paths alphabetically for consistent ordering
+ var paths []string
+ for path := range m.modFiles {
+ paths = append(paths, path)
+ }
+ sort.Strings(paths)
+
+ // Create views for each file in sorted order
var fileViews []string
- for path, stats := range m.modFiles {
+ for _, path := range paths {
+ stats := m.modFiles[path]
fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
}
@@ -266,6 +266,18 @@ func (p *permissionDialogCmp) renderEditContent() string {
return ""
}
+func (p *permissionDialogCmp) renderPatchContent() string {
+ if pr, ok := p.permission.Params.(tools.PatchPermissionsParams); ok {
+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
+ })
+
+ p.contentViewPort.SetContent(diff)
+ return p.styleViewport()
+ }
+ return ""
+}
+
func (p *permissionDialogCmp) renderWriteContent() string {
if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
// Use the cache for diff rendering
@@ -350,6 +362,8 @@ func (p *permissionDialogCmp) render() string {
contentFinal = p.renderBashContent()
case tools.EditToolName:
contentFinal = p.renderEditContent()
+ case tools.PatchToolName:
+ contentFinal = p.renderPatchContent()
case tools.WriteToolName:
contentFinal = p.renderWriteContent()
case tools.FetchToolName:
@@ -6,11 +6,9 @@ import (
)
func main() {
- // Set up panic recovery for the main function
defer logging.RecoverPanic("main", func() {
- // Perform any necessary cleanup before exit
logging.ErrorPersist("Application terminated due to unhandled panic")
})
-
+
cmd.Execute()
}