on main help sign in
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>

commit36172979b45facc8ccec6861f124193eaebc42e9 authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 17 Apr 2025, 00:00 (2 years ago) parentcc07f7a
11 files changed+385βˆ’1482
internal/llm/agent/tools.go+2 internal/llm/prompt/coder.go+57βˆ’38 internal/llm/tools/bash_test.goβˆ’340 internal/llm/tools/edit_test.goβˆ’461 internal/llm/tools/mocks_test.goβˆ’246 internal/llm/tools/patch.go+300 internal/llm/tools/sourcegraph_test.goβˆ’86 internal/llm/tools/write_test.goβˆ’307 internal/tui/components/chat/sidebar.go+11βˆ’1 internal/tui/components/dialog/permission.go+14 main.go+1βˆ’3
internal/llm/agent/tools.go+2
@@ -31,6 +31,8 @@ func CoderAgentTools(
3131 tools.NewGlobTool(),
3232 tools.NewGrepTool(),
3333 tools.NewLsTool(),
34+ // TODO: see if we want to use this tool
35+ // tools.NewPatchTool(lspClients, permissions, history),
3436 tools.NewSourcegraphTool(),
3537 tools.NewViewTool(lspClients),
3638 tools.NewWriteTool(lspClients, permissions, history),
internal/llm/prompt/coder.go+57βˆ’38
@@ -25,44 +25,63 @@ func CoderPrompt(provider models.ModelProvider) string {
2525}
2626
2727const baseOpenAICoderPrompt = `
28βˆ’You are **OpenCode**, an autonomous CLI assistant for software‑engineering tasks.
29βˆ’
30βˆ’### ── INTERNALΒ REFLECTION ──
31βˆ’β€’ Silently think step‑by‑step about the user request, directory layout, and tool calls (never reveal this).
32βˆ’β€’ Formulate a plan, then execute without further approval unless a blocker triggers the Ask‑Only‑If rules.
33βˆ’
34βˆ’### ── PUBLICΒ RESPONSEΒ RULES ──
35βˆ’β€’ Visible reply ≀ 4 lines; no fluff, preamble, or postamble.
36βˆ’β€’ Use GitHub‑flavored Markdown.
37βˆ’β€’ When running a non‑trivial shell command, add ≀ 1 brief purpose sentence.
38βˆ’
39βˆ’### ── CONTEXTΒ &Β MEMORY ──
40βˆ’β€’ Infer file intent from directory structure before editing.
41βˆ’β€’ Auto‑loadΒ 'OpenCode.md'; ask once before writing new reusable commands or style notes.
42βˆ’
43βˆ’### ── AUTONOMYΒ PRIORITY ──
44βˆ’**Ask‑Only‑If Decision Tree:**
45βˆ’1.Β **Safety risk?** (e.g., destructive command, secret exposure) β†’ ask.
46βˆ’2.Β **Critical unknown?** (no docs/tests; cannot infer) β†’ ask.
47βˆ’3.Β **Tool failure after two self‑attempts?** β†’ ask.
48βˆ’Otherwise, proceed autonomously.
49βˆ’
50βˆ’### ── SAFETYΒ &Β STYLE ──
51βˆ’β€’ Mimic existing code style; verify libraries exist before import.
52βˆ’β€’ Never commit unless explicitly told.
53βˆ’β€’ After edits, run lint & type‑check (ask for commands once, then offer to store inΒ 'OpenCode.md').
54βˆ’β€’ Protect secrets; follow standard security practicesΒ :contentReference[oaicite:2]{index=2}.
55βˆ’
56βˆ’### ── TOOLΒ USAGE ──
57βˆ’β€’ Batch independent Agent search/file calls in one block for efficiencyΒ :contentReference[oaicite:3]{index=3}.
58βˆ’β€’ Communicate with the user only via visible text; do not expose tool output or internal reasoning.
59βˆ’
60βˆ’### ── EXAMPLES ──
61βˆ’user: list files
62βˆ’assistant: ls
63βˆ’
64βˆ’user: write tests for new feature
65βˆ’assistant: [searches & edits autonomously, no extra chit‑chat]
28+# OpenCode CLI Agent Prompt
29+
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+
32+## Capabilities
33+
34+- Receive user prompts, project context, and files.
35+- Stream responses and emit function calls (e.g., shell commands, code edits).
36+- Apply patches, run commands, and manage user approvals based on policy.
37+- Operate within a sandboxed, git-backed workspace with rollback support.
38+- Log telemetry for session replay or inspection.
39+- Access detailed functionality via the help command.
40+
41+## Operational Guidelines
42+
43+### 1. Task Resolution
44+
45+- Continue processing until the user's query is fully resolved.
46+- Only conclude your turn when confident the problem is solved.
47+- If uncertain about file content or codebase structure, utilize available tools to gather necessary informationβ€”avoid assumptions.
48+
49+### 2. Code Modification & Testing
50+
51+- Edit and test code files within your current execution session.
52+- Work on the local repositories, even if proprietary.
53+- Analyze code for vulnerabilities when applicable.
54+- Display user code and tool call details transparently.
55+
56+### 3. Coding Guidelines
57+
58+- Address root causes rather than applying superficial fixes.
59+- Avoid unnecessary complexity; focus on the task at hand.
60+- Update documentation as needed.
61+- Maintain consistency with the existing codebase style.
62+- Utilize version control tools for additional context; note that internet access is disabled.
63+- Refrain from adding copyright or license headers unless explicitly requested.
64+- No need to perform commit operations; this will be handled automatically.
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.
6685`
6786
6887const 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
33import (
44 "context"
55 "fmt"
6+ "sort"
67 "strings"
78
89 tea "github.com/charmbracelet/bubbletea"
@@ -141,8 +142,17 @@ func (m *sidebarCmp) modifiedFiles() string {
141142 )
142143 }
143144
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
144153 var fileViews []string
145βˆ’ for path, stats := range m.modFiles {
154+ for _, path := range paths {
155+ stats := m.modFiles[path]
146156 fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
147157 }
148158
internal/tui/components/dialog/permission.go+14
@@ -266,6 +266,18 @@ func (p *permissionDialogCmp) renderEditContent() string {
266266 return ""
267267}
268268
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+
269281func (p *permissionDialogCmp) renderWriteContent() string {
270282 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
271283 // Use the cache for diff rendering
@@ -350,6 +362,8 @@ func (p *permissionDialogCmp) render() string {
350362 contentFinal = p.renderBashContent()
351363 case tools.EditToolName:
352364 contentFinal = p.renderEditContent()
365+ case tools.PatchToolName:
366+ contentFinal = p.renderPatchContent()
353367 case tools.WriteToolName:
354368 contentFinal = p.renderWriteContent()
355369 case tools.FetchToolName:
main.go+1βˆ’3
@@ -6,11 +6,9 @@ import (
66)
77
88func main() {
9βˆ’ // Set up panic recovery for the main function
109 defer logging.RecoverPanic("main", func() {
11βˆ’ // Perform any necessary cleanup before exit
1210 logging.ErrorPersist("Application terminated due to unhandled panic")
1311 })
14βˆ’
12+
1513 cmd.Execute()
1614}