on main help sign in
Update agent prompt, improve TUI patch UI, remove obsolete tool tests
3617297Kujtim Hoxha
internal/llm/agent/tools.go+2
@@ -31,6 +31,8 @@ func CoderAgentTools(
31 tools.NewGlobTool(),
31 tools.NewGlobTool(),
32 tools.NewGrepTool(),
32 tools.NewGrepTool(),
33 tools.NewLsTool(),
33 tools.NewLsTool(),
34+ // TODO: see if we want to use this tool
35+ // tools.NewPatchTool(lspClients, permissions, history),
34 tools.NewSourcegraphTool(),
36 tools.NewSourcegraphTool(),
35 tools.NewViewTool(lspClients),
37 tools.NewViewTool(lspClients),
36 tools.NewWriteTool(lspClients, permissions, history),
38 tools.NewWriteTool(lspClients, permissions, history),
internal/llm/prompt/coder.go+57−38
@@ -25,44 +25,63 @@ func CoderPrompt(provider models.ModelProvider) string {
25}
25}
26
26
27const baseOpenAICoderPrompt = `
27const baseOpenAICoderPrompt = `
28−You are **OpenCode**, an autonomous CLI assistant for software‑engineering tasks.
28+# OpenCode CLI Agent Prompt
29−
29+
30−### ── INTERNAL REFLECTION ──
30+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.
31−• Silently think step‑by‑step about the user request, directory layout, and tool calls (never reveal this).
31+
32−• Formulate a plan, then execute without further approval unless a blocker triggers the Ask‑Only‑If rules.
32+## Capabilities
33−
33+
34−### ── PUBLIC RESPONSE RULES ──
34+- Receive user prompts, project context, and files.
35−• Visible reply ≤ 4 lines; no fluff, preamble, or postamble.
35+- Stream responses and emit function calls (e.g., shell commands, code edits).
36−• Use GitHub‑flavored Markdown.
36+- Apply patches, run commands, and manage user approvals based on policy.
37−• When running a non‑trivial shell command, add ≤ 1 brief purpose sentence.
37+- Operate within a sandboxed, git-backed workspace with rollback support.
38−
38+- Log telemetry for session replay or inspection.
39−### ── CONTEXT & MEMORY ──
39+- Access detailed functionality via the help command.
40−• Infer file intent from directory structure before editing.
40+
41−• Auto‑load 'OpenCode.md'; ask once before writing new reusable commands or style notes.
41+## Operational Guidelines
42−
42+
43−### ── AUTONOMY PRIORITY ──
43+### 1. Task Resolution
44−**Ask‑Only‑If Decision Tree:**
44+
45−1. **Safety risk?** (e.g., destructive command, secret exposure) → ask.
45+- Continue processing until the user's query is fully resolved.
46−2. **Critical unknown?** (no docs/tests; cannot infer) → ask.
46+- Only conclude your turn when confident the problem is solved.
47−3. **Tool failure after two self‑attempts?** → ask.
47+- If uncertain about file content or codebase structure, utilize available tools to gather necessary information—avoid assumptions.
48−Otherwise, proceed autonomously.
48+
49−
49+### 2. Code Modification & Testing
50−### ── SAFETY & STYLE ──
50+
51−• Mimic existing code style; verify libraries exist before import.
51+- Edit and test code files within your current execution session.
52−• Never commit unless explicitly told.
52+- Work on the local repositories, even if proprietary.
53−• After edits, run lint & type‑check (ask for commands once, then offer to store in 'OpenCode.md').
53+- Analyze code for vulnerabilities when applicable.
54−• Protect secrets; follow standard security practices :contentReference[oaicite:2]{index=2}.
54+- Display user code and tool call details transparently.
55−
55+
56−### ── TOOL USAGE ──
56+### 3. Coding Guidelines
57−• Batch independent Agent search/file calls in one block for efficiency :contentReference[oaicite:3]{index=3}.
57+
58−• Communicate with the user only via visible text; do not expose tool output or internal reasoning.
58+- Address root causes rather than applying superficial fixes.
59−
59+- Avoid unnecessary complexity; focus on the task at hand.
60−### ── EXAMPLES ──
60+- Update documentation as needed.
61−user: list files
61+- Maintain consistency with the existing codebase style.
62−assistant: ls
62+- Utilize version control tools for additional context; note that internet access is disabled.
63−
63+- Refrain from adding copyright or license headers unless explicitly requested.
64−user: write tests for new feature
64+- No need to perform commit operations; this will be handled automatically.
65−assistant: [searches & edits autonomously, no extra chit‑chat]
65+- If a pre-commit configuration file exists, run the appropriate checks to ensure changes pass. Do not fix pre-existing errors on untouched lines.
66+- If pre-commit checks fail after retries, inform the user that the setup may be broken.
67+
68+### 4. Post-Modification Checks
69+
70+- Use version control status commands to verify changes; revert any unintended modifications.
71+- Remove all added inline comments unless they are essential for understanding.
72+- Ensure no accidental addition of copyright or license headers.
73+- Attempt to run pre-commit checks if available.
74+- For smaller tasks, provide brief bullet points summarizing changes.
75+- For complex tasks, include a high-level description, bullet points, and relevant details for code reviewers.
76+
77+### 5. Non-Code Modification Tasks
78+
79+- Respond in a friendly, collaborative tone, akin to a knowledgeable remote teammate eager to assist with coding inquiries.
80+
81+### 6. File Handling
82+
83+- Do not instruct the user to save or copy code into files if modifications have already been made using the editing tools.
84+- Avoid displaying full contents of large files unless explicitly requested by the user.
66`
85`
67
86
68const 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.
87const 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.
internal/llm/tools/bash_test.godeleted−340
@@ -1,340 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6− "os"
7− "strings"
8− "testing"
9− "time"
10−
11− "github.com/stretchr/testify/assert"
12− "github.com/stretchr/testify/require"
13−)
14−
15−func TestBashTool_Info(t *testing.T) {
16− tool := NewBashTool(newMockPermissionService(true))
17− info := tool.Info()
18−
19− assert.Equal(t, BashToolName, info.Name)
20− assert.NotEmpty(t, info.Description)
21− assert.Contains(t, info.Parameters, "command")
22− assert.Contains(t, info.Parameters, "timeout")
23− assert.Contains(t, info.Required, "command")
24−}
25−
26−func TestBashTool_Run(t *testing.T) {
27− // Save original working directory
28− origWd, err := os.Getwd()
29− require.NoError(t, err)
30− defer func() {
31− os.Chdir(origWd)
32− }()
33−
34− t.Run("executes command successfully", func(t *testing.T) {
35− tool := NewBashTool(newMockPermissionService(true))
36− params := BashParams{
37− Command: "echo 'Hello World'",
38− }
39−
40− paramsJSON, err := json.Marshal(params)
41− require.NoError(t, err)
42−
43− call := ToolCall{
44− Name: BashToolName,
45− Input: string(paramsJSON),
46− }
47−
48− response, err := tool.Run(context.Background(), call)
49− require.NoError(t, err)
50− assert.Equal(t, "Hello World\n", response.Content)
51− })
52−
53− t.Run("handles invalid parameters", func(t *testing.T) {
54− tool := NewBashTool(newMockPermissionService(true))
55− call := ToolCall{
56− Name: BashToolName,
57− Input: "invalid json",
58− }
59−
60− response, err := tool.Run(context.Background(), call)
61− require.NoError(t, err)
62− assert.Contains(t, response.Content, "invalid parameters")
63− })
64−
65− t.Run("handles missing command", func(t *testing.T) {
66− tool := NewBashTool(newMockPermissionService(true))
67− params := BashParams{
68− Command: "",
69− }
70−
71− paramsJSON, err := json.Marshal(params)
72− require.NoError(t, err)
73−
74− call := ToolCall{
75− Name: BashToolName,
76− Input: string(paramsJSON),
77− }
78−
79− response, err := tool.Run(context.Background(), call)
80− require.NoError(t, err)
81− assert.Contains(t, response.Content, "missing command")
82− })
83−
84− t.Run("handles banned commands", func(t *testing.T) {
85− tool := NewBashTool(newMockPermissionService(true))
86−
87− for _, bannedCmd := range bannedCommands {
88− params := BashParams{
89− Command: bannedCmd + " arg1 arg2",
90− }
91−
92− paramsJSON, err := json.Marshal(params)
93− require.NoError(t, err)
94−
95− call := ToolCall{
96− Name: BashToolName,
97− Input: string(paramsJSON),
98− }
99−
100− response, err := tool.Run(context.Background(), call)
101− require.NoError(t, err)
102− assert.Contains(t, response.Content, "not allowed", "Command %s should be blocked", bannedCmd)
103− }
104− })
105−
106− t.Run("handles multi-word safe commands without permission check", func(t *testing.T) {
107− tool := NewBashTool(newMockPermissionService(false))
108−
109− // Test with multi-word safe commands
110− multiWordCommands := []string{
111− "go env",
112− }
113−
114− for _, cmd := range multiWordCommands {
115− params := BashParams{
116− Command: cmd,
117− }
118−
119− paramsJSON, err := json.Marshal(params)
120− require.NoError(t, err)
121−
122− call := ToolCall{
123− Name: BashToolName,
124− Input: string(paramsJSON),
125− }
126−
127− response, err := tool.Run(context.Background(), call)
128− require.NoError(t, err)
129− assert.NotContains(t, response.Content, "permission denied",
130− "Command %s should be allowed without permission", cmd)
131− }
132− })
133−
134− t.Run("handles permission denied", func(t *testing.T) {
135− tool := NewBashTool(newMockPermissionService(false))
136−
137− // Test with a command that requires permission
138− params := BashParams{
139− Command: "mkdir test_dir",
140− }
141−
142− paramsJSON, err := json.Marshal(params)
143− require.NoError(t, err)
144−
145− call := ToolCall{
146− Name: BashToolName,
147− Input: string(paramsJSON),
148− }
149−
150− response, err := tool.Run(context.Background(), call)
151− require.NoError(t, err)
152− assert.Contains(t, response.Content, "permission denied")
153− })
154−
155− t.Run("handles command timeout", func(t *testing.T) {
156− tool := NewBashTool(newMockPermissionService(true))
157− params := BashParams{
158− Command: "sleep 2",
159− Timeout: 100, // 100ms timeout
160− }
161−
162− paramsJSON, err := json.Marshal(params)
163− require.NoError(t, err)
164−
165− call := ToolCall{
166− Name: BashToolName,
167− Input: string(paramsJSON),
168− }
169−
170− response, err := tool.Run(context.Background(), call)
171− require.NoError(t, err)
172− assert.Contains(t, response.Content, "aborted")
173− })
174−
175− t.Run("handles command with stderr output", func(t *testing.T) {
176− tool := NewBashTool(newMockPermissionService(true))
177− params := BashParams{
178− Command: "echo 'error message' >&2",
179− }
180−
181− paramsJSON, err := json.Marshal(params)
182− require.NoError(t, err)
183−
184− call := ToolCall{
185− Name: BashToolName,
186− Input: string(paramsJSON),
187− }
188−
189− response, err := tool.Run(context.Background(), call)
190− require.NoError(t, err)
191− assert.Contains(t, response.Content, "error message")
192− })
193−
194− t.Run("handles command with both stdout and stderr", func(t *testing.T) {
195− tool := NewBashTool(newMockPermissionService(true))
196− params := BashParams{
197− Command: "echo 'stdout message' && echo 'stderr message' >&2",
198− }
199−
200− paramsJSON, err := json.Marshal(params)
201− require.NoError(t, err)
202−
203− call := ToolCall{
204− Name: BashToolName,
205− Input: string(paramsJSON),
206− }
207−
208− response, err := tool.Run(context.Background(), call)
209− require.NoError(t, err)
210− assert.Contains(t, response.Content, "stdout message")
211− assert.Contains(t, response.Content, "stderr message")
212− })
213−
214− t.Run("handles context cancellation", func(t *testing.T) {
215− tool := NewBashTool(newMockPermissionService(true))
216− params := BashParams{
217− Command: "sleep 5",
218− }
219−
220− paramsJSON, err := json.Marshal(params)
221− require.NoError(t, err)
222−
223− call := ToolCall{
224− Name: BashToolName,
225− Input: string(paramsJSON),
226− }
227−
228− ctx, cancel := context.WithCancel(context.Background())
229−
230− // Cancel the context after a short delay
231− go func() {
232− time.Sleep(100 * time.Millisecond)
233− cancel()
234− }()
235−
236− response, err := tool.Run(ctx, call)
237− require.NoError(t, err)
238− assert.Contains(t, response.Content, "aborted")
239− })
240−
241− t.Run("respects max timeout", func(t *testing.T) {
242− tool := NewBashTool(newMockPermissionService(true))
243− params := BashParams{
244− Command: "echo 'test'",
245− Timeout: MaxTimeout + 1000, // Exceeds max timeout
246− }
247−
248− paramsJSON, err := json.Marshal(params)
249− require.NoError(t, err)
250−
251− call := ToolCall{
252− Name: BashToolName,
253− Input: string(paramsJSON),
254− }
255−
256− response, err := tool.Run(context.Background(), call)
257− require.NoError(t, err)
258− assert.Equal(t, "test\n", response.Content)
259− })
260−
261− t.Run("uses default timeout for zero or negative timeout", func(t *testing.T) {
262− tool := NewBashTool(newMockPermissionService(true))
263− params := BashParams{
264− Command: "echo 'test'",
265− Timeout: -100, // Negative timeout
266− }
267−
268− paramsJSON, err := json.Marshal(params)
269− require.NoError(t, err)
270−
271− call := ToolCall{
272− Name: BashToolName,
273− Input: string(paramsJSON),
274− }
275−
276− response, err := tool.Run(context.Background(), call)
277− require.NoError(t, err)
278− assert.Equal(t, "test\n", response.Content)
279− })
280−}
281−
282−func TestTruncateOutput(t *testing.T) {
283− t.Run("does not truncate short output", func(t *testing.T) {
284− output := "short output"
285− result := truncateOutput(output)
286− assert.Equal(t, output, result)
287− })
288−
289− t.Run("truncates long output", func(t *testing.T) {
290− // Create a string longer than MaxOutputLength
291− longOutput := strings.Repeat("a\n", MaxOutputLength)
292− result := truncateOutput(longOutput)
293−
294− // Check that the result is shorter than the original
295− assert.Less(t, len(result), len(longOutput))
296−
297− // Check that the truncation message is included
298− assert.Contains(t, result, "lines truncated")
299−
300− // Check that we have the beginning and end of the original string
301− assert.True(t, strings.HasPrefix(result, "a\n"))
302− assert.True(t, strings.HasSuffix(result, "a\n"))
303− })
304−}
305−
306−func TestCountLines(t *testing.T) {
307− testCases := []struct {
308− name string
309− input string
310− expected int
311− }{
312− {
313− name: "empty string",
314− input: "",
315− expected: 0,
316− },
317− {
318− name: "single line",
319− input: "line1",
320− expected: 1,
321− },
322− {
323− name: "multiple lines",
324− input: "line1\nline2\nline3",
325− expected: 3,
326− },
327− {
328− name: "trailing newline",
329− input: "line1\nline2\n",
330− expected: 3, // Empty string after last newline counts as a line
331− },
332− }
333−
334− for _, tc := range testCases {
335− t.Run(tc.name, func(t *testing.T) {
336− result := countLines(tc.input)
337− assert.Equal(t, tc.expected, result)
338− })
339− }
340−}
internal/llm/tools/edit_test.godeleted−461
@@ -1,461 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6− "os"
7− "path/filepath"
8− "testing"
9− "time"
10−
11− "github.com/kujtimiihoxha/opencode/internal/lsp"
12− "github.com/stretchr/testify/assert"
13− "github.com/stretchr/testify/require"
14−)
15−
16−func TestEditTool_Info(t *testing.T) {
17− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
18− info := tool.Info()
19−
20− assert.Equal(t, EditToolName, info.Name)
21− assert.NotEmpty(t, info.Description)
22− assert.Contains(t, info.Parameters, "file_path")
23− assert.Contains(t, info.Parameters, "old_string")
24− assert.Contains(t, info.Parameters, "new_string")
25− assert.Contains(t, info.Required, "file_path")
26− assert.Contains(t, info.Required, "old_string")
27− assert.Contains(t, info.Required, "new_string")
28−}
29−
30−func TestEditTool_Run(t *testing.T) {
31− // Create a temporary directory for testing
32− tempDir, err := os.MkdirTemp("", "edit_tool_test")
33− require.NoError(t, err)
34− defer os.RemoveAll(tempDir)
35−
36− t.Run("creates a new file successfully", func(t *testing.T) {
37− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
38−
39− filePath := filepath.Join(tempDir, "new_file.txt")
40− content := "This is a test content"
41−
42− params := EditParams{
43− FilePath: filePath,
44− OldString: "",
45− NewString: content,
46− }
47−
48− paramsJSON, err := json.Marshal(params)
49− require.NoError(t, err)
50−
51− call := ToolCall{
52− Name: EditToolName,
53− Input: string(paramsJSON),
54− }
55−
56− response, err := tool.Run(context.Background(), call)
57− require.NoError(t, err)
58− assert.Contains(t, response.Content, "File created")
59−
60− // Verify file was created with correct content
61− fileContent, err := os.ReadFile(filePath)
62− require.NoError(t, err)
63− assert.Equal(t, content, string(fileContent))
64− })
65−
66− t.Run("creates file with nested directories", func(t *testing.T) {
67− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
68−
69− filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
70− content := "Content in nested directory"
71−
72− params := EditParams{
73− FilePath: filePath,
74− OldString: "",
75− NewString: content,
76− }
77−
78− paramsJSON, err := json.Marshal(params)
79− require.NoError(t, err)
80−
81− call := ToolCall{
82− Name: EditToolName,
83− Input: string(paramsJSON),
84− }
85−
86− response, err := tool.Run(context.Background(), call)
87− require.NoError(t, err)
88− assert.Contains(t, response.Content, "File created")
89−
90− // Verify file was created with correct content
91− fileContent, err := os.ReadFile(filePath)
92− require.NoError(t, err)
93− assert.Equal(t, content, string(fileContent))
94− })
95−
96− t.Run("fails to create file that already exists", func(t *testing.T) {
97− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
98−
99− // Create a file first
100− filePath := filepath.Join(tempDir, "existing_file.txt")
101− initialContent := "Initial content"
102− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
103− require.NoError(t, err)
104−
105− // Try to create the same file
106− params := EditParams{
107− FilePath: filePath,
108− OldString: "",
109− NewString: "New content",
110− }
111−
112− paramsJSON, err := json.Marshal(params)
113− require.NoError(t, err)
114−
115− call := ToolCall{
116− Name: EditToolName,
117− Input: string(paramsJSON),
118− }
119−
120− response, err := tool.Run(context.Background(), call)
121− require.NoError(t, err)
122− assert.Contains(t, response.Content, "file already exists")
123− })
124−
125− t.Run("fails to create file when path is a directory", func(t *testing.T) {
126− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
127−
128− // Create a directory
129− dirPath := filepath.Join(tempDir, "test_dir")
130− err := os.Mkdir(dirPath, 0o755)
131− require.NoError(t, err)
132−
133− // Try to create a file with the same path as the directory
134− params := EditParams{
135− FilePath: dirPath,
136− OldString: "",
137− NewString: "Some content",
138− }
139−
140− paramsJSON, err := json.Marshal(params)
141− require.NoError(t, err)
142−
143− call := ToolCall{
144− Name: EditToolName,
145− Input: string(paramsJSON),
146− }
147−
148− response, err := tool.Run(context.Background(), call)
149− require.NoError(t, err)
150− assert.Contains(t, response.Content, "path is a directory")
151− })
152−
153− t.Run("replaces content successfully", func(t *testing.T) {
154− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
155−
156− // Create a file first
157− filePath := filepath.Join(tempDir, "replace_content.txt")
158− initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
159− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
160− require.NoError(t, err)
161−
162− // Record the file read to avoid modification time check failure
163− recordFileRead(filePath)
164−
165− // Replace content
166− oldString := "Line 2\nLine 3"
167− newString := "Line 2 modified\nLine 3 modified"
168− params := EditParams{
169− FilePath: filePath,
170− OldString: oldString,
171− NewString: newString,
172− }
173−
174− paramsJSON, err := json.Marshal(params)
175− require.NoError(t, err)
176−
177− call := ToolCall{
178− Name: EditToolName,
179− Input: string(paramsJSON),
180− }
181−
182− response, err := tool.Run(context.Background(), call)
183− require.NoError(t, err)
184− assert.Contains(t, response.Content, "Content replaced")
185−
186− // Verify file was updated with correct content
187− expectedContent := "Line 1\nLine 2 modified\nLine 3 modified\nLine 4\nLine 5"
188− fileContent, err := os.ReadFile(filePath)
189− require.NoError(t, err)
190− assert.Equal(t, expectedContent, string(fileContent))
191− })
192−
193− t.Run("deletes content successfully", func(t *testing.T) {
194− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
195−
196− // Create a file first
197− filePath := filepath.Join(tempDir, "delete_content.txt")
198− initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
199− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
200− require.NoError(t, err)
201−
202− // Record the file read to avoid modification time check failure
203− recordFileRead(filePath)
204−
205− // Delete content
206− oldString := "Line 2\nLine 3\n"
207− params := EditParams{
208− FilePath: filePath,
209− OldString: oldString,
210− NewString: "",
211− }
212−
213− paramsJSON, err := json.Marshal(params)
214− require.NoError(t, err)
215−
216− call := ToolCall{
217− Name: EditToolName,
218− Input: string(paramsJSON),
219− }
220−
221− response, err := tool.Run(context.Background(), call)
222− require.NoError(t, err)
223− assert.Contains(t, response.Content, "Content deleted")
224−
225− // Verify file was updated with correct content
226− expectedContent := "Line 1\nLine 4\nLine 5"
227− fileContent, err := os.ReadFile(filePath)
228− require.NoError(t, err)
229− assert.Equal(t, expectedContent, string(fileContent))
230− })
231−
232− t.Run("handles invalid parameters", func(t *testing.T) {
233− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
234−
235− call := ToolCall{
236− Name: EditToolName,
237− Input: "invalid json",
238− }
239−
240− response, err := tool.Run(context.Background(), call)
241− require.NoError(t, err)
242− assert.Contains(t, response.Content, "invalid parameters")
243− })
244−
245− t.Run("handles missing file_path", func(t *testing.T) {
246− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
247−
248− params := EditParams{
249− FilePath: "",
250− OldString: "old",
251− NewString: "new",
252− }
253−
254− paramsJSON, err := json.Marshal(params)
255− require.NoError(t, err)
256−
257− call := ToolCall{
258− Name: EditToolName,
259− Input: string(paramsJSON),
260− }
261−
262− response, err := tool.Run(context.Background(), call)
263− require.NoError(t, err)
264− assert.Contains(t, response.Content, "file_path is required")
265− })
266−
267− t.Run("handles file not found", func(t *testing.T) {
268− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
269−
270− filePath := filepath.Join(tempDir, "non_existent_file.txt")
271− params := EditParams{
272− FilePath: filePath,
273− OldString: "old content",
274− NewString: "new content",
275− }
276−
277− paramsJSON, err := json.Marshal(params)
278− require.NoError(t, err)
279−
280− call := ToolCall{
281− Name: EditToolName,
282− Input: string(paramsJSON),
283− }
284−
285− response, err := tool.Run(context.Background(), call)
286− require.NoError(t, err)
287− assert.Contains(t, response.Content, "file not found")
288− })
289−
290− t.Run("handles old_string not found in file", func(t *testing.T) {
291− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
292−
293− // Create a file first
294− filePath := filepath.Join(tempDir, "content_not_found.txt")
295− initialContent := "Line 1\nLine 2\nLine 3"
296− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
297− require.NoError(t, err)
298−
299− // Record the file read to avoid modification time check failure
300− recordFileRead(filePath)
301−
302− // Try to replace content that doesn't exist
303− params := EditParams{
304− FilePath: filePath,
305− OldString: "This content does not exist",
306− NewString: "new content",
307− }
308−
309− paramsJSON, err := json.Marshal(params)
310− require.NoError(t, err)
311−
312− call := ToolCall{
313− Name: EditToolName,
314− Input: string(paramsJSON),
315− }
316−
317− response, err := tool.Run(context.Background(), call)
318− require.NoError(t, err)
319− assert.Contains(t, response.Content, "old_string not found in file")
320− })
321−
322− t.Run("handles multiple occurrences of old_string", func(t *testing.T) {
323− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
324−
325− // Create a file with duplicate content
326− filePath := filepath.Join(tempDir, "duplicate_content.txt")
327− initialContent := "Line 1\nDuplicate\nLine 3\nDuplicate\nLine 5"
328− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
329− require.NoError(t, err)
330−
331− // Record the file read to avoid modification time check failure
332− recordFileRead(filePath)
333−
334− // Try to replace content that appears multiple times
335− params := EditParams{
336− FilePath: filePath,
337− OldString: "Duplicate",
338− NewString: "Replaced",
339− }
340−
341− paramsJSON, err := json.Marshal(params)
342− require.NoError(t, err)
343−
344− call := ToolCall{
345− Name: EditToolName,
346− Input: string(paramsJSON),
347− }
348−
349− response, err := tool.Run(context.Background(), call)
350− require.NoError(t, err)
351− assert.Contains(t, response.Content, "appears multiple times")
352− })
353−
354− t.Run("handles file modified since last read", func(t *testing.T) {
355− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
356−
357− // Create a file
358− filePath := filepath.Join(tempDir, "modified_file.txt")
359− initialContent := "Initial content"
360− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
361− require.NoError(t, err)
362−
363− // Record an old read time
364− fileRecordMutex.Lock()
365− fileRecords[filePath] = fileRecord{
366− path: filePath,
367− readTime: time.Now().Add(-1 * time.Hour),
368− }
369− fileRecordMutex.Unlock()
370−
371− // Try to update the file
372− params := EditParams{
373− FilePath: filePath,
374− OldString: "Initial",
375− NewString: "Updated",
376− }
377−
378− paramsJSON, err := json.Marshal(params)
379− require.NoError(t, err)
380−
381− call := ToolCall{
382− Name: EditToolName,
383− Input: string(paramsJSON),
384− }
385−
386− response, err := tool.Run(context.Background(), call)
387− require.NoError(t, err)
388− assert.Contains(t, response.Content, "has been modified since it was last read")
389−
390− // Verify file was not modified
391− fileContent, err := os.ReadFile(filePath)
392− require.NoError(t, err)
393− assert.Equal(t, initialContent, string(fileContent))
394− })
395−
396− t.Run("handles file not read before editing", func(t *testing.T) {
397− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
398−
399− // Create a file
400− filePath := filepath.Join(tempDir, "not_read_file.txt")
401− initialContent := "Initial content"
402− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
403− require.NoError(t, err)
404−
405− // Try to update the file without reading it first
406− params := EditParams{
407− FilePath: filePath,
408− OldString: "Initial",
409− NewString: "Updated",
410− }
411−
412− paramsJSON, err := json.Marshal(params)
413− require.NoError(t, err)
414−
415− call := ToolCall{
416− Name: EditToolName,
417− Input: string(paramsJSON),
418− }
419−
420− response, err := tool.Run(context.Background(), call)
421− require.NoError(t, err)
422− assert.Contains(t, response.Content, "you must read the file before editing it")
423− })
424−
425− t.Run("handles permission denied", func(t *testing.T) {
426− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false), newMockFileHistoryService())
427−
428− // Create a file
429− filePath := filepath.Join(tempDir, "permission_denied.txt")
430− initialContent := "Initial content"
431− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
432− require.NoError(t, err)
433−
434− // Record the file read to avoid modification time check failure
435− recordFileRead(filePath)
436−
437− // Try to update the file
438− params := EditParams{
439− FilePath: filePath,
440− OldString: "Initial",
441− NewString: "Updated",
442− }
443−
444− paramsJSON, err := json.Marshal(params)
445− require.NoError(t, err)
446−
447− call := ToolCall{
448− Name: EditToolName,
449− Input: string(paramsJSON),
450− }
451−
452− response, err := tool.Run(context.Background(), call)
453− require.NoError(t, err)
454− assert.Contains(t, response.Content, "permission denied")
455−
456− // Verify file was not modified
457− fileContent, err := os.ReadFile(filePath)
458− require.NoError(t, err)
459− assert.Equal(t, initialContent, string(fileContent))
460− })
461−}
internal/llm/tools/mocks_test.godeleted−246
@@ -1,246 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "fmt"
6− "sort"
7− "strconv"
8− "strings"
9− "time"
10−
11− "github.com/google/uuid"
12− "github.com/kujtimiihoxha/opencode/internal/history"
13− "github.com/kujtimiihoxha/opencode/internal/permission"
14− "github.com/kujtimiihoxha/opencode/internal/pubsub"
15−)
16−
17−// Mock permission service for testing
18−type mockPermissionService struct {
19− *pubsub.Broker[permission.PermissionRequest]
20− allow bool
21−}
22−
23−func (m *mockPermissionService) GrantPersistant(permission permission.PermissionRequest) {
24− // Not needed for tests
25−}
26−
27−func (m *mockPermissionService) Grant(permission permission.PermissionRequest) {
28− // Not needed for tests
29−}
30−
31−func (m *mockPermissionService) Deny(permission permission.PermissionRequest) {
32− // Not needed for tests
33−}
34−
35−func (m *mockPermissionService) Request(opts permission.CreatePermissionRequest) bool {
36− return m.allow
37−}
38−
39−func newMockPermissionService(allow bool) permission.Service {
40− return &mockPermissionService{
41− Broker: pubsub.NewBroker[permission.PermissionRequest](),
42− allow: allow,
43− }
44−}
45−
46−type mockFileHistoryService struct {
47− *pubsub.Broker[history.File]
48− files map[string]history.File // ID -> File
49− timeNow func() int64
50−}
51−
52−// Create implements history.Service.
53−func (m *mockFileHistoryService) Create(ctx context.Context, sessionID string, path string, content string) (history.File, error) {
54− return m.createWithVersion(ctx, sessionID, path, content, history.InitialVersion)
55−}
56−
57−// CreateVersion implements history.Service.
58−func (m *mockFileHistoryService) CreateVersion(ctx context.Context, sessionID string, path string, content string) (history.File, error) {
59− var files []history.File
60− for _, file := range m.files {
61− if file.Path == path {
62− files = append(files, file)
63− }
64− }
65−
66− if len(files) == 0 {
67− // No previous versions, create initial
68− return m.Create(ctx, sessionID, path, content)
69− }
70−
71− // Sort files by CreatedAt in descending order
72− sort.Slice(files, func(i, j int) bool {
73− return files[i].CreatedAt > files[j].CreatedAt
74− })
75−
76− // Get the latest version
77− latestFile := files[0]
78− latestVersion := latestFile.Version
79−
80− // Generate the next version
81− var nextVersion string
82− if latestVersion == history.InitialVersion {
83− nextVersion = "v1"
84− } else if strings.HasPrefix(latestVersion, "v") {
85− versionNum, err := strconv.Atoi(latestVersion[1:])
86− if err != nil {
87− // If we can't parse the version, just use a timestamp-based version
88− nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
89− } else {
90− nextVersion = fmt.Sprintf("v%d", versionNum+1)
91− }
92− } else {
93− // If the version format is unexpected, use a timestamp-based version
94− nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
95− }
96−
97− return m.createWithVersion(ctx, sessionID, path, content, nextVersion)
98−}
99−
100−func (m *mockFileHistoryService) createWithVersion(_ context.Context, sessionID, path, content, version string) (history.File, error) {
101− now := m.timeNow()
102− file := history.File{
103− ID: uuid.New().String(),
104− SessionID: sessionID,
105− Path: path,
106− Content: content,
107− Version: version,
108− CreatedAt: now,
109− UpdatedAt: now,
110− }
111−
112− m.files[file.ID] = file
113− m.Publish(pubsub.CreatedEvent, file)
114− return file, nil
115−}
116−
117−// Delete implements history.Service.
118−func (m *mockFileHistoryService) Delete(ctx context.Context, id string) error {
119− file, ok := m.files[id]
120− if !ok {
121− return fmt.Errorf("file not found: %s", id)
122− }
123−
124− delete(m.files, id)
125− m.Publish(pubsub.DeletedEvent, file)
126− return nil
127−}
128−
129−// DeleteSessionFiles implements history.Service.
130−func (m *mockFileHistoryService) DeleteSessionFiles(ctx context.Context, sessionID string) error {
131− files, err := m.ListBySession(ctx, sessionID)
132− if err != nil {
133− return err
134− }
135−
136− for _, file := range files {
137− err = m.Delete(ctx, file.ID)
138− if err != nil {
139− return err
140− }
141− }
142−
143− return nil
144−}
145−
146−// Get implements history.Service.
147−func (m *mockFileHistoryService) Get(ctx context.Context, id string) (history.File, error) {
148− file, ok := m.files[id]
149− if !ok {
150− return history.File{}, fmt.Errorf("file not found: %s", id)
151− }
152− return file, nil
153−}
154−
155−// GetByPathAndSession implements history.Service.
156−func (m *mockFileHistoryService) GetByPathAndSession(ctx context.Context, path string, sessionID string) (history.File, error) {
157− var latestFile history.File
158− var found bool
159− var latestTime int64
160−
161− for _, file := range m.files {
162− if file.Path == path && file.SessionID == sessionID {
163− if !found || file.CreatedAt > latestTime {
164− latestFile = file
165− latestTime = file.CreatedAt
166− found = true
167− }
168− }
169− }
170−
171− if !found {
172− return history.File{}, fmt.Errorf("file not found: %s for session %s", path, sessionID)
173− }
174− return latestFile, nil
175−}
176−
177−// ListBySession implements history.Service.
178−func (m *mockFileHistoryService) ListBySession(ctx context.Context, sessionID string) ([]history.File, error) {
179− var files []history.File
180− for _, file := range m.files {
181− if file.SessionID == sessionID {
182− files = append(files, file)
183− }
184− }
185−
186− // Sort by CreatedAt in descending order
187− sort.Slice(files, func(i, j int) bool {
188− return files[i].CreatedAt > files[j].CreatedAt
189− })
190−
191− return files, nil
192−}
193−
194−// ListLatestSessionFiles implements history.Service.
195−func (m *mockFileHistoryService) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]history.File, error) {
196− // Map to track the latest file for each path
197− latestFiles := make(map[string]history.File)
198−
199− for _, file := range m.files {
200− if file.SessionID == sessionID {
201− existing, ok := latestFiles[file.Path]
202− if !ok || file.CreatedAt > existing.CreatedAt {
203− latestFiles[file.Path] = file
204− }
205− }
206− }
207−
208− // Convert map to slice
209− var result []history.File
210− for _, file := range latestFiles {
211− result = append(result, file)
212− }
213−
214− // Sort by CreatedAt in descending order
215− sort.Slice(result, func(i, j int) bool {
216− return result[i].CreatedAt > result[j].CreatedAt
217− })
218−
219− return result, nil
220−}
221−
222−// Subscribe implements history.Service.
223−func (m *mockFileHistoryService) Subscribe(ctx context.Context) <-chan pubsub.Event[history.File] {
224− return m.Broker.Subscribe(ctx)
225−}
226−
227−// Update implements history.Service.
228−func (m *mockFileHistoryService) Update(ctx context.Context, file history.File) (history.File, error) {
229− _, ok := m.files[file.ID]
230− if !ok {
231− return history.File{}, fmt.Errorf("file not found: %s", file.ID)
232− }
233−
234− file.UpdatedAt = m.timeNow()
235− m.files[file.ID] = file
236− m.Publish(pubsub.UpdatedEvent, file)
237− return file, nil
238−}
239−
240−func newMockFileHistoryService() history.Service {
241− return &mockFileHistoryService{
242− Broker: pubsub.NewBroker[history.File](),
243− files: make(map[string]history.File),
244− timeNow: func() int64 { return time.Now().Unix() },
245− }
246−}
internal/llm/tools/patch.gonew file+300
@@ -0,0 +1,300 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+
12+ "github.com/kujtimiihoxha/opencode/internal/config"
13+ "github.com/kujtimiihoxha/opencode/internal/diff"
14+ "github.com/kujtimiihoxha/opencode/internal/history"
15+ "github.com/kujtimiihoxha/opencode/internal/lsp"
16+ "github.com/kujtimiihoxha/opencode/internal/permission"
17+)
18+
19+type PatchParams struct {
20+ FilePath string `json:"file_path"`
21+ Patch string `json:"patch"`
22+}
23+
24+type PatchPermissionsParams struct {
25+ FilePath string `json:"file_path"`
26+ Diff string `json:"diff"`
27+}
28+
29+type PatchResponseMetadata struct {
30+ Diff string `json:"diff"`
31+ Additions int `json:"additions"`
32+ Removals int `json:"removals"`
33+}
34+
35+type patchTool struct {
36+ lspClients map[string]*lsp.Client
37+ permissions permission.Service
38+ files history.Service
39+}
40+
41+const (
42+ // TODO: test if this works as expected
43+ PatchToolName = "patch"
44+ 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.
45+
46+Before using this tool:
47+
48+1. Use the FileRead tool to understand the file's contents and context
49+
50+2. Verify the directory path is correct:
51+ - Use the LS tool to verify the parent directory exists and is the correct location
52+
53+To apply a patch, provide the following:
54+1. file_path: The absolute path to the file to modify (must be absolute, not relative)
55+2. patch: A unified diff patch to apply to the file
56+
57+The tool will apply the patch to the specified file. The patch must be in unified diff format.
58+
59+CRITICAL REQUIREMENTS FOR USING THIS TOOL:
60+
61+1. PATCH FORMAT: The patch must be in unified diff format, which includes:
62+ - File headers (--- a/file_path, +++ b/file_path)
63+ - Hunk headers (@@ -start,count +start,count @@)
64+ - Added lines (prefixed with +)
65+ - Removed lines (prefixed with -)
66+
67+2. CONTEXT: The patch must include sufficient context around the changes to ensure it applies correctly.
68+
69+3. VERIFICATION: Before using this tool:
70+ - Ensure the patch applies cleanly to the current state of the file
71+ - Check that the file exists and you have read it first
72+
73+WARNING: If you do not follow these requirements:
74+ - The tool will fail if the patch doesn't apply cleanly
75+ - You may change the wrong parts of the file if the context is insufficient
76+
77+When applying patches:
78+ - Ensure the patch results in idiomatic, correct code
79+ - Do not leave the code in a broken state
80+ - Always use absolute file paths (starting with /)
81+
82+Remember: patches are a powerful way to make multiple related changes at once, but they require careful preparation.`
83+)
84+
85+func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
86+ return &patchTool{
87+ lspClients: lspClients,
88+ permissions: permissions,
89+ files: files,
90+ }
91+}
92+
93+func (p *patchTool) Info() ToolInfo {
94+ return ToolInfo{
95+ Name: PatchToolName,
96+ Description: patchDescription,
97+ Parameters: map[string]any{
98+ "file_path": map[string]any{
99+ "type": "string",
100+ "description": "The absolute path to the file to modify",
101+ },
102+ "patch": map[string]any{
103+ "type": "string",
104+ "description": "The unified diff patch to apply",
105+ },
106+ },
107+ Required: []string{"file_path", "patch"},
108+ }
109+}
110+
111+func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
112+ var params PatchParams
113+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
114+ return NewTextErrorResponse("invalid parameters"), nil
115+ }
116+
117+ if params.FilePath == "" {
118+ return NewTextErrorResponse("file_path is required"), nil
119+ }
120+
121+ if params.Patch == "" {
122+ return NewTextErrorResponse("patch is required"), nil
123+ }
124+
125+ if !filepath.IsAbs(params.FilePath) {
126+ wd := config.WorkingDirectory()
127+ params.FilePath = filepath.Join(wd, params.FilePath)
128+ }
129+
130+ // Check if file exists
131+ fileInfo, err := os.Stat(params.FilePath)
132+ if err != nil {
133+ if os.IsNotExist(err) {
134+ return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
135+ }
136+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
137+ }
138+
139+ if fileInfo.IsDir() {
140+ return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
141+ }
142+
143+ if getLastReadTime(params.FilePath).IsZero() {
144+ return NewTextErrorResponse("you must read the file before patching it. Use the View tool first"), nil
145+ }
146+
147+ modTime := fileInfo.ModTime()
148+ lastRead := getLastReadTime(params.FilePath)
149+ if modTime.After(lastRead) {
150+ return NewTextErrorResponse(
151+ fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
152+ params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
153+ )), nil
154+ }
155+
156+ // Read the current file content
157+ content, err := os.ReadFile(params.FilePath)
158+ if err != nil {
159+ return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
160+ }
161+
162+ oldContent := string(content)
163+
164+ // Parse and apply the patch
165+ diffResult, err := diff.ParseUnifiedDiff(params.Patch)
166+ if err != nil {
167+ return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %v", err)), nil
168+ }
169+
170+ // Apply the patch to get the new content
171+ newContent, err := applyPatch(oldContent, diffResult)
172+ if err != nil {
173+ return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %v", err)), nil
174+ }
175+
176+ if oldContent == newContent {
177+ return NewTextErrorResponse("patch did not result in any changes to the file"), nil
178+ }
179+
180+ sessionID, messageID := GetContextValues(ctx)
181+ if sessionID == "" || messageID == "" {
182+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for patching a file")
183+ }
184+
185+ // Generate a diff for permission request and metadata
186+ diffText, additions, removals := diff.GenerateDiff(
187+ oldContent,
188+ newContent,
189+ params.FilePath,
190+ )
191+
192+ // Request permission to apply the patch
193+ p.permissions.Request(
194+ permission.CreatePermissionRequest{
195+ Path: filepath.Dir(params.FilePath),
196+ ToolName: PatchToolName,
197+ Action: "patch",
198+ Description: fmt.Sprintf("Apply patch to file %s", params.FilePath),
199+ Params: PatchPermissionsParams{
200+ FilePath: params.FilePath,
201+ Diff: diffText,
202+ },
203+ },
204+ )
205+
206+ // Write the new content to the file
207+ err = os.WriteFile(params.FilePath, []byte(newContent), 0o644)
208+ if err != nil {
209+ return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
210+ }
211+
212+ // Update file history
213+ file, err := p.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
214+ if err != nil {
215+ _, err = p.files.Create(ctx, sessionID, params.FilePath, oldContent)
216+ if err != nil {
217+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
218+ }
219+ }
220+ if file.Content != oldContent {
221+ // User manually changed the content, store an intermediate version
222+ _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
223+ if err != nil {
224+ fmt.Printf("Error creating file history version: %v\n", err)
225+ }
226+ }
227+ // Store the new version
228+ _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, newContent)
229+ if err != nil {
230+ fmt.Printf("Error creating file history version: %v\n", err)
231+ }
232+
233+ recordFileWrite(params.FilePath)
234+ recordFileRead(params.FilePath)
235+
236+ // Wait for LSP diagnostics and include them in the response
237+ waitForLspDiagnostics(ctx, params.FilePath, p.lspClients)
238+ text := fmt.Sprintf("<r>\nPatch applied to file: %s\n</r>\n", params.FilePath)
239+ text += getDiagnostics(params.FilePath, p.lspClients)
240+
241+ return WithResponseMetadata(
242+ NewTextResponse(text),
243+ PatchResponseMetadata{
244+ Diff: diffText,
245+ Additions: additions,
246+ Removals: removals,
247+ }), nil
248+}
249+
250+// applyPatch applies a parsed diff to a string and returns the resulting content
251+func applyPatch(content string, diffResult diff.DiffResult) (string, error) {
252+ lines := strings.Split(content, "\n")
253+
254+ // Process each hunk in the diff
255+ for _, hunk := range diffResult.Hunks {
256+ // Parse the hunk header to get line numbers
257+ var oldStart, oldCount, newStart, newCount int
258+ _, err := fmt.Sscanf(hunk.Header, "@@ -%d,%d +%d,%d @@", &oldStart, &oldCount, &newStart, &newCount)
259+ if err != nil {
260+ // Try alternative format with single line counts
261+ _, err = fmt.Sscanf(hunk.Header, "@@ -%d +%d @@", &oldStart, &newStart)
262+ if err != nil {
263+ return "", fmt.Errorf("invalid hunk header format: %s", hunk.Header)
264+ }
265+ oldCount = 1
266+ newCount = 1
267+ }
268+
269+ // Adjust for 0-based array indexing
270+ oldStart--
271+ newStart--
272+
273+ // Apply the changes
274+ newLines := make([]string, 0)
275+ newLines = append(newLines, lines[:oldStart]...)
276+
277+ // Process the hunk lines in order
278+ currentOldLine := oldStart
279+ for _, line := range hunk.Lines {
280+ switch line.Kind {
281+ case diff.LineContext:
282+ newLines = append(newLines, line.Content)
283+ currentOldLine++
284+ case diff.LineRemoved:
285+ // Skip this line in the output (it's being removed)
286+ currentOldLine++
287+ case diff.LineAdded:
288+ // Add the new line
289+ newLines = append(newLines, line.Content)
290+ }
291+ }
292+
293+ // Append the rest of the file
294+ newLines = append(newLines, lines[currentOldLine:]...)
295+ lines = newLines
296+ }
297+
298+ return strings.Join(lines, "\n"), nil
299+}
300+
internal/llm/tools/sourcegraph_test.godeleted−86
@@ -1,86 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6− "testing"
7−
8− "github.com/stretchr/testify/assert"
9− "github.com/stretchr/testify/require"
10−)
11−
12−func TestSourcegraphTool_Info(t *testing.T) {
13− tool := NewSourcegraphTool()
14− info := tool.Info()
15−
16− assert.Equal(t, SourcegraphToolName, info.Name)
17− assert.NotEmpty(t, info.Description)
18− assert.Contains(t, info.Parameters, "query")
19− assert.Contains(t, info.Parameters, "count")
20− assert.Contains(t, info.Parameters, "timeout")
21− assert.Contains(t, info.Required, "query")
22−}
23−
24−func TestSourcegraphTool_Run(t *testing.T) {
25− t.Run("handles missing query parameter", func(t *testing.T) {
26− tool := NewSourcegraphTool()
27− params := SourcegraphParams{
28− Query: "",
29− }
30−
31− paramsJSON, err := json.Marshal(params)
32− require.NoError(t, err)
33−
34− call := ToolCall{
35− Name: SourcegraphToolName,
36− Input: string(paramsJSON),
37− }
38−
39− response, err := tool.Run(context.Background(), call)
40− require.NoError(t, err)
41− assert.Contains(t, response.Content, "Query parameter is required")
42− })
43−
44− t.Run("handles invalid parameters", func(t *testing.T) {
45− tool := NewSourcegraphTool()
46− call := ToolCall{
47− Name: SourcegraphToolName,
48− Input: "invalid json",
49− }
50−
51− response, err := tool.Run(context.Background(), call)
52− require.NoError(t, err)
53− assert.Contains(t, response.Content, "Failed to parse sourcegraph parameters")
54− })
55−
56− t.Run("normalizes count parameter", func(t *testing.T) {
57− // Test cases for count normalization
58− testCases := []struct {
59− name string
60− inputCount int
61− expectedCount int
62− }{
63− {"negative count", -5, 10}, // Should use default (10)
64− {"zero count", 0, 10}, // Should use default (10)
65− {"valid count", 50, 50}, // Should keep as is
66− {"excessive count", 150, 100}, // Should cap at 100
67− }
68−
69− for _, tc := range testCases {
70− t.Run(tc.name, func(t *testing.T) {
71− // Verify count normalization logic directly
72− assert.NotPanics(t, func() {
73− // Apply the same normalization logic as in the tool
74− normalizedCount := tc.inputCount
75− if normalizedCount <= 0 {
76− normalizedCount = 10
77− } else if normalizedCount > 100 {
78− normalizedCount = 100
79− }
80−
81− assert.Equal(t, tc.expectedCount, normalizedCount)
82− })
83− })
84− }
85− })
86−}
internal/llm/tools/write_test.godeleted−307
@@ -1,307 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6− "os"
7− "path/filepath"
8− "testing"
9− "time"
10−
11− "github.com/kujtimiihoxha/opencode/internal/lsp"
12− "github.com/stretchr/testify/assert"
13− "github.com/stretchr/testify/require"
14−)
15−
16−func TestWriteTool_Info(t *testing.T) {
17− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
18− info := tool.Info()
19−
20− assert.Equal(t, WriteToolName, info.Name)
21− assert.NotEmpty(t, info.Description)
22− assert.Contains(t, info.Parameters, "file_path")
23− assert.Contains(t, info.Parameters, "content")
24− assert.Contains(t, info.Required, "file_path")
25− assert.Contains(t, info.Required, "content")
26−}
27−
28−func TestWriteTool_Run(t *testing.T) {
29− // Create a temporary directory for testing
30− tempDir, err := os.MkdirTemp("", "write_tool_test")
31− require.NoError(t, err)
32− defer os.RemoveAll(tempDir)
33−
34− t.Run("creates a new file successfully", func(t *testing.T) {
35− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
36−
37− filePath := filepath.Join(tempDir, "new_file.txt")
38− content := "This is a test content"
39−
40− params := WriteParams{
41− FilePath: filePath,
42− Content: content,
43− }
44−
45− paramsJSON, err := json.Marshal(params)
46− require.NoError(t, err)
47−
48− call := ToolCall{
49− Name: WriteToolName,
50− Input: string(paramsJSON),
51− }
52−
53− response, err := tool.Run(context.Background(), call)
54− require.NoError(t, err)
55− assert.Contains(t, response.Content, "successfully written")
56−
57− // Verify file was created with correct content
58− fileContent, err := os.ReadFile(filePath)
59− require.NoError(t, err)
60− assert.Equal(t, content, string(fileContent))
61− })
62−
63− t.Run("creates file with nested directories", func(t *testing.T) {
64− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
65−
66− filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
67− content := "Content in nested directory"
68−
69− params := WriteParams{
70− FilePath: filePath,
71− Content: content,
72− }
73−
74− paramsJSON, err := json.Marshal(params)
75− require.NoError(t, err)
76−
77− call := ToolCall{
78− Name: WriteToolName,
79− Input: string(paramsJSON),
80− }
81−
82− response, err := tool.Run(context.Background(), call)
83− require.NoError(t, err)
84− assert.Contains(t, response.Content, "successfully written")
85−
86− // Verify file was created with correct content
87− fileContent, err := os.ReadFile(filePath)
88− require.NoError(t, err)
89− assert.Equal(t, content, string(fileContent))
90− })
91−
92− t.Run("updates existing file", func(t *testing.T) {
93− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
94−
95− // Create a file first
96− filePath := filepath.Join(tempDir, "existing_file.txt")
97− initialContent := "Initial content"
98− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
99− require.NoError(t, err)
100−
101− // Record the file read to avoid modification time check failure
102− recordFileRead(filePath)
103−
104− // Update the file
105− updatedContent := "Updated content"
106− params := WriteParams{
107− FilePath: filePath,
108− Content: updatedContent,
109− }
110−
111− paramsJSON, err := json.Marshal(params)
112− require.NoError(t, err)
113−
114− call := ToolCall{
115− Name: WriteToolName,
116− Input: string(paramsJSON),
117− }
118−
119− response, err := tool.Run(context.Background(), call)
120− require.NoError(t, err)
121− assert.Contains(t, response.Content, "successfully written")
122−
123− // Verify file was updated with correct content
124− fileContent, err := os.ReadFile(filePath)
125− require.NoError(t, err)
126− assert.Equal(t, updatedContent, string(fileContent))
127− })
128−
129− t.Run("handles invalid parameters", func(t *testing.T) {
130− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
131−
132− call := ToolCall{
133− Name: WriteToolName,
134− Input: "invalid json",
135− }
136−
137− response, err := tool.Run(context.Background(), call)
138− require.NoError(t, err)
139− assert.Contains(t, response.Content, "error parsing parameters")
140− })
141−
142− t.Run("handles missing file_path", func(t *testing.T) {
143− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
144−
145− params := WriteParams{
146− FilePath: "",
147− Content: "Some content",
148− }
149−
150− paramsJSON, err := json.Marshal(params)
151− require.NoError(t, err)
152−
153− call := ToolCall{
154− Name: WriteToolName,
155− Input: string(paramsJSON),
156− }
157−
158− response, err := tool.Run(context.Background(), call)
159− require.NoError(t, err)
160− assert.Contains(t, response.Content, "file_path is required")
161− })
162−
163− t.Run("handles missing content", func(t *testing.T) {
164− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
165−
166− params := WriteParams{
167− FilePath: filepath.Join(tempDir, "file.txt"),
168− Content: "",
169− }
170−
171− paramsJSON, err := json.Marshal(params)
172− require.NoError(t, err)
173−
174− call := ToolCall{
175− Name: WriteToolName,
176− Input: string(paramsJSON),
177− }
178−
179− response, err := tool.Run(context.Background(), call)
180− require.NoError(t, err)
181− assert.Contains(t, response.Content, "content is required")
182− })
183−
184− t.Run("handles writing to a directory path", func(t *testing.T) {
185− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
186−
187− // Create a directory
188− dirPath := filepath.Join(tempDir, "test_dir")
189− err := os.Mkdir(dirPath, 0o755)
190− require.NoError(t, err)
191−
192− params := WriteParams{
193− FilePath: dirPath,
194− Content: "Some content",
195− }
196−
197− paramsJSON, err := json.Marshal(params)
198− require.NoError(t, err)
199−
200− call := ToolCall{
201− Name: WriteToolName,
202− Input: string(paramsJSON),
203− }
204−
205− response, err := tool.Run(context.Background(), call)
206− require.NoError(t, err)
207− assert.Contains(t, response.Content, "Path is a directory")
208− })
209−
210− t.Run("handles permission denied", func(t *testing.T) {
211− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(false), newMockFileHistoryService())
212−
213− filePath := filepath.Join(tempDir, "permission_denied.txt")
214− params := WriteParams{
215− FilePath: filePath,
216− Content: "Content that should not be written",
217− }
218−
219− paramsJSON, err := json.Marshal(params)
220− require.NoError(t, err)
221−
222− call := ToolCall{
223− Name: WriteToolName,
224− Input: string(paramsJSON),
225− }
226−
227− response, err := tool.Run(context.Background(), call)
228− require.NoError(t, err)
229− assert.Contains(t, response.Content, "Permission denied")
230−
231− // Verify file was not created
232− _, err = os.Stat(filePath)
233− assert.True(t, os.IsNotExist(err))
234− })
235−
236− t.Run("detects file modified since last read", func(t *testing.T) {
237− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
238−
239− // Create a file
240− filePath := filepath.Join(tempDir, "modified_file.txt")
241− initialContent := "Initial content"
242− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
243− require.NoError(t, err)
244−
245− // Record an old read time
246− fileRecordMutex.Lock()
247− fileRecords[filePath] = fileRecord{
248− path: filePath,
249− readTime: time.Now().Add(-1 * time.Hour),
250− }
251− fileRecordMutex.Unlock()
252−
253− // Try to update the file
254− params := WriteParams{
255− FilePath: filePath,
256− Content: "Updated content",
257− }
258−
259− paramsJSON, err := json.Marshal(params)
260− require.NoError(t, err)
261−
262− call := ToolCall{
263− Name: WriteToolName,
264− Input: string(paramsJSON),
265− }
266−
267− response, err := tool.Run(context.Background(), call)
268− require.NoError(t, err)
269− assert.Contains(t, response.Content, "has been modified since it was last read")
270−
271− // Verify file was not modified
272− fileContent, err := os.ReadFile(filePath)
273− require.NoError(t, err)
274− assert.Equal(t, initialContent, string(fileContent))
275− })
276−
277− t.Run("skips writing when content is identical", func(t *testing.T) {
278− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
279−
280− // Create a file
281− filePath := filepath.Join(tempDir, "identical_content.txt")
282− content := "Content that won't change"
283− err := os.WriteFile(filePath, []byte(content), 0o644)
284− require.NoError(t, err)
285−
286− // Record a read time
287− recordFileRead(filePath)
288−
289− // Try to write the same content
290− params := WriteParams{
291− FilePath: filePath,
292− Content: content,
293− }
294−
295− paramsJSON, err := json.Marshal(params)
296− require.NoError(t, err)
297−
298− call := ToolCall{
299− Name: WriteToolName,
300− Input: string(paramsJSON),
301− }
302−
303− response, err := tool.Run(context.Background(), call)
304− require.NoError(t, err)
305− assert.Contains(t, response.Content, "already contains the exact content")
306− })
307−}
internal/tui/components/chat/sidebar.go+11−1
@@ -3,6 +3,7 @@ package chat
3import (
3import (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6+ "sort"
6 "strings"
7 "strings"
7
8
8 tea "github.com/charmbracelet/bubbletea"
9 tea "github.com/charmbracelet/bubbletea"
@@ -141,8 +142,17 @@ func (m *sidebarCmp) modifiedFiles() string {
141 )
142 )
142 }
143 }
143
144
145+ // Sort file paths alphabetically for consistent ordering
146+ var paths []string
147+ for path := range m.modFiles {
148+ paths = append(paths, path)
149+ }
150+ sort.Strings(paths)
151+
152+ // Create views for each file in sorted order
144 var fileViews []string
153 var fileViews []string
154+ for _, path := range paths {
145− for path, stats := range m.modFiles {
155+ stats := m.modFiles[path]
146 fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
156 fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
147 }
157 }
148
158
internal/tui/components/dialog/permission.go+14
@@ -266,6 +266,18 @@ func (p *permissionDialogCmp) renderEditContent() string {
266 return ""
266 return ""
267}
267}
268
268
269+func (p *permissionDialogCmp) renderPatchContent() string {
270+ if pr, ok := p.permission.Params.(tools.PatchPermissionsParams); ok {
271+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
272+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
273+ })
274+
275+ p.contentViewPort.SetContent(diff)
276+ return p.styleViewport()
277+ }
278+ return ""
279+}
280+
269func (p *permissionDialogCmp) renderWriteContent() string {
281func (p *permissionDialogCmp) renderWriteContent() string {
270 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
282 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
271 // Use the cache for diff rendering
283 // Use the cache for diff rendering
@@ -350,6 +362,8 @@ func (p *permissionDialogCmp) render() string {
350 contentFinal = p.renderBashContent()
362 contentFinal = p.renderBashContent()
351 case tools.EditToolName:
363 case tools.EditToolName:
352 contentFinal = p.renderEditContent()
364 contentFinal = p.renderEditContent()
365+ case tools.PatchToolName:
366+ contentFinal = p.renderPatchContent()
353 case tools.WriteToolName:
367 case tools.WriteToolName:
354 contentFinal = p.renderWriteContent()
368 contentFinal = p.renderWriteContent()
355 case tools.FetchToolName:
369 case tools.FetchToolName:
main.go+1−3
@@ -6,11 +6,9 @@ import (
6)
6)
7
7
8func main() {
8func main() {
9− // Set up panic recovery for the main function
10 defer logging.RecoverPanic("main", func() {
9 defer logging.RecoverPanic("main", func() {
11− // Perform any necessary cleanup before exit
12 logging.ErrorPersist("Application terminated due to unhandled panic")
10 logging.ErrorPersist("Application terminated due to unhandled panic")
13 })
11 })
14−
12+
15 cmd.Execute()
13 cmd.Execute()
16}
14}