Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions internal/provider/builtin/builtin_more_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package builtin

import (
"context"
"errors"
"testing"

"neo-code/internal/config"
"neo-code/internal/provider"
"neo-code/internal/provider/openai"
)

func TestRegister(t *testing.T) {
t.Parallel()

tests := []struct {
name string
reg *provider.Registry
wantErr string
}{
{
name: "nil registry",
reg: nil,
wantErr: "builtin provider registry is nil",
},
{
name: "registers openai driver",
reg: provider.NewRegistry(),
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := Register(tt.reg)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("expected error %q, got %v", tt.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !tt.reg.Supports(openai.Name) {
t.Fatalf("expected registry to support %q", openai.Name)
}
})
}
}

func TestNewRegistry(t *testing.T) {
t.Parallel()

reg, err := NewRegistry()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reg == nil {
t.Fatal("expected registry")
}
if !reg.Supports(openai.Name) {
t.Fatalf("expected builtin registry to support %q", openai.Name)
}
}

func TestNewRegistryBuildsRegisteredDriver(t *testing.T) {
t.Parallel()

reg, err := NewRegistry()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

_, err = reg.Build(context.Background(), config.ResolvedProviderConfig{
ProviderConfig: config.ProviderConfig{
Driver: openai.Name,
},
})
if err == nil {
t.Fatal("expected build to fail without api key or required config")
}
if errors.Is(err, provider.ErrDriverNotFound) {
t.Fatalf("expected registered driver error, got %v", err)
}
}
40 changes: 22 additions & 18 deletions internal/tools/bash/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ import (
)

type Tool struct {
root string
shell string
timeout time.Duration
outputLimit int
root string
shell string
timeout time.Duration
}

type input struct {
Expand All @@ -28,10 +27,9 @@ type input struct {

func New(root string, shell string, timeout time.Duration) *Tool {
return &Tool{
root: root,
shell: shell,
timeout: timeout,
outputLimit: 16 * 1024,
root: root,
shell: shell,
timeout: timeout,
}
}

Expand Down Expand Up @@ -63,15 +61,16 @@ func (t *Tool) Schema() map[string]any {
func (t *Tool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) {
var in input
if err := json.Unmarshal(call.Arguments, &in); err != nil {
return tools.ToolResult{Name: t.Name()}, err
return tools.NewErrorResult(t.Name(), "invalid arguments", err.Error(), nil), err
}
if strings.TrimSpace(in.Command) == "" {
return tools.ToolResult{Name: t.Name()}, errors.New("bash: command is empty")
err := errors.New("bash: command is empty")
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
}

workdir, err := resolveWorkdir(call.Workdir, in.Workdir)
if err != nil {
return tools.ToolResult{Name: t.Name()}, err
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
}

runCtx, cancel := context.WithTimeout(ctx, t.timeout)
Expand All @@ -83,21 +82,26 @@ func (t *Tool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.Too
output, err := cmd.CombinedOutput()

content := string(output)
if len(content) > t.outputLimit {
content = content[:t.outputLimit] + "\n...[truncated]"
if err != nil {
result := tools.NewErrorResult(
t.Name(),
tools.NormalizeErrorReason(t.Name(), err),
content,
map[string]any{"workdir": workdir},
)
result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes)
return result, err
}

result := tools.ToolResult{
Name: t.Name(),
Content: content,
IsError: err != nil,
Metadata: map[string]any{
"workdir": workdir,
},
}
if err != nil && strings.TrimSpace(result.Content) == "" {
result.Content = err.Error()
}
return result, err
result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes)
return result, nil
}

func (t *Tool) shellArgs(command string) []string {
Expand Down
104 changes: 104 additions & 0 deletions internal/tools/bash/tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,88 @@ func TestToolExecute(t *testing.T) {
}
}

func TestToolExecuteErrorFormattingAndTruncation(t *testing.T) {
workspace := t.TempDir()
tool := New(workspace, defaultShell(), 3*time.Second)

tests := []struct {
name string
arguments []byte
expectErr string
expectContent []string
expectMetadata bool
expectTruncate bool
}{
{
name: "invalid json arguments",
arguments: []byte(`{invalid`),
expectErr: "invalid character",
expectContent: []string{"tool error", "tool: bash", "reason: invalid arguments"},
},
{
name: "command failure returns formatted error",
arguments: mustMarshalArgs(t, map[string]string{"command": failingCommand(), "workdir": ""}),
expectErr: commandFailureFragment(),
expectContent: []string{
"tool error",
"tool: bash",
"reason:",
commandFailureOutput(),
},
expectMetadata: true,
},
{
name: "large output is truncated",
arguments: mustMarshalArgs(t, map[string]string{"command": largeOutputCommand(), "workdir": ""}),
expectContent: []string{
"...[truncated]",
},
expectMetadata: true,
expectTruncate: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := tool.Execute(context.Background(), tools.ToolCallInput{
Name: tool.Name(),
Arguments: tt.arguments,
Workdir: workspace,
})

if tt.expectErr != "" {
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.expectErr)) {
t.Fatalf("expected error containing %q, got %v", tt.expectErr, err)
}
} else if err != nil {
t.Fatalf("unexpected error: %v", err)
}

for _, fragment := range tt.expectContent {
if !strings.Contains(result.Content, fragment) {
t.Fatalf("expected content to contain %q, got %q", fragment, result.Content)
}
}
if tt.expectMetadata && result.Metadata["workdir"] == "" {
t.Fatalf("expected workdir metadata, got %#v", result.Metadata)
}
if truncated, _ := result.Metadata["truncated"].(bool); truncated != tt.expectTruncate {
t.Fatalf("expected truncated=%v, got %#v", tt.expectTruncate, result.Metadata["truncated"])
}
})
}
}

func mustMarshalArgs(t *testing.T, value any) []byte {
t.Helper()

data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal args: %v", err)
}
return data
}

func safeEchoCommand() string {
if goruntime.GOOS == "windows" {
return "Write-Output 'hello'"
Expand All @@ -102,6 +184,28 @@ func safePwdCommand() string {
return "pwd"
}

func failingCommand() string {
if goruntime.GOOS == "windows" {
return "Write-Output 'boom'; exit 1"
}
return "printf 'boom\\n'; exit 1"
}

func commandFailureFragment() string {
return "exit status 1"
}

func commandFailureOutput() string {
return "boom"
}

func largeOutputCommand() string {
if goruntime.GOOS == "windows" {
return "$text = 'x' * 70000; Write-Output $text"
}
return "i=0; while [ $i -lt 70000 ]; do printf x; i=$((i+1)); done"
}

func defaultShell() string {
if goruntime.GOOS == "windows" {
return "powershell"
Expand Down
27 changes: 16 additions & 11 deletions internal/tools/filesystem/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,48 +57,53 @@ func (t *EditTool) Schema() map[string]any {
func (t *EditTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) {
var args editInput
if err := json.Unmarshal(input.Arguments, &args); err != nil {
return tools.ToolResult{Name: t.Name()}, err
return tools.NewErrorResult(t.Name(), "invalid arguments", err.Error(), nil), err
}
if strings.TrimSpace(args.Path) == "" {
return tools.ToolResult{Name: t.Name()}, errors.New(editToolName + ": path is required")
err := errors.New(editToolName + ": path is required")
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
}
if args.SearchString == "" {
return tools.ToolResult{Name: t.Name()}, errors.New(editToolName + ": search_string is required")
err := errors.New(editToolName + ": search_string is required")
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
}
if err := ctx.Err(); err != nil {
return tools.ToolResult{Name: t.Name()}, err
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
}

root := effectiveRoot(t.root, input.Workdir)
target, err := resolvePath(root, args.Path)
if err != nil {
return tools.ToolResult{Name: t.Name()}, err
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
}

data, err := os.ReadFile(target)
if err != nil {
return tools.ToolResult{Name: t.Name()}, err
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
}

content := string(data)
matches := strings.Count(content, args.SearchString)
switch {
case matches == 0:
return tools.ToolResult{Name: t.Name()}, fmt.Errorf("%s: search_string not found in %s", editToolName, toRelativePath(root, target))
err := fmt.Errorf("%s: search_string not found in %s", editToolName, toRelativePath(root, target))
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
case matches > 1:
return tools.ToolResult{Name: t.Name()}, fmt.Errorf("%s: search_string matched %d locations in %s; refine it to a unique block", editToolName, matches, toRelativePath(root, target))
err := fmt.Errorf("%s: search_string matched %d locations in %s; refine it to a unique block", editToolName, matches, toRelativePath(root, target))
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
}

updated := strings.Replace(content, args.SearchString, args.ReplaceString, 1)
if updated == content {
return tools.ToolResult{Name: t.Name()}, fmt.Errorf("%s: replacement produced no changes", editToolName)
err := fmt.Errorf("%s: replacement produced no changes", editToolName)
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
}
if err := ctx.Err(); err != nil {
return tools.ToolResult{Name: t.Name()}, err
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
}

if err := os.WriteFile(target, []byte(updated), 0o644); err != nil {
return tools.ToolResult{Name: t.Name()}, err
return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err
}

return tools.ToolResult{
Expand Down
Loading
Loading