diff options
Diffstat (limited to 'internal/llm/tools')
| -rw-r--r-- | internal/llm/tools/bash.go | 42 | ||||
| -rw-r--r-- | internal/llm/tools/bash_test.go | 371 | ||||
| -rw-r--r-- | internal/llm/tools/diagnostics.go | 8 | ||||
| -rw-r--r-- | internal/llm/tools/edit.go | 341 | ||||
| -rw-r--r-- | internal/llm/tools/edit_test.go | 509 | ||||
| -rw-r--r-- | internal/llm/tools/fetch.go | 19 | ||||
| -rw-r--r-- | internal/llm/tools/glob.go | 55 | ||||
| -rw-r--r-- | internal/llm/tools/grep.go | 145 | ||||
| -rw-r--r-- | internal/llm/tools/ls.go | 17 | ||||
| -rw-r--r-- | internal/llm/tools/patch.go | 372 | ||||
| -rw-r--r-- | internal/llm/tools/shell/shell.go | 20 | ||||
| -rw-r--r-- | internal/llm/tools/sourcegraph.go | 17 | ||||
| -rw-r--r-- | internal/llm/tools/sourcegraph_test.go | 86 | ||||
| -rw-r--r-- | internal/llm/tools/tools.go | 43 | ||||
| -rw-r--r-- | internal/llm/tools/view.go | 24 | ||||
| -rw-r--r-- | internal/llm/tools/write.go | 85 | ||||
| -rw-r--r-- | internal/llm/tools/write_test.go | 307 |
17 files changed, 954 insertions, 1507 deletions
diff --git a/internal/llm/tools/bash.go b/internal/llm/tools/bash.go index 4e80ae60a..a17506197 100644 --- a/internal/llm/tools/bash.go +++ b/internal/llm/tools/bash.go @@ -5,10 +5,11 @@ import ( "encoding/json" "fmt" "strings" + "time" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/llm/tools/shell" - "github.com/kujtimiihoxha/termai/internal/permission" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/llm/tools/shell" + "github.com/kujtimiihoxha/opencode/internal/permission" ) type BashParams struct { @@ -21,6 +22,10 @@ type BashPermissionsParams struct { Timeout int `json:"timeout"` } +type BashResponseMetadata struct { + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` +} type bashTool struct { permissions permission.Service } @@ -46,7 +51,7 @@ var safeReadOnlyCommands = []string{ "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote", "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog", - "go version", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean", + "go version", "go help", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean", } func bashDescription() string { @@ -117,16 +122,16 @@ When the user asks you to create a new git commit, follow these steps carefully: </commit_analysis> 4. Create the commit with a message ending with: -🤖 Generated with termai -Co-Authored-By: termai <[email protected]> +🤖 Generated with opencode +Co-Authored-By: opencode <[email protected]> - In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example: <example> git commit -m "$(cat <<'EOF' Commit message here. - 🤖 Generated with termai - Co-Authored-By: termai <[email protected]> + 🤖 Generated with opencode + Co-Authored-By: opencode <[email protected]> EOF )" </example> @@ -188,7 +193,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF' ## Test plan [Checklist of TODOs for testing the pull request...] -🤖 Generated with termai +🤖 Generated with opencode EOF )" </example> @@ -256,9 +261,15 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) } } } + + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") + } if !isSafeReadOnly { p := b.permissions.Request( permission.CreatePermissionRequest{ + SessionID: sessionID, Path: config.WorkingDirectory(), ToolName: BashToolName, Action: "execute", @@ -269,13 +280,14 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) }, ) if !p { - return NewTextErrorResponse("permission denied"), nil + return ToolResponse{}, permission.ErrorPermissionDenied } } + startTime := time.Now() shell := shell.GetPersistentShell(config.WorkingDirectory()) stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error executing command: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error executing command: %w", err) } stdout = truncateOutput(stdout) @@ -304,10 +316,14 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) stdout += "\n" + errorMessage } + metadata := BashResponseMetadata{ + StartTime: startTime.UnixMilli(), + EndTime: time.Now().UnixMilli(), + } if stdout == "" { - return NewTextResponse("no output"), nil + return WithResponseMetadata(NewTextResponse("no output"), metadata), nil } - return NewTextResponse(stdout), nil + return WithResponseMetadata(NewTextResponse(stdout), metadata), nil } func truncateOutput(content string) string { diff --git a/internal/llm/tools/bash_test.go b/internal/llm/tools/bash_test.go deleted file mode 100644 index 97be3683a..000000000 --- a/internal/llm/tools/bash_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "os" - "strings" - "testing" - "time" - - "github.com/kujtimiihoxha/termai/internal/permission" - "github.com/kujtimiihoxha/termai/internal/pubsub" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBashTool_Info(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - info := tool.Info() - - assert.Equal(t, BashToolName, info.Name) - assert.NotEmpty(t, info.Description) - assert.Contains(t, info.Parameters, "command") - assert.Contains(t, info.Parameters, "timeout") - assert.Contains(t, info.Required, "command") -} - -func TestBashTool_Run(t *testing.T) { - // Save original working directory - origWd, err := os.Getwd() - require.NoError(t, err) - defer func() { - os.Chdir(origWd) - }() - - t.Run("executes command successfully", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "echo 'Hello World'", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Equal(t, "Hello World\n", response.Content) - }) - - t.Run("handles invalid parameters", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - call := ToolCall{ - Name: BashToolName, - Input: "invalid json", - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "invalid parameters") - }) - - t.Run("handles missing command", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "missing command") - }) - - t.Run("handles banned commands", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - - for _, bannedCmd := range bannedCommands { - params := BashParams{ - Command: bannedCmd + " arg1 arg2", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "not allowed", "Command %s should be blocked", bannedCmd) - } - }) - - t.Run("handles multi-word safe commands without permission check", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(false)) - - // Test with multi-word safe commands - multiWordCommands := []string{ - "go env", - } - - for _, cmd := range multiWordCommands { - params := BashParams{ - Command: cmd, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.NotContains(t, response.Content, "permission denied", - "Command %s should be allowed without permission", cmd) - } - }) - - t.Run("handles permission denied", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(false)) - - // Test with a command that requires permission - params := BashParams{ - Command: "mkdir test_dir", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "permission denied") - }) - - t.Run("handles command timeout", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "sleep 2", - Timeout: 100, // 100ms timeout - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "aborted") - }) - - t.Run("handles command with stderr output", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "echo 'error message' >&2", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "error message") - }) - - t.Run("handles command with both stdout and stderr", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "echo 'stdout message' && echo 'stderr message' >&2", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "stdout message") - assert.Contains(t, response.Content, "stderr message") - }) - - t.Run("handles context cancellation", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "sleep 5", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - ctx, cancel := context.WithCancel(context.Background()) - - // Cancel the context after a short delay - go func() { - time.Sleep(100 * time.Millisecond) - cancel() - }() - - response, err := tool.Run(ctx, call) - require.NoError(t, err) - assert.Contains(t, response.Content, "aborted") - }) - - t.Run("respects max timeout", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "echo 'test'", - Timeout: MaxTimeout + 1000, // Exceeds max timeout - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Equal(t, "test\n", response.Content) - }) - - t.Run("uses default timeout for zero or negative timeout", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "echo 'test'", - Timeout: -100, // Negative timeout - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Equal(t, "test\n", response.Content) - }) -} - -func TestTruncateOutput(t *testing.T) { - t.Run("does not truncate short output", func(t *testing.T) { - output := "short output" - result := truncateOutput(output) - assert.Equal(t, output, result) - }) - - t.Run("truncates long output", func(t *testing.T) { - // Create a string longer than MaxOutputLength - longOutput := strings.Repeat("a\n", MaxOutputLength) - result := truncateOutput(longOutput) - - // Check that the result is shorter than the original - assert.Less(t, len(result), len(longOutput)) - - // Check that the truncation message is included - assert.Contains(t, result, "lines truncated") - - // Check that we have the beginning and end of the original string - assert.True(t, strings.HasPrefix(result, "a\n")) - assert.True(t, strings.HasSuffix(result, "a\n")) - }) -} - -func TestCountLines(t *testing.T) { - testCases := []struct { - name string - input string - expected int - }{ - { - name: "empty string", - input: "", - expected: 0, - }, - { - name: "single line", - input: "line1", - expected: 1, - }, - { - name: "multiple lines", - input: "line1\nline2\nline3", - expected: 3, - }, - { - name: "trailing newline", - input: "line1\nline2\n", - expected: 3, // Empty string after last newline counts as a line - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := countLines(tc.input) - assert.Equal(t, tc.expected, result) - }) - } -} - -// Mock permission service for testing -type mockPermissionService struct { - *pubsub.Broker[permission.PermissionRequest] - allow bool -} - -func (m *mockPermissionService) GrantPersistant(permission permission.PermissionRequest) { - // Not needed for tests -} - -func (m *mockPermissionService) Grant(permission permission.PermissionRequest) { - // Not needed for tests -} - -func (m *mockPermissionService) Deny(permission permission.PermissionRequest) { - // Not needed for tests -} - -func (m *mockPermissionService) Request(opts permission.CreatePermissionRequest) bool { - return m.allow -} - -func newMockPermissionService(allow bool) permission.Service { - return &mockPermissionService{ - Broker: pubsub.NewBroker[permission.PermissionRequest](), - allow: allow, - } -} diff --git a/internal/llm/tools/diagnostics.go b/internal/llm/tools/diagnostics.go index 1bb02098e..82989c774 100644 --- a/internal/llm/tools/diagnostics.go +++ b/internal/llm/tools/diagnostics.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/kujtimiihoxha/termai/internal/lsp" - "github.com/kujtimiihoxha/termai/internal/lsp/protocol" + "github.com/kujtimiihoxha/opencode/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/lsp/protocol" ) type DiagnosticsParams struct { @@ -82,7 +82,7 @@ func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, waitForLspDiagnostics(ctx, params.FilePath, lsps) } - output := appendDiagnostics(params.FilePath, lsps) + output := getDiagnostics(params.FilePath, lsps) return NewTextResponse(output), nil } @@ -154,7 +154,7 @@ func hasDiagnosticsChanged(current, original map[protocol.DocumentUri][]protocol return false } -func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string { +func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string { fileDiagnostics := []string{} projectDiagnostics := []string{} diff --git a/internal/llm/tools/edit.go b/internal/llm/tools/edit.go index 32e2034e4..e2e257875 100644 --- a/internal/llm/tools/edit.go +++ b/internal/llm/tools/edit.go @@ -9,10 +9,12 @@ import ( "strings" "time" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/lsp" - "github.com/kujtimiihoxha/termai/internal/permission" - "github.com/sergi/go-diff/diffmatchpatch" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/diff" + "github.com/kujtimiihoxha/opencode/internal/history" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/permission" ) type EditParams struct { @@ -22,15 +24,20 @@ type EditParams struct { } type EditPermissionsParams struct { - FilePath string `json:"file_path"` - OldString string `json:"old_string"` - NewString string `json:"new_string"` + FilePath string `json:"file_path"` + Diff string `json:"diff"` +} + +type EditResponseMetadata struct { Diff string `json:"diff"` + Additions int `json:"additions"` + Removals int `json:"removals"` } type editTool struct { lspClients map[string]*lsp.Client permissions permission.Service + files history.Service } const ( @@ -84,10 +91,11 @@ When making edits: Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.` ) -func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool { +func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool { return &editTool{ lspClients: lspClients, permissions: permissions, + files: files, } } @@ -128,275 +136,354 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) params.FilePath = filepath.Join(wd, params.FilePath) } + var response ToolResponse + var err error + if params.OldString == "" { - result, err := e.createNewFile(params.FilePath, params.NewString) + response, err = e.createNewFile(ctx, params.FilePath, params.NewString) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil + return response, err } - return NewTextResponse(result), nil } if params.NewString == "" { - result, err := e.deleteContent(params.FilePath, params.OldString) + response, err = e.deleteContent(ctx, params.FilePath, params.OldString) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil + return response, err } - return NewTextResponse(result), nil } - result, err := e.replaceContent(params.FilePath, params.OldString, params.NewString) + response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil + return response, err + } + if response.IsError { + // Return early if there was an error during content replacement + // This prevents unnecessary LSP diagnostics processing + return response, nil } waitForLspDiagnostics(ctx, params.FilePath, e.lspClients) - result = fmt.Sprintf("<result>\n%s\n</result>\n", result) - result += appendDiagnostics(params.FilePath, e.lspClients) - return NewTextResponse(result), nil + text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content) + text += getDiagnostics(params.FilePath, e.lspClients) + response.Content = text + return response, nil } -func (e *editTool) createNewFile(filePath, content string) (string, error) { +func (e *editTool) createNewFile(ctx context.Context, filePath, content string) (ToolResponse, error) { fileInfo, err := os.Stat(filePath) if err == nil { if fileInfo.IsDir() { - return "", fmt.Errorf("path is a directory, not a file: %s", filePath) + return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil } - return "", fmt.Errorf("file already exists: %s. Use the Replace tool to overwrite an existing file", filePath) + return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil } else if !os.IsNotExist(err) { - return "", fmt.Errorf("failed to access file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to access file: %w", err) } dir := filepath.Dir(filePath) if err = os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("failed to create parent directories: %w", err) + return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err) } + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") + } + + diff, additions, removals := diff.GenerateDiff( + "", + content, + filePath, + ) + rootDir := config.WorkingDirectory() + permissionPath := filepath.Dir(filePath) + if strings.HasPrefix(filePath, rootDir) { + permissionPath = rootDir + } p := e.permissions.Request( permission.CreatePermissionRequest{ - Path: filepath.Dir(filePath), + SessionID: sessionID, + Path: permissionPath, ToolName: EditToolName, - Action: "create", + Action: "write", Description: fmt.Sprintf("Create file %s", filePath), Params: EditPermissionsParams{ - FilePath: filePath, - OldString: "", - NewString: content, - Diff: GenerateDiff("", content), + FilePath: filePath, + Diff: diff, }, }, ) if !p { - return "", fmt.Errorf("permission denied") + return ToolResponse{}, permission.ErrorPermissionDenied } err = os.WriteFile(filePath, []byte(content), 0o644) if err != nil { - return "", fmt.Errorf("failed to write file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + } + + // File can't be in the history so we create a new file history + _, err = e.files.Create(ctx, sessionID, filePath, "") + if err != nil { + // Log error but don't fail the operation + return ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + } + + // Add the new content to the file history + _, err = e.files.CreateVersion(ctx, sessionID, filePath, content) + if err != nil { + // Log error but don't fail the operation + logging.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) recordFileRead(filePath) - return "File created: " + filePath, nil + return WithResponseMetadata( + NewTextResponse("File created: "+filePath), + EditResponseMetadata{ + Diff: diff, + Additions: additions, + Removals: removals, + }, + ), nil } -func (e *editTool) deleteContent(filePath, oldString string) (string, error) { +func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (ToolResponse, error) { fileInfo, err := os.Stat(filePath) if err != nil { if os.IsNotExist(err) { - return "", fmt.Errorf("file not found: %s", filePath) + return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil } - return "", fmt.Errorf("failed to access file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to access file: %w", err) } if fileInfo.IsDir() { - return "", fmt.Errorf("path is a directory, not a file: %s", filePath) + return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil } if getLastReadTime(filePath).IsZero() { - return "", fmt.Errorf("you must read the file before editing it. Use the View tool first") + return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil } modTime := fileInfo.ModTime() lastRead := getLastReadTime(filePath) if modTime.After(lastRead) { - return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)", - filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339)) + return NewTextErrorResponse( + fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)", + filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339), + )), nil } content, err := os.ReadFile(filePath) if err != nil { - return "", fmt.Errorf("failed to read file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to read file: %w", err) } oldContent := string(content) index := strings.Index(oldContent, oldString) if index == -1 { - return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks") + return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil } lastIndex := strings.LastIndex(oldContent, oldString) if index != lastIndex { - return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match") + return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil } newContent := oldContent[:index] + oldContent[index+len(oldString):] + sessionID, messageID := GetContextValues(ctx) + + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") + } + + diff, additions, removals := diff.GenerateDiff( + oldContent, + newContent, + filePath, + ) + + rootDir := config.WorkingDirectory() + permissionPath := filepath.Dir(filePath) + if strings.HasPrefix(filePath, rootDir) { + permissionPath = rootDir + } p := e.permissions.Request( permission.CreatePermissionRequest{ - Path: filepath.Dir(filePath), + SessionID: sessionID, + Path: permissionPath, ToolName: EditToolName, - Action: "delete", + Action: "write", Description: fmt.Sprintf("Delete content from file %s", filePath), Params: EditPermissionsParams{ - FilePath: filePath, - OldString: oldString, - NewString: "", - Diff: GenerateDiff(oldContent, newContent), + FilePath: filePath, + Diff: diff, }, }, ) if !p { - return "", fmt.Errorf("permission denied") + return ToolResponse{}, permission.ErrorPermissionDenied } err = os.WriteFile(filePath, []byte(newContent), 0o644) if err != nil { - return "", fmt.Errorf("failed to write file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + } + + // Check if file exists in history + file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID) + if err != nil { + _, err = e.files.Create(ctx, sessionID, filePath, oldContent) + if err != nil { + // Log error but don't fail the operation + return ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + } + } + if file.Content != oldContent { + // User Manually changed the content store an intermediate version + _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent) + if err != nil { + logging.Debug("Error creating file history version", "error", err) + } + } + // Store the new version + _, err = e.files.CreateVersion(ctx, sessionID, filePath, "") + if err != nil { + logging.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) recordFileRead(filePath) - return "Content deleted from file: " + filePath, nil + return WithResponseMetadata( + NewTextResponse("Content deleted from file: "+filePath), + EditResponseMetadata{ + Diff: diff, + Additions: additions, + Removals: removals, + }, + ), nil } -func (e *editTool) replaceContent(filePath, oldString, newString string) (string, error) { +func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (ToolResponse, error) { fileInfo, err := os.Stat(filePath) if err != nil { if os.IsNotExist(err) { - return "", fmt.Errorf("file not found: %s", filePath) + return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil } - return "", fmt.Errorf("failed to access file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to access file: %w", err) } if fileInfo.IsDir() { - return "", fmt.Errorf("path is a directory, not a file: %s", filePath) + return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil } if getLastReadTime(filePath).IsZero() { - return "", fmt.Errorf("you must read the file before editing it. Use the View tool first") + return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil } modTime := fileInfo.ModTime() lastRead := getLastReadTime(filePath) if modTime.After(lastRead) { - return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)", - filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339)) + return NewTextErrorResponse( + fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)", + filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339), + )), nil } content, err := os.ReadFile(filePath) if err != nil { - return "", fmt.Errorf("failed to read file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to read file: %w", err) } oldContent := string(content) index := strings.Index(oldContent, oldString) if index == -1 { - return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks") + return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil } lastIndex := strings.LastIndex(oldContent, oldString) if index != lastIndex { - return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match") + return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil } newContent := oldContent[:index] + newString + oldContent[index+len(oldString):] - startIndex := max(0, index-3) - oldEndIndex := min(len(oldContent), index+len(oldString)+3) - newEndIndex := min(len(newContent), index+len(newString)+3) - - diff := GenerateDiff(oldContent[startIndex:oldEndIndex], newContent[startIndex:newEndIndex]) + if oldContent == newContent { + return NewTextErrorResponse("new content is the same as old content. No changes made."), nil + } + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") + } + diff, additions, removals := diff.GenerateDiff( + oldContent, + newContent, + filePath, + ) + rootDir := config.WorkingDirectory() + permissionPath := filepath.Dir(filePath) + if strings.HasPrefix(filePath, rootDir) { + permissionPath = rootDir + } p := e.permissions.Request( permission.CreatePermissionRequest{ - Path: filepath.Dir(filePath), + SessionID: sessionID, + Path: permissionPath, ToolName: EditToolName, - Action: "replace", + Action: "write", Description: fmt.Sprintf("Replace content in file %s", filePath), Params: EditPermissionsParams{ - FilePath: filePath, - OldString: oldString, - NewString: newString, - Diff: diff, + FilePath: filePath, + Diff: diff, }, }, ) if !p { - return "", fmt.Errorf("permission denied") + return ToolResponse{}, permission.ErrorPermissionDenied } err = os.WriteFile(filePath, []byte(newContent), 0o644) if err != nil { - return "", fmt.Errorf("failed to write file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + } + + // Check if file exists in history + file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID) + if err != nil { + _, err = e.files.Create(ctx, sessionID, filePath, oldContent) + if err != nil { + // Log error but don't fail the operation + return ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + } + } + if file.Content != oldContent { + // User Manually changed the content store an intermediate version + _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent) + if err != nil { + logging.Debug("Error creating file history version", "error", err) + } + } + // Store the new version + _, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent) + if err != nil { + logging.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) recordFileRead(filePath) - return "Content replaced in file: " + filePath, nil -} - -func GenerateDiff(oldContent, newContent string) string { - dmp := diffmatchpatch.New() - fileAdmp, fileBdmp, dmpStrings := dmp.DiffLinesToChars(oldContent, newContent) - diffs := dmp.DiffMain(fileAdmp, fileBdmp, false) - diffs = dmp.DiffCharsToLines(diffs, dmpStrings) - diffs = dmp.DiffCleanupSemantic(diffs) - buff := strings.Builder{} - - buff.WriteString("Changes:\n") - - for _, diff := range diffs { - text := diff.Text - - switch diff.Type { - case diffmatchpatch.DiffInsert: - for line := range strings.SplitSeq(text, "\n") { - if line == "" { - continue - } - _, _ = buff.WriteString("+ " + line + "\n") - } - case diffmatchpatch.DiffDelete: - for line := range strings.SplitSeq(text, "\n") { - if line == "" { - continue - } - _, _ = buff.WriteString("- " + line + "\n") - } - case diffmatchpatch.DiffEqual: - lines := strings.Split(text, "\n") - if len(lines) > 3 { - if lines[0] != "" { - _, _ = buff.WriteString(" " + lines[0] + "\n") - } - _, _ = buff.WriteString(" ...\n") - if lines[len(lines)-1] != "" { - _, _ = buff.WriteString(" " + lines[len(lines)-1] + "\n") - } - } else { - for _, line := range lines { - if line == "" { - continue - } - _, _ = buff.WriteString(" " + line + "\n") - } - } - } - } - return buff.String() + return WithResponseMetadata( + NewTextResponse("Content replaced in file: "+filePath), + EditResponseMetadata{ + Diff: diff, + Additions: additions, + Removals: removals, + }), nil } diff --git a/internal/llm/tools/edit_test.go b/internal/llm/tools/edit_test.go deleted file mode 100644 index dbc6e488f..000000000 --- a/internal/llm/tools/edit_test.go +++ /dev/null @@ -1,509 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "github.com/kujtimiihoxha/termai/internal/lsp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEditTool_Info(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - info := tool.Info() - - assert.Equal(t, EditToolName, info.Name) - assert.NotEmpty(t, info.Description) - assert.Contains(t, info.Parameters, "file_path") - assert.Contains(t, info.Parameters, "old_string") - assert.Contains(t, info.Parameters, "new_string") - assert.Contains(t, info.Required, "file_path") - assert.Contains(t, info.Required, "old_string") - assert.Contains(t, info.Required, "new_string") -} - -func TestEditTool_Run(t *testing.T) { - // Create a temporary directory for testing - tempDir, err := os.MkdirTemp("", "edit_tool_test") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - t.Run("creates a new file successfully", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - filePath := filepath.Join(tempDir, "new_file.txt") - content := "This is a test content" - - params := EditParams{ - FilePath: filePath, - OldString: "", - NewString: content, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "File created") - - // Verify file was created with correct content - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, content, string(fileContent)) - }) - - t.Run("creates file with nested directories", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt") - content := "Content in nested directory" - - params := EditParams{ - FilePath: filePath, - OldString: "", - NewString: content, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "File created") - - // Verify file was created with correct content - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, content, string(fileContent)) - }) - - t.Run("fails to create file that already exists", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file first - filePath := filepath.Join(tempDir, "existing_file.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Try to create the same file - params := EditParams{ - FilePath: filePath, - OldString: "", - NewString: "New content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "file already exists") - }) - - t.Run("fails to create file when path is a directory", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a directory - dirPath := filepath.Join(tempDir, "test_dir") - err := os.Mkdir(dirPath, 0o755) - require.NoError(t, err) - - // Try to create a file with the same path as the directory - params := EditParams{ - FilePath: dirPath, - OldString: "", - NewString: "Some content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "path is a directory") - }) - - t.Run("replaces content successfully", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file first - filePath := filepath.Join(tempDir, "replace_content.txt") - initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Replace content - oldString := "Line 2\nLine 3" - newString := "Line 2 modified\nLine 3 modified" - params := EditParams{ - FilePath: filePath, - OldString: oldString, - NewString: newString, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Content replaced") - - // Verify file was updated with correct content - expectedContent := "Line 1\nLine 2 modified\nLine 3 modified\nLine 4\nLine 5" - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, expectedContent, string(fileContent)) - }) - - t.Run("deletes content successfully", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file first - filePath := filepath.Join(tempDir, "delete_content.txt") - initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Delete content - oldString := "Line 2\nLine 3\n" - params := EditParams{ - FilePath: filePath, - OldString: oldString, - NewString: "", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Content deleted") - - // Verify file was updated with correct content - expectedContent := "Line 1\nLine 4\nLine 5" - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, expectedContent, string(fileContent)) - }) - - t.Run("handles invalid parameters", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - call := ToolCall{ - Name: EditToolName, - Input: "invalid json", - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "invalid parameters") - }) - - t.Run("handles missing file_path", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - params := EditParams{ - FilePath: "", - OldString: "old", - NewString: "new", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "file_path is required") - }) - - t.Run("handles file not found", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - filePath := filepath.Join(tempDir, "non_existent_file.txt") - params := EditParams{ - FilePath: filePath, - OldString: "old content", - NewString: "new content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "file not found") - }) - - t.Run("handles old_string not found in file", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file first - filePath := filepath.Join(tempDir, "content_not_found.txt") - initialContent := "Line 1\nLine 2\nLine 3" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Try to replace content that doesn't exist - params := EditParams{ - FilePath: filePath, - OldString: "This content does not exist", - NewString: "new content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "old_string not found in file") - }) - - t.Run("handles multiple occurrences of old_string", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file with duplicate content - filePath := filepath.Join(tempDir, "duplicate_content.txt") - initialContent := "Line 1\nDuplicate\nLine 3\nDuplicate\nLine 5" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Try to replace content that appears multiple times - params := EditParams{ - FilePath: filePath, - OldString: "Duplicate", - NewString: "Replaced", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "appears multiple times") - }) - - t.Run("handles file modified since last read", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file - filePath := filepath.Join(tempDir, "modified_file.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record an old read time - fileRecordMutex.Lock() - fileRecords[filePath] = fileRecord{ - path: filePath, - readTime: time.Now().Add(-1 * time.Hour), - } - fileRecordMutex.Unlock() - - // Try to update the file - params := EditParams{ - FilePath: filePath, - OldString: "Initial", - NewString: "Updated", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "has been modified since it was last read") - - // Verify file was not modified - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, initialContent, string(fileContent)) - }) - - t.Run("handles file not read before editing", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file - filePath := filepath.Join(tempDir, "not_read_file.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Try to update the file without reading it first - params := EditParams{ - FilePath: filePath, - OldString: "Initial", - NewString: "Updated", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "you must read the file before editing it") - }) - - t.Run("handles permission denied", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false)) - - // Create a file - filePath := filepath.Join(tempDir, "permission_denied.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Try to update the file - params := EditParams{ - FilePath: filePath, - OldString: "Initial", - NewString: "Updated", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "permission denied") - - // Verify file was not modified - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, initialContent, string(fileContent)) - }) -} - -func TestGenerateDiff(t *testing.T) { - testCases := []struct { - name string - oldContent string - newContent string - expectedDiff string - }{ - { - name: "add content", - oldContent: "Line 1\nLine 2\n", - newContent: "Line 1\nLine 2\nLine 3\n", - expectedDiff: "Changes:\n Line 1\n Line 2\n+ Line 3\n", - }, - { - name: "remove content", - oldContent: "Line 1\nLine 2\nLine 3\n", - newContent: "Line 1\nLine 3\n", - expectedDiff: "Changes:\n Line 1\n- Line 2\n Line 3\n", - }, - { - name: "replace content", - oldContent: "Line 1\nLine 2\nLine 3\n", - newContent: "Line 1\nModified Line\nLine 3\n", - expectedDiff: "Changes:\n Line 1\n- Line 2\n+ Modified Line\n Line 3\n", - }, - { - name: "empty to content", - oldContent: "", - newContent: "Line 1\nLine 2\n", - expectedDiff: "Changes:\n+ Line 1\n+ Line 2\n", - }, - { - name: "content to empty", - oldContent: "Line 1\nLine 2\n", - newContent: "", - expectedDiff: "Changes:\n- Line 1\n- Line 2\n", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - diff := GenerateDiff(tc.oldContent, tc.newContent) - assert.Contains(t, diff, tc.expectedDiff) - }) - } -} - diff --git a/internal/llm/tools/fetch.go b/internal/llm/tools/fetch.go index 19e644281..47ff03e57 100644 --- a/internal/llm/tools/fetch.go +++ b/internal/llm/tools/fetch.go @@ -11,8 +11,8 @@ import ( md "github.com/JohannesKaufmann/html-to-markdown" "github.com/PuerkitoBio/goquery" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/permission" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/permission" ) type FetchParams struct { @@ -86,6 +86,7 @@ func (t *fetchTool) Info() ToolInfo { "format": map[string]any{ "type": "string", "description": "The format to return the content in (text, markdown, or html)", + "enum": []string{"text", "markdown", "html"}, }, "timeout": map[string]any{ "type": "number", @@ -115,8 +116,14 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error return NewTextErrorResponse("URL must start with http:// or https://"), nil } + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") + } + p := t.permissions.Request( permission.CreatePermissionRequest{ + SessionID: sessionID, Path: config.WorkingDirectory(), ToolName: FetchToolName, Action: "fetch", @@ -126,7 +133,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error ) if !p { - return NewTextErrorResponse("Permission denied to fetch from URL: " + params.URL), nil + return ToolResponse{}, permission.ErrorPermissionDenied } client := t.client @@ -142,14 +149,14 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error req, err := http.NewRequestWithContext(ctx, "GET", params.URL, nil) if err != nil { - return NewTextErrorResponse("Failed to create request: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "termai/1.0") + req.Header.Set("User-Agent", "opencode/1.0") resp, err := client.Do(req) if err != nil { - return NewTextErrorResponse("Failed to execute request: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err) } defer resp.Body.Close() diff --git a/internal/llm/tools/glob.go b/internal/llm/tools/glob.go index 4de7971e6..e3c7b7b61 100644 --- a/internal/llm/tools/glob.go +++ b/internal/llm/tools/glob.go @@ -12,7 +12,7 @@ import ( "time" "github.com/bmatcuk/doublestar/v4" - "github.com/kujtimiihoxha/termai/internal/config" + "github.com/kujtimiihoxha/opencode/internal/config" ) const ( @@ -63,6 +63,11 @@ type GlobParams struct { Path string `json:"path"` } +type GlobResponseMetadata struct { + NumberOfFiles int `json:"number_of_files"` + Truncated bool `json:"truncated"` +} + type globTool struct{} func NewGlobTool() BaseTool { @@ -104,7 +109,7 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) files, truncated, err := globFiles(params.Pattern, searchPath, 100) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error performing glob search: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error finding files: %w", err) } var output string @@ -117,7 +122,13 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) } } - return NewTextResponse(output), nil + return WithResponseMetadata( + NewTextResponse(output), + GlobResponseMetadata{ + NumberOfFiles: len(files), + Truncated: truncated, + }, + ), nil } func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) { @@ -181,6 +192,42 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) { } func skipHidden(path string) bool { + // Check for hidden files (starting with a dot) base := filepath.Base(path) - return base != "." && strings.HasPrefix(base, ".") + if base != "." && strings.HasPrefix(base, ".") { + return true + } + + // List of commonly ignored directories in development projects + commonIgnoredDirs := map[string]bool{ + "node_modules": true, + "vendor": true, + "dist": true, + "build": true, + "target": true, + ".git": true, + ".idea": true, + ".vscode": true, + "__pycache__": true, + "bin": true, + "obj": true, + "out": true, + "coverage": true, + "tmp": true, + "temp": true, + "logs": true, + "generated": true, + "bower_components": true, + "jspm_packages": true, + } + + // Check if any path component is in our ignore list + parts := strings.SplitSeq(path, string(os.PathSeparator)) + for part := range parts { + if commonIgnoredDirs[part] { + return true + } + } + + return false } diff --git a/internal/llm/tools/grep.go b/internal/llm/tools/grep.go index f349e8370..475370ffb 100644 --- a/internal/llm/tools/grep.go +++ b/internal/llm/tools/grep.go @@ -10,21 +10,30 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "time" - "github.com/kujtimiihoxha/termai/internal/config" + "github.com/kujtimiihoxha/opencode/internal/config" ) type GrepParams struct { - Pattern string `json:"pattern"` - Path string `json:"path"` - Include string `json:"include"` + Pattern string `json:"pattern"` + Path string `json:"path"` + Include string `json:"include"` + LiteralText bool `json:"literal_text"` } type grepMatch struct { - path string - modTime time.Time + path string + modTime time.Time + lineNum int + lineText string +} + +type GrepResponseMetadata struct { + NumberOfMatches int `json:"number_of_matches"` + Truncated bool `json:"truncated"` } type grepTool struct{} @@ -40,11 +49,12 @@ WHEN TO USE THIS TOOL: HOW TO USE: - Provide a regex pattern to search for within file contents +- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users) - Optionally specify a starting directory (defaults to current working directory) - Optionally provide an include pattern to filter which files to search - Results are sorted with most recently modified files first -REGEX PATTERN SYNTAX: +REGEX PATTERN SYNTAX (when literal_text=false): - Supports standard regular expression syntax - 'function' searches for the literal text "function" - 'log\..*Error' finds text starting with "log." and ending with "Error" @@ -64,7 +74,8 @@ LIMITATIONS: TIPS: - For faster, more targeted searches, first use Glob to find relevant files, then use Grep - When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead -- Always check if results are truncated and refine your search pattern if needed` +- Always check if results are truncated and refine your search pattern if needed +- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.` ) func NewGrepTool() BaseTool { @@ -88,11 +99,27 @@ func (g *grepTool) Info() ToolInfo { "type": "string", "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")", }, + "literal_text": map[string]any{ + "type": "boolean", + "description": "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.", + }, }, Required: []string{"pattern"}, } } +// escapeRegexPattern escapes special regex characters so they're treated as literal characters +func escapeRegexPattern(pattern string) string { + specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"} + escaped := pattern + + for _, char := range specialChars { + escaped = strings.ReplaceAll(escaped, char, "\\"+char) + } + + return escaped +} + func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { var params GrepParams if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { @@ -103,41 +130,59 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) return NewTextErrorResponse("pattern is required"), nil } + // If literal_text is true, escape the pattern + searchPattern := params.Pattern + if params.LiteralText { + searchPattern = escapeRegexPattern(params.Pattern) + } + searchPath := params.Path if searchPath == "" { searchPath = config.WorkingDirectory() } - matches, truncated, err := searchFiles(params.Pattern, searchPath, params.Include, 100) + matches, truncated, err := searchFiles(searchPattern, searchPath, params.Include, 100) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error searching files: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error searching files: %w", err) } var output string if len(matches) == 0 { output = "No files found" } else { - output = fmt.Sprintf("Found %d file%s\n%s", - len(matches), - pluralize(len(matches)), - strings.Join(matches, "\n")) + output = fmt.Sprintf("Found %d matches\n", len(matches)) + + currentFile := "" + for _, match := range matches { + if currentFile != match.path { + if currentFile != "" { + output += "\n" + } + currentFile = match.path + output += fmt.Sprintf("%s:\n", match.path) + } + if match.lineNum > 0 { + output += fmt.Sprintf(" Line %d: %s\n", match.lineNum, match.lineText) + } else { + output += fmt.Sprintf(" %s\n", match.path) + } + } if truncated { - output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)" + output += "\n(Results are truncated. Consider using a more specific path or pattern.)" } } - return NewTextResponse(output), nil -} - -func pluralize(count int) string { - if count == 1 { - return "" - } - return "s" + return WithResponseMetadata( + NewTextResponse(output), + GrepResponseMetadata{ + NumberOfMatches: len(matches), + Truncated: truncated, + }, + ), nil } -func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, error) { +func searchFiles(pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) { matches, err := searchWithRipgrep(pattern, rootPath, include) if err != nil { matches, err = searchFilesWithRegex(pattern, rootPath, include) @@ -155,12 +200,7 @@ func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, matches = matches[:limit] } - results := make([]string, len(matches)) - for i, m := range matches { - results[i] = m.path - } - - return results, truncated, nil + return matches, truncated, nil } func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) { @@ -169,7 +209,8 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) { return nil, fmt.Errorf("ripgrep not found: %w", err) } - args := []string{"-l", pattern} + // Use -n to show line numbers and include the matched line + args := []string{"-n", pattern} if include != "" { args = append(args, "--glob", include) } @@ -192,14 +233,29 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) { continue } - fileInfo, err := os.Stat(line) + // Parse ripgrep output format: file:line:content + parts := strings.SplitN(line, ":", 3) + if len(parts) < 3 { + continue + } + + filePath := parts[0] + lineNum, err := strconv.Atoi(parts[1]) + if err != nil { + continue + } + lineText := parts[2] + + fileInfo, err := os.Stat(filePath) if err != nil { continue // Skip files we can't access } matches = append(matches, grepMatch{ - path: line, - modTime: fileInfo.ModTime(), + path: filePath, + modTime: fileInfo.ModTime(), + lineNum: lineNum, + lineText: lineText, }) } @@ -240,15 +296,17 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error return nil } - match, err := fileContainsPattern(path, regex) + match, lineNum, lineText, err := fileContainsPattern(path, regex) if err != nil { return nil // Skip files we can't read } if match { matches = append(matches, grepMatch{ - path: path, - modTime: info.ModTime(), + path: path, + modTime: info.ModTime(), + lineNum: lineNum, + lineText: lineText, }) if len(matches) >= 200 { @@ -265,21 +323,24 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error return matches, nil } -func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error) { +func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) { file, err := os.Open(filePath) if err != nil { - return false, err + return false, 0, "", err } defer file.Close() scanner := bufio.NewScanner(file) + lineNum := 0 for scanner.Scan() { - if pattern.MatchString(scanner.Text()) { - return true, nil + lineNum++ + line := scanner.Text() + if pattern.MatchString(line) { + return true, lineNum, line, nil } } - return false, scanner.Err() + return false, 0, "", scanner.Err() } func globToRegex(glob string) string { diff --git a/internal/llm/tools/ls.go b/internal/llm/tools/ls.go index 59e8dcd21..05f300c0e 100644 --- a/internal/llm/tools/ls.go +++ b/internal/llm/tools/ls.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/kujtimiihoxha/termai/internal/config" + "github.com/kujtimiihoxha/opencode/internal/config" ) type LSParams struct { @@ -23,6 +23,11 @@ type TreeNode struct { Children []*TreeNode `json:"children,omitempty"` } +type LSResponseMetadata struct { + NumberOfFiles int `json:"number_of_files"` + Truncated bool `json:"truncated"` +} + type lsTool struct{} const ( @@ -104,7 +109,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error listing directory: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error listing directory: %w", err) } tree := createFileTree(files) @@ -114,7 +119,13 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output) } - return NewTextResponse(output), nil + return WithResponseMetadata( + NewTextResponse(output), + LSResponseMetadata{ + NumberOfFiles: len(files), + Truncated: truncated, + }, + ), nil } func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) { diff --git a/internal/llm/tools/patch.go b/internal/llm/tools/patch.go new file mode 100644 index 000000000..7e20e378e --- /dev/null +++ b/internal/llm/tools/patch.go @@ -0,0 +1,372 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/diff" + "github.com/kujtimiihoxha/opencode/internal/history" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/permission" +) + +type PatchParams struct { + PatchText string `json:"patch_text"` +} + +type PatchResponseMetadata struct { + FilesChanged []string `json:"files_changed"` + Additions int `json:"additions"` + Removals int `json:"removals"` +} + +type patchTool struct { + lspClients map[string]*lsp.Client + permissions permission.Service + files history.Service +} + +const ( + PatchToolName = "patch" + patchDescription = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files. + +The patch text must follow this format: +*** Begin Patch +*** Update File: /path/to/file +@@ Context line (unique within the file) + Line to keep +-Line to remove ++Line to add + Line to keep +*** Add File: /path/to/new/file ++Content of the new file ++More content +*** Delete File: /path/to/file/to/delete +*** End Patch + +Before using this tool: +1. Use the FileRead tool to understand the files' contents and context +2. Verify all file paths are correct (use the LS tool) + +CRITICAL REQUIREMENTS FOR USING THIS TOOL: + +1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change +2. PRECISION: All whitespace, indentation, and surrounding code must match exactly +3. VALIDATION: Ensure edits result in idiomatic, correct code +4. PATHS: Always use absolute file paths (starting with /) + +The tool will apply all changes in a single atomic operation.` +) + +func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool { + return &patchTool{ + lspClients: lspClients, + permissions: permissions, + files: files, + } +} + +func (p *patchTool) Info() ToolInfo { + return ToolInfo{ + Name: PatchToolName, + Description: patchDescription, + Parameters: map[string]any{ + "patch_text": map[string]any{ + "type": "string", + "description": "The full patch text that describes all changes to be made", + }, + }, + Required: []string{"patch_text"}, + } +} + +func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { + var params PatchParams + if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { + return NewTextErrorResponse("invalid parameters"), nil + } + + if params.PatchText == "" { + return NewTextErrorResponse("patch_text is required"), nil + } + + // Identify all files needed for the patch and verify they've been read + filesToRead := diff.IdentifyFilesNeeded(params.PatchText) + for _, filePath := range filesToRead { + absPath := filePath + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + + if getLastReadTime(absPath).IsZero() { + return NewTextErrorResponse(fmt.Sprintf("you must read the file %s before patching it. Use the FileRead tool first", filePath)), nil + } + + fileInfo, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil + } + return ToolResponse{}, fmt.Errorf("failed to access file: %w", err) + } + + if fileInfo.IsDir() { + return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil + } + + modTime := fileInfo.ModTime() + lastRead := getLastReadTime(absPath) + if modTime.After(lastRead) { + return NewTextErrorResponse( + fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)", + absPath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339), + )), nil + } + } + + // Check for new files to ensure they don't already exist + filesToAdd := diff.IdentifyFilesAdded(params.PatchText) + for _, filePath := range filesToAdd { + absPath := filePath + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + + _, err := os.Stat(absPath) + if err == nil { + return NewTextErrorResponse(fmt.Sprintf("file already exists and cannot be added: %s", absPath)), nil + } else if !os.IsNotExist(err) { + return ToolResponse{}, fmt.Errorf("failed to check file: %w", err) + } + } + + // Load all required files + currentFiles := make(map[string]string) + for _, filePath := range filesToRead { + absPath := filePath + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + + content, err := os.ReadFile(absPath) + if err != nil { + return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err) + } + currentFiles[filePath] = string(content) + } + + // Process the patch + patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles) + if err != nil { + return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %s", err)), nil + } + + if fuzz > 3 { + return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil + } + + // Convert patch to commit + commit, err := diff.PatchToCommit(patch, currentFiles) + if err != nil { + return NewTextErrorResponse(fmt.Sprintf("failed to create commit from patch: %s", err)), nil + } + + // Get session ID and message ID + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a patch") + } + + // Request permission for all changes + for path, change := range commit.Changes { + switch change.Type { + case diff.ActionAdd: + dir := filepath.Dir(path) + patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path) + p := p.permissions.Request( + permission.CreatePermissionRequest{ + SessionID: sessionID, + Path: dir, + ToolName: PatchToolName, + Action: "create", + Description: fmt.Sprintf("Create file %s", path), + Params: EditPermissionsParams{ + FilePath: path, + Diff: patchDiff, + }, + }, + ) + if !p { + return ToolResponse{}, permission.ErrorPermissionDenied + } + case diff.ActionUpdate: + currentContent := "" + if change.OldContent != nil { + currentContent = *change.OldContent + } + newContent := "" + if change.NewContent != nil { + newContent = *change.NewContent + } + patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path) + dir := filepath.Dir(path) + p := p.permissions.Request( + permission.CreatePermissionRequest{ + SessionID: sessionID, + Path: dir, + ToolName: PatchToolName, + Action: "update", + Description: fmt.Sprintf("Update file %s", path), + Params: EditPermissionsParams{ + FilePath: path, + Diff: patchDiff, + }, + }, + ) + if !p { + return ToolResponse{}, permission.ErrorPermissionDenied + } + case diff.ActionDelete: + dir := filepath.Dir(path) + patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path) + p := p.permissions.Request( + permission.CreatePermissionRequest{ + SessionID: sessionID, + Path: dir, + ToolName: PatchToolName, + Action: "delete", + Description: fmt.Sprintf("Delete file %s", path), + Params: EditPermissionsParams{ + FilePath: path, + Diff: patchDiff, + }, + }, + ) + if !p { + return ToolResponse{}, permission.ErrorPermissionDenied + } + } + } + + // Apply the changes to the filesystem + err = diff.ApplyCommit(commit, func(path string, content string) error { + absPath := path + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + + // Create parent directories if needed + dir := filepath.Dir(absPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create parent directories for %s: %w", absPath, err) + } + + return os.WriteFile(absPath, []byte(content), 0o644) + }, func(path string) error { + absPath := path + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + return os.Remove(absPath) + }) + if err != nil { + return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %s", err)), nil + } + + // Update file history for all modified files + changedFiles := []string{} + totalAdditions := 0 + totalRemovals := 0 + + for path, change := range commit.Changes { + absPath := path + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + changedFiles = append(changedFiles, absPath) + + oldContent := "" + if change.OldContent != nil { + oldContent = *change.OldContent + } + + newContent := "" + if change.NewContent != nil { + newContent = *change.NewContent + } + + // Calculate diff statistics + _, additions, removals := diff.GenerateDiff(oldContent, newContent, path) + totalAdditions += additions + totalRemovals += removals + + // Update history + file, err := p.files.GetByPathAndSession(ctx, absPath, sessionID) + if err != nil && change.Type != diff.ActionAdd { + // If not adding a file, create history entry for existing file + _, err = p.files.Create(ctx, sessionID, absPath, oldContent) + if err != nil { + logging.Debug("Error creating file history", "error", err) + } + } + + if err == nil && change.Type != diff.ActionAdd && file.Content != oldContent { + // User manually changed content, store intermediate version + _, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent) + if err != nil { + logging.Debug("Error creating file history version", "error", err) + } + } + + // Store new version + if change.Type == diff.ActionDelete { + _, err = p.files.CreateVersion(ctx, sessionID, absPath, "") + } else { + _, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent) + } + if err != nil { + logging.Debug("Error creating file history version", "error", err) + } + + // Record file operations + recordFileWrite(absPath) + recordFileRead(absPath) + } + + // Run LSP diagnostics on all changed files + for _, filePath := range changedFiles { + waitForLspDiagnostics(ctx, filePath, p.lspClients) + } + + result := fmt.Sprintf("Patch applied successfully. %d files changed, %d additions, %d removals", + len(changedFiles), totalAdditions, totalRemovals) + + diagnosticsText := "" + for _, filePath := range changedFiles { + diagnosticsText += getDiagnostics(filePath, p.lspClients) + } + + if diagnosticsText != "" { + result += "\n\nDiagnostics:\n" + diagnosticsText + } + + return WithResponseMetadata( + NewTextResponse(result), + PatchResponseMetadata{ + FilesChanged: changedFiles, + Additions: totalAdditions, + Removals: totalRemovals, + }), nil +} diff --git a/internal/llm/tools/shell/shell.go b/internal/llm/tools/shell/shell.go index 64592f67d..e25bdf3ea 100644 --- a/internal/llm/tools/shell/shell.go +++ b/internal/llm/tools/shell/shell.go @@ -83,11 +83,21 @@ func newPersistentShell(cwd string) *PersistentShell { commandQueue: make(chan *commandExecution, 10), } - go shell.processCommands() + go func() { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "Panic in shell command processor: %v\n", r) + shell.isAlive = false + close(shell.commandQueue) + } + }() + shell.processCommands() + }() go func() { err := cmd.Wait() if err != nil { + // Log the error if needed } shell.isAlive = false close(shell.commandQueue) @@ -116,10 +126,10 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx } tempDir := os.TempDir() - stdoutFile := filepath.Join(tempDir, fmt.Sprintf("termai-stdout-%d", time.Now().UnixNano())) - stderrFile := filepath.Join(tempDir, fmt.Sprintf("termai-stderr-%d", time.Now().UnixNano())) - statusFile := filepath.Join(tempDir, fmt.Sprintf("termai-status-%d", time.Now().UnixNano())) - cwdFile := filepath.Join(tempDir, fmt.Sprintf("termai-cwd-%d", time.Now().UnixNano())) + stdoutFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stdout-%d", time.Now().UnixNano())) + stderrFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stderr-%d", time.Now().UnixNano())) + statusFile := filepath.Join(tempDir, fmt.Sprintf("opencode-status-%d", time.Now().UnixNano())) + cwdFile := filepath.Join(tempDir, fmt.Sprintf("opencode-cwd-%d", time.Now().UnixNano())) defer func() { os.Remove(stdoutFile) diff --git a/internal/llm/tools/sourcegraph.go b/internal/llm/tools/sourcegraph.go index e1ea962d4..0d38c975f 100644 --- a/internal/llm/tools/sourcegraph.go +++ b/internal/llm/tools/sourcegraph.go @@ -18,6 +18,11 @@ type SourcegraphParams struct { Timeout int `json:"timeout,omitempty"` } +type SourcegraphResponseMetadata struct { + NumberOfMatches int `json:"number_of_matches"` + Truncated bool `json:"truncated"` +} + type sourcegraphTool struct { client *http.Client } @@ -198,7 +203,7 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, graphqlQueryBytes, err := json.Marshal(request) if err != nil { - return NewTextErrorResponse("Failed to create GraphQL request: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err) } graphqlQuery := string(graphqlQueryBytes) @@ -209,15 +214,15 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, bytes.NewBuffer([]byte(graphqlQuery)), ) if err != nil { - return NewTextErrorResponse("Failed to create request: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "termai/1.0") + req.Header.Set("User-Agent", "opencode/1.0") resp, err := client.Do(req) if err != nil { - return NewTextErrorResponse("Failed to execute request: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err) } defer resp.Body.Close() @@ -231,12 +236,12 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, } body, err := io.ReadAll(resp.Body) if err != nil { - return NewTextErrorResponse("Failed to read response body: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to read response body: %w", err) } var result map[string]any if err = json.Unmarshal(body, &result); err != nil { - return NewTextErrorResponse("Failed to parse response: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err) } formattedResults, err := formatSourcegraphResults(result, params.ContextWindow) diff --git a/internal/llm/tools/sourcegraph_test.go b/internal/llm/tools/sourcegraph_test.go deleted file mode 100644 index 89829aefc..000000000 --- a/internal/llm/tools/sourcegraph_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSourcegraphTool_Info(t *testing.T) { - tool := NewSourcegraphTool() - info := tool.Info() - - assert.Equal(t, SourcegraphToolName, info.Name) - assert.NotEmpty(t, info.Description) - assert.Contains(t, info.Parameters, "query") - assert.Contains(t, info.Parameters, "count") - assert.Contains(t, info.Parameters, "timeout") - assert.Contains(t, info.Required, "query") -} - -func TestSourcegraphTool_Run(t *testing.T) { - t.Run("handles missing query parameter", func(t *testing.T) { - tool := NewSourcegraphTool() - params := SourcegraphParams{ - Query: "", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: SourcegraphToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Query parameter is required") - }) - - t.Run("handles invalid parameters", func(t *testing.T) { - tool := NewSourcegraphTool() - call := ToolCall{ - Name: SourcegraphToolName, - Input: "invalid json", - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Failed to parse sourcegraph parameters") - }) - - t.Run("normalizes count parameter", func(t *testing.T) { - // Test cases for count normalization - testCases := []struct { - name string - inputCount int - expectedCount int - }{ - {"negative count", -5, 10}, // Should use default (10) - {"zero count", 0, 10}, // Should use default (10) - {"valid count", 50, 50}, // Should keep as is - {"excessive count", 150, 100}, // Should cap at 100 - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Verify count normalization logic directly - assert.NotPanics(t, func() { - // Apply the same normalization logic as in the tool - normalizedCount := tc.inputCount - if normalizedCount <= 0 { - normalizedCount = 10 - } else if normalizedCount > 100 { - normalizedCount = 100 - } - - assert.Equal(t, tc.expectedCount, normalizedCount) - }) - }) - } - }) -} diff --git a/internal/llm/tools/tools.go b/internal/llm/tools/tools.go index e15c1c31f..bf0f8df0b 100644 --- a/internal/llm/tools/tools.go +++ b/internal/llm/tools/tools.go @@ -1,6 +1,9 @@ package tools -import "context" +import ( + "context" + "encoding/json" +) type ToolInfo struct { Name string @@ -11,15 +14,24 @@ type ToolInfo struct { type toolResponseType string +type ( + sessionIDContextKey string + messageIDContextKey string +) + const ( ToolResponseTypeText toolResponseType = "text" ToolResponseTypeImage toolResponseType = "image" + + SessionIDContextKey sessionIDContextKey = "session_id" + MessageIDContextKey messageIDContextKey = "message_id" ) type ToolResponse struct { - Type toolResponseType `json:"type"` - Content string `json:"content"` - IsError bool `json:"is_error"` + Type toolResponseType `json:"type"` + Content string `json:"content"` + Metadata string `json:"metadata,omitempty"` + IsError bool `json:"is_error"` } func NewTextResponse(content string) ToolResponse { @@ -29,6 +41,17 @@ func NewTextResponse(content string) ToolResponse { } } +func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse { + if metadata != nil { + metadataBytes, err := json.Marshal(metadata) + if err != nil { + return response + } + response.Metadata = string(metadataBytes) + } + return response +} + func NewTextErrorResponse(content string) ToolResponse { return ToolResponse{ Type: ToolResponseTypeText, @@ -47,3 +70,15 @@ type BaseTool interface { Info() ToolInfo Run(ctx context.Context, params ToolCall) (ToolResponse, error) } + +func GetContextValues(ctx context.Context) (string, string) { + sessionID := ctx.Value(SessionIDContextKey) + messageID := ctx.Value(MessageIDContextKey) + if sessionID == nil { + return "", "" + } + if messageID == nil { + return sessionID.(string), "" + } + return sessionID.(string), messageID.(string) +} diff --git a/internal/llm/tools/view.go b/internal/llm/tools/view.go index a687be015..dc02b34f3 100644 --- a/internal/llm/tools/view.go +++ b/internal/llm/tools/view.go @@ -10,8 +10,8 @@ import ( "path/filepath" "strings" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/lsp" ) type ViewParams struct { @@ -24,6 +24,11 @@ type viewTool struct { lspClients map[string]*lsp.Client } +type ViewResponseMetadata struct { + FilePath string `json:"file_path"` + Content string `json:"content"` +} + const ( ViewToolName = "view" MaxReadSize = 250 * 1024 @@ -135,7 +140,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil } - return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error accessing file: %w", err) } // Check if it's a directory @@ -156,6 +161,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) // Check if it's an image file isImage, imageType := isImageFile(filePath) + // TODO: handle images if isImage { return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil } @@ -163,7 +169,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) // Read the file content content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("Failed to read file: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error reading file: %w", err) } notifyLspOpenFile(ctx, filePath, v.lspClients) @@ -177,9 +183,15 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) params.Offset+len(strings.Split(content, "\n"))) } output += "\n</file>\n" - output += appendDiagnostics(filePath, v.lspClients) + output += getDiagnostics(filePath, v.lspClients) recordFileRead(filePath) - return NewTextResponse(output), nil + return WithResponseMetadata( + NewTextResponse(output), + ViewResponseMetadata{ + FilePath: filePath, + Content: content, + }, + ), nil } func addLineNumbers(content string, startLine int) string { diff --git a/internal/llm/tools/write.go b/internal/llm/tools/write.go index 7b698d2d8..ec6fc1dc4 100644 --- a/internal/llm/tools/write.go +++ b/internal/llm/tools/write.go @@ -6,11 +6,15 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/lsp" - "github.com/kujtimiihoxha/termai/internal/permission" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/diff" + "github.com/kujtimiihoxha/opencode/internal/history" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/permission" ) type WriteParams struct { @@ -20,12 +24,19 @@ type WriteParams struct { type WritePermissionsParams struct { FilePath string `json:"file_path"` - Content string `json:"content"` + Diff string `json:"diff"` } type writeTool struct { lspClients map[string]*lsp.Client permissions permission.Service + files history.Service +} + +type WriteResponseMetadata struct { + Diff string `json:"diff"` + Additions int `json:"additions"` + Removals int `json:"removals"` } const ( @@ -60,10 +71,11 @@ TIPS: - Always include descriptive comments when making changes to existing code` ) -func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool { +func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool { return &writeTool{ lspClients: lspClients, permissions: permissions, + files: files, } } @@ -122,12 +134,12 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil } } else if !os.IsNotExist(err) { - return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error checking file: %w", err) } dir := filepath.Dir(filePath) if err = os.MkdirAll(dir, 0o755); err != nil { - return NewTextErrorResponse(fmt.Sprintf("Failed to create parent directories: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error creating directory: %w", err) } oldContent := "" @@ -138,25 +150,64 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error } } + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session_id and message_id are required") + } + + diff, additions, removals := diff.GenerateDiff( + oldContent, + params.Content, + filePath, + ) + + rootDir := config.WorkingDirectory() + permissionPath := filepath.Dir(filePath) + if strings.HasPrefix(filePath, rootDir) { + permissionPath = rootDir + } p := w.permissions.Request( permission.CreatePermissionRequest{ - Path: filePath, + SessionID: sessionID, + Path: permissionPath, ToolName: WriteToolName, - Action: "create", + Action: "write", Description: fmt.Sprintf("Create file %s", filePath), Params: WritePermissionsParams{ FilePath: filePath, - Content: GenerateDiff(oldContent, params.Content), + Diff: diff, }, }, ) if !p { - return NewTextErrorResponse(fmt.Sprintf("Permission denied to create file: %s", filePath)), nil + return ToolResponse{}, permission.ErrorPermissionDenied } err = os.WriteFile(filePath, []byte(params.Content), 0o644) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("Failed to write file: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error writing file: %w", err) + } + + // Check if file exists in history + file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID) + if err != nil { + _, err = w.files.Create(ctx, sessionID, filePath, oldContent) + if err != nil { + // Log error but don't fail the operation + return ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + } + } + if file.Content != oldContent { + // User Manually changed the content store an intermediate version + _, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent) + if err != nil { + logging.Debug("Error creating file history version", "error", err) + } + } + // Store the new version + _, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content) + if err != nil { + logging.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) @@ -165,6 +216,12 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error result := fmt.Sprintf("File successfully written: %s", filePath) result = fmt.Sprintf("<result>\n%s\n</result>", result) - result += appendDiagnostics(filePath, w.lspClients) - return NewTextResponse(result), nil + result += getDiagnostics(filePath, w.lspClients) + return WithResponseMetadata(NewTextResponse(result), + WriteResponseMetadata{ + Diff: diff, + Additions: additions, + Removals: removals, + }, + ), nil } diff --git a/internal/llm/tools/write_test.go b/internal/llm/tools/write_test.go deleted file mode 100644 index 50dafc14f..000000000 --- a/internal/llm/tools/write_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "github.com/kujtimiihoxha/termai/internal/lsp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestWriteTool_Info(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - info := tool.Info() - - assert.Equal(t, WriteToolName, info.Name) - assert.NotEmpty(t, info.Description) - assert.Contains(t, info.Parameters, "file_path") - assert.Contains(t, info.Parameters, "content") - assert.Contains(t, info.Required, "file_path") - assert.Contains(t, info.Required, "content") -} - -func TestWriteTool_Run(t *testing.T) { - // Create a temporary directory for testing - tempDir, err := os.MkdirTemp("", "write_tool_test") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - t.Run("creates a new file successfully", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - filePath := filepath.Join(tempDir, "new_file.txt") - content := "This is a test content" - - params := WriteParams{ - FilePath: filePath, - Content: content, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "successfully written") - - // Verify file was created with correct content - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, content, string(fileContent)) - }) - - t.Run("creates file with nested directories", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt") - content := "Content in nested directory" - - params := WriteParams{ - FilePath: filePath, - Content: content, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "successfully written") - - // Verify file was created with correct content - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, content, string(fileContent)) - }) - - t.Run("updates existing file", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file first - filePath := filepath.Join(tempDir, "existing_file.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Update the file - updatedContent := "Updated content" - params := WriteParams{ - FilePath: filePath, - Content: updatedContent, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "successfully written") - - // Verify file was updated with correct content - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, updatedContent, string(fileContent)) - }) - - t.Run("handles invalid parameters", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - call := ToolCall{ - Name: WriteToolName, - Input: "invalid json", - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "error parsing parameters") - }) - - t.Run("handles missing file_path", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - params := WriteParams{ - FilePath: "", - Content: "Some content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "file_path is required") - }) - - t.Run("handles missing content", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - params := WriteParams{ - FilePath: filepath.Join(tempDir, "file.txt"), - Content: "", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "content is required") - }) - - t.Run("handles writing to a directory path", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a directory - dirPath := filepath.Join(tempDir, "test_dir") - err := os.Mkdir(dirPath, 0o755) - require.NoError(t, err) - - params := WriteParams{ - FilePath: dirPath, - Content: "Some content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Path is a directory") - }) - - t.Run("handles permission denied", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(false)) - - filePath := filepath.Join(tempDir, "permission_denied.txt") - params := WriteParams{ - FilePath: filePath, - Content: "Content that should not be written", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Permission denied") - - // Verify file was not created - _, err = os.Stat(filePath) - assert.True(t, os.IsNotExist(err)) - }) - - t.Run("detects file modified since last read", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file - filePath := filepath.Join(tempDir, "modified_file.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record an old read time - fileRecordMutex.Lock() - fileRecords[filePath] = fileRecord{ - path: filePath, - readTime: time.Now().Add(-1 * time.Hour), - } - fileRecordMutex.Unlock() - - // Try to update the file - params := WriteParams{ - FilePath: filePath, - Content: "Updated content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "has been modified since it was last read") - - // Verify file was not modified - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, initialContent, string(fileContent)) - }) - - t.Run("skips writing when content is identical", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file - filePath := filepath.Join(tempDir, "identical_content.txt") - content := "Content that won't change" - err := os.WriteFile(filePath, []byte(content), 0o644) - require.NoError(t, err) - - // Record a read time - recordFileRead(filePath) - - // Try to write the same content - params := WriteParams{ - FilePath: filePath, - Content: content, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "already contains the exact content") - }) -} |
