summaryrefslogtreecommitdiffhomepage
path: root/internal/llm/tools
diff options
context:
space:
mode:
authorKujtim Hoxha <[email protected]>2025-04-21 19:59:35 +0200
committerGitHub <[email protected]>2025-04-21 19:59:35 +0200
commitf33dff87725764af0b675b5e5b2e011b21c14c90 (patch)
tree4fe2c022305f13775f2cab3cdd80cd808259765b /internal/llm/tools
parent6b1c64bcc75b89c530294b6a2d4404682b435d56 (diff)
parent3a6a26981a8074b6ab0eaadb520db986e04799ff (diff)
downloadopencode-f33dff87725764af0b675b5e5b2e011b21c14c90.tar.gz
opencode-f33dff87725764af0b675b5e5b2e011b21c14c90.zip
Merge pull request #27 from kujtimiihoxha/opencode
OpenCode - Initial Implementation
Diffstat (limited to 'internal/llm/tools')
-rw-r--r--internal/llm/tools/bash.go42
-rw-r--r--internal/llm/tools/bash_test.go371
-rw-r--r--internal/llm/tools/diagnostics.go8
-rw-r--r--internal/llm/tools/edit.go341
-rw-r--r--internal/llm/tools/edit_test.go509
-rw-r--r--internal/llm/tools/fetch.go19
-rw-r--r--internal/llm/tools/glob.go55
-rw-r--r--internal/llm/tools/grep.go145
-rw-r--r--internal/llm/tools/ls.go17
-rw-r--r--internal/llm/tools/patch.go372
-rw-r--r--internal/llm/tools/shell/shell.go20
-rw-r--r--internal/llm/tools/sourcegraph.go17
-rw-r--r--internal/llm/tools/sourcegraph_test.go86
-rw-r--r--internal/llm/tools/tools.go43
-rw-r--r--internal/llm/tools/view.go24
-rw-r--r--internal/llm/tools/write.go85
-rw-r--r--internal/llm/tools/write_test.go307
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), &params); 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), &params); 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")
- })
-}