Update agent prompt, improve TUI patch UI, remove obsolete tool tests

- Replace and expand agent coder prompt for clarity and safety
- Add patch tool and TUI dialog support for patch diffs
- Sort sidebar modified files by name
- Remove Bash/Edit/Sourcegraph/Write tool tests

🤖 Generated with opencode
Co-Authored-By: opencode <noreply@opencode.ai>
This commit is contained in:
Kujtim Hoxha 2025-04-17 00:00:19 +02:00
parent cc07f7a186
commit 36172979b4
11 changed files with 377 additions and 1474 deletions

View file

@ -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),

View file

@ -25,44 +25,63 @@ func CoderPrompt(provider models.ModelProvider) string {
}
const baseOpenAICoderPrompt = `
You are **OpenCode**, an autonomous CLI assistant for softwareengineering tasks.
# OpenCode CLI Agent Prompt
### INTERNAL REFLECTION
Silently think stepbystep 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 AskOnlyIf rules.
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.
### PUBLIC RESPONSE RULES
Visible reply  4 lines; no fluff, preamble, or postamble.
Use GitHubflavored Markdown.
When running a nontrivial shell command, add  1 brief purpose sentence.
## Capabilities
### CONTEXT & MEMORY
Infer file intent from directory structure before editing.
Autoload 'OpenCode.md'; ask once before writing new reusable commands or style notes.
- 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.
### AUTONOMY PRIORITY
**AskOnlyIf 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 selfattempts?** ask.
Otherwise, proceed autonomously.
## Operational Guidelines
### SAFETY & STYLE
Mimic existing code style; verify libraries exist before import.
Never commit unless explicitly told.
After edits, run lint & typecheck (ask for commands once, then offer to store in 'OpenCode.md').
Protect secrets; follow standard security practices :contentReference[oaicite:2]{index=2}.
### 1. Task Resolution
### 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.
- 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 informationavoid assumptions.
### EXAMPLES
user: list files
assistant: ls
### 2. Code Modification & Testing
user: write tests for new feature
assistant: [searches & edits autonomously, no extra chitchat]
- 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.

View file

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

View file

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

View file

@ -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() },
}
}

300
internal/llm/tools/patch.go Normal file
View file

@ -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), &params); 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
}

View file

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

View file

@ -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")
})
}

View file

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

View file

@ -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:

View file

@ -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()
}