mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 13:30:52 +00:00
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:
parent
cc07f7a186
commit
36172979b4
11 changed files with 377 additions and 1474 deletions
|
@ -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.
|
||||
# OpenCode CLI Agent Prompt
|
||||
|
||||
### ── 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.
|
||||
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 GitHub‑flavored Markdown.
|
||||
• When running a non‑trivial shell command, add ≤ 1 brief purpose sentence.
|
||||
## Capabilities
|
||||
|
||||
### ── CONTEXT & MEMORY ──
|
||||
• Infer file intent from directory structure before editing.
|
||||
• Auto‑load '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 ──
|
||||
**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.
|
||||
## Operational Guidelines
|
||||
|
||||
### ── 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}.
|
||||
### 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 information—avoid assumptions.
|
||||
|
||||
### ── EXAMPLES ──
|
||||
user: list files
|
||||
assistant: ls
|
||||
### 2. Code Modification & Testing
|
||||
|
||||
user: write tests for new feature
|
||||
assistant: [searches & edits autonomously, no extra chit‑chat]
|
||||
- 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() },
|
||||
}
|
||||
}
|
300
internal/llm/tools/patch.go
Normal file
300
internal/llm/tools/patch.go
Normal 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), ¶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:
|
||||
|
|
4
main.go
4
main.go
|
@ -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()
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue