From d33802ceaf416daad1c0a32c219b7778f1d55016 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 21:41:21 +0800 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=BB=9F=E4=B8=80=E8=BE=93=E5=87=BA=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E4=B8=8E=E9=94=99=E8=AF=AF=E6=A0=BC=E5=BC=8F=E5=8A=A9=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/format.go | 76 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 internal/tools/format.go diff --git a/internal/tools/format.go b/internal/tools/format.go new file mode 100644 index 00000000..ae404b7e --- /dev/null +++ b/internal/tools/format.go @@ -0,0 +1,76 @@ +package tools + +import "strings" + +const ( + // DefaultOutputLimitBytes is the default max size of tool output content. + DefaultOutputLimitBytes = 64 * 1024 + truncatedSuffix = "\n...[truncated]" +) + +// ApplyOutputLimit truncates tool output content and adds a truncated metadata flag. +func ApplyOutputLimit(result ToolResult, limit int) ToolResult { + if limit <= 0 { + return result + } + if len(result.Content) <= limit { + return result + } + + result.Content = result.Content[:limit] + truncatedSuffix + if result.Metadata == nil { + result.Metadata = map[string]any{} + } + if existing, ok := result.Metadata["truncated"].(bool); !ok || !existing { + result.Metadata["truncated"] = true + } + return result +} + +// FormatError builds a consistent error payload for tool failures. +func FormatError(toolName string, reason string, details string) string { + toolName = strings.TrimSpace(toolName) + reason = strings.TrimSpace(reason) + details = strings.TrimSpace(details) + + lines := []string{"tool error"} + if toolName != "" { + lines = append(lines, "tool: "+toolName) + } + if reason != "" { + lines = append(lines, "reason: "+reason) + } + if details != "" { + lines = append(lines, "details: "+details) + } + + return strings.Join(lines, "\n") +} + +// NormalizeErrorReason strips the tool name prefix from an error message, if present. +func NormalizeErrorReason(toolName string, err error) string { + if err == nil { + return "" + } + reason := strings.TrimSpace(err.Error()) + if toolName == "" { + return reason + } + + prefix := strings.ToLower(strings.TrimSpace(toolName)) + ":" + lower := strings.ToLower(reason) + if strings.HasPrefix(lower, prefix) { + return strings.TrimSpace(reason[len(prefix):]) + } + return reason +} + +// NewErrorResult returns a standardized error ToolResult. +func NewErrorResult(toolName string, reason string, details string, metadata map[string]any) ToolResult { + return ToolResult{ + Name: toolName, + Content: FormatError(toolName, reason, details), + IsError: true, + Metadata: metadata, + } +} From 0f3dcf4905bae9b51b59af3d79bbb51d4e106434 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 21:41:55 +0800 Subject: [PATCH 02/20] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=B3=A8=E5=86=8C=E8=A1=A8=E9=94=99=E8=AF=AF=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/registry.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/tools/registry.go b/internal/tools/registry.go index ed1906ce..c0fc57a1 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -60,10 +60,11 @@ func (r *Registry) ListSchemas() []provider.ToolSpec { func (r *Registry) Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) { tool, err := r.Get(input.Name) if err != nil { + content := FormatError(input.Name, NormalizeErrorReason(input.Name, err), "") return ToolResult{ ToolCallID: input.ID, Name: input.Name, - Content: err.Error(), + Content: content, IsError: true, }, err } @@ -73,7 +74,7 @@ func (r *Registry) Execute(ctx context.Context, input ToolCallInput) (ToolResult if execErr != nil { result.IsError = true if strings.TrimSpace(result.Content) == "" { - result.Content = execErr.Error() + result.Content = FormatError(result.Name, NormalizeErrorReason(result.Name, execErr), "") } return result, execErr } From ea592a607440e56bca21bf6071a881fcfa79704c Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 21:42:22 +0800 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=B3=A8=E5=86=8C=E8=A1=A8=E9=94=99=E8=AF=AF=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/registry_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/tools/registry_test.go b/internal/tools/registry_test.go index f573a73a..fd12f3b1 100644 --- a/internal/tools/registry_test.go +++ b/internal/tools/registry_test.go @@ -3,6 +3,7 @@ package tools import ( "context" "errors" + "strings" "testing" ) @@ -94,7 +95,7 @@ func TestRegistryExecute(t *testing.T) { Name: "missing_tool", }, expectErr: "tool: not found", - expectContent: "tool: not found", + expectContent: "tool error", expectIsError: true, }, { @@ -104,7 +105,7 @@ func TestRegistryExecute(t *testing.T) { Name: "error_tool", }, expectErr: "boom", - expectContent: "boom", + expectContent: "tool error", expectIsError: true, }, { @@ -135,8 +136,8 @@ func TestRegistryExecute(t *testing.T) { if result.ToolCallID != tt.input.ID { t.Fatalf("expected tool call id %q, got %q", tt.input.ID, result.ToolCallID) } - if result.Content != tt.expectContent { - t.Fatalf("expected content %q, got %q", tt.expectContent, result.Content) + if tt.expectContent != "" && !strings.Contains(result.Content, tt.expectContent) { + t.Fatalf("expected content containing %q, got %q", tt.expectContent, result.Content) } if result.IsError != tt.expectIsError { t.Fatalf("expected IsError=%v, got %v", tt.expectIsError, result.IsError) From ab757106bbefe084fb8d69b6be19eaa762af999b Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 21:43:00 +0800 Subject: [PATCH 04/20] =?UTF-8?q?feat:=20bash=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=94=99=E8=AF=AF=E8=BE=93=E5=87=BA=E4=B8=8E?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/bash/tool.go | 40 ++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/internal/tools/bash/tool.go b/internal/tools/bash/tool.go index f04ea611..d8243122 100644 --- a/internal/tools/bash/tool.go +++ b/internal/tools/bash/tool.go @@ -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 { @@ -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, } } @@ -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) @@ -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 { From 7e20c2e68aca113f6a8801765e973762c24bf8f2 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 21:43:24 +0800 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20read=5Ffile=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E9=99=90=E5=88=B6=E4=B8=8E=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/filesystem/read_file.go | 37 +++++++++++++++----------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/internal/tools/filesystem/read_file.go b/internal/tools/filesystem/read_file.go index f7902282..612501a0 100644 --- a/internal/tools/filesystem/read_file.go +++ b/internal/tools/filesystem/read_file.go @@ -49,41 +49,46 @@ func (t *ReadFileTool) Schema() map[string]any { func (t *ReadFileTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { var args readFileInput 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(readFileToolName + ": path is required") + err := errors.New(readFileToolName + ": path is required") + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } base := effectiveRoot(t.root, input.Workdir) target, err := resolvePath(base, 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 } - if input.EmitChunk != nil && len(data) > emitChunkSize { - for start := 0; start < len(data); start += emitChunkSize { - end := start + emitChunkSize - if end > len(data) { - end = len(data) - } - input.EmitChunk(data[start:end]) - } - } - - return tools.ToolResult{ + result := tools.ToolResult{ Name: t.Name(), Content: string(data), Metadata: map[string]any{ "path": target, }, - }, nil + } + result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes) + + if input.EmitChunk != nil { + content := []byte(result.Content) + for start := 0; start < len(content); start += emitChunkSize { + end := start + emitChunkSize + if end > len(content) { + end = len(content) + } + input.EmitChunk(content[start:end]) + } + } + + return result, nil } func resolvePath(root string, requested string) (string, error) { From 0e2cfe5e37fd24351f4bad2b2bcab0a0ed6500bd Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 21:44:06 +0800 Subject: [PATCH 06/20] =?UTF-8?q?feat:=20write=5Ffile=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=BE=93=E5=87=BA=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/filesystem/write_file.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/tools/filesystem/write_file.go b/internal/tools/filesystem/write_file.go index f5220315..bdc9bc3c 100644 --- a/internal/tools/filesystem/write_file.go +++ b/internal/tools/filesystem/write_file.go @@ -52,26 +52,27 @@ func (t *WriteFileTool) Schema() map[string]any { func (t *WriteFileTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { var args writeFileInput 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(writeFileToolName + ": path is required") + err := errors.New(writeFileToolName + ": path 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 } base := effectiveRoot(t.root, input.Workdir) target, err := resolvePath(base, args.Path) if err != nil { - return tools.ToolResult{Name: t.Name()}, err + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } if err := os.MkdirAll(filepath.Dir(target), 0o755); 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(args.Content), 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{ From b1aedbdc612774aae545503f56c93cacb10ecc35 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 21:44:39 +0800 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20edit=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=94=99=E8=AF=AF=E8=BE=93=E5=87=BA=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/filesystem/edit.go | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/internal/tools/filesystem/edit.go b/internal/tools/filesystem/edit.go index 7d831ada..f878062a 100644 --- a/internal/tools/filesystem/edit.go +++ b/internal/tools/filesystem/edit.go @@ -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{ From 7f1bd7f24274e4c5370c56df164134558de1c7a4 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 21:45:01 +0800 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20grep=20=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E4=B8=8E=E7=BB=9F=E4=B8=80=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/filesystem/grep.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/tools/filesystem/grep.go b/internal/tools/filesystem/grep.go index a152dfe0..cd54adec 100644 --- a/internal/tools/filesystem/grep.go +++ b/internal/tools/filesystem/grep.go @@ -63,26 +63,27 @@ func (t *GrepTool) Schema() map[string]any { func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { var args grepInput 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 } pattern := strings.TrimSpace(args.Pattern) if pattern == "" { - return tools.ToolResult{Name: t.Name()}, errors.New(grepToolName + ": pattern is required") + err := errors.New(grepToolName + ": pattern 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) searchRoot, err := resolveSearchDir(root, args.Dir) if err != nil { - return tools.ToolResult{Name: t.Name()}, err + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } matcher, err := buildGrepMatcher(pattern, args.UseRegex) if err != nil { - return tools.ToolResult{Name: t.Name()}, err + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } var ( @@ -126,7 +127,7 @@ func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool return nil }) if err != nil && !errors.Is(err, errGrepResultLimitReached) { - return tools.ToolResult{Name: t.Name()}, err + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } if len(results) == 0 { @@ -141,7 +142,7 @@ func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool }, nil } - return tools.ToolResult{ + result := tools.ToolResult{ Name: t.Name(), Content: strings.Join(results, "\n"), Metadata: map[string]any{ @@ -149,7 +150,9 @@ func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool "matched_files": matchedFiles, "matched_lines": len(results), }, - }, nil + } + result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes) + return result, nil } func buildGrepMatcher(pattern string, useRegex bool) (func(string) bool, error) { From f8896137f595952d447a925ae557a94905c2e354 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 21:45:40 +0800 Subject: [PATCH 09/20] =?UTF-8?q?feat:=20glob=20=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E4=B8=8E=E7=BB=9F=E4=B8=80=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/filesystem/glob.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/tools/filesystem/glob.go b/internal/tools/filesystem/glob.go index 41bac66a..03574e80 100644 --- a/internal/tools/filesystem/glob.go +++ b/internal/tools/filesystem/glob.go @@ -54,27 +54,28 @@ func (t *GlobTool) Schema() map[string]any { func (t *GlobTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { var args globInput 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 } rawPattern := strings.TrimSpace(args.Pattern) if rawPattern == "" { - return tools.ToolResult{Name: t.Name()}, errors.New(globToolName + ": pattern is required") + err := errors.New(globToolName + ": pattern is required") + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } pattern := normalizeSlashPath(rawPattern) 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) searchRoot, err := resolveSearchDir(root, args.Dir) if err != nil { - return tools.ToolResult{Name: t.Name()}, err + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } matcher, err := buildGlobMatcher(pattern) if err != nil { - return tools.ToolResult{Name: t.Name()}, err + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } matches := make([]string, 0, 32) @@ -102,7 +103,7 @@ func (t *GlobTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool return nil }) if err != nil { - return tools.ToolResult{Name: t.Name()}, err + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } sort.Strings(matches) @@ -117,14 +118,16 @@ func (t *GlobTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool }, nil } - return tools.ToolResult{ + result := tools.ToolResult{ Name: t.Name(), Content: strings.Join(matches, "\n"), Metadata: map[string]any{ "root": searchRoot, "count": len(matches), }, - }, nil + } + result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes) + return result, nil } func buildGlobMatcher(pattern string) (*regexp.Regexp, error) { From 69e286fe8af7894ef40e402d27f6ca91ab628099 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 21:46:29 +0800 Subject: [PATCH 10/20] =?UTF-8?q?feat:=20webfetch=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=BE=93=E5=87=BA=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/webfetch/tool.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/tools/webfetch/tool.go b/internal/tools/webfetch/tool.go index dfc889bf..efa5474f 100644 --- a/internal/tools/webfetch/tool.go +++ b/internal/tools/webfetch/tool.go @@ -170,7 +170,7 @@ func (t *Tool) handleResponse(targetURL string, resp *http.Response) (tools.Tool content, title, err := t.extractContent(data.ContentType, body) if err != nil { - return t.newErrorResult(data, reasonUnsupportedType, ""), fmt.Errorf("webfetch: extract content: %w", err) + return t.newErrorResult(data, reasonUnsupportedType, err.Error()), fmt.Errorf("webfetch: extract content: %w", err) } data.Title = title @@ -255,9 +255,15 @@ func formatSuccess(data responseData) string { } func formatError(data responseData, reason string, details string) string { - lines := append([]string{"webfetch error"}, formatCommonLines(data)...) - lines = append(lines, "reason: "+reason) - return joinMessage(lines, details) + lines := []string{"tool error", "tool: webfetch"} + if strings.TrimSpace(reason) != "" { + lines = append(lines, "reason: "+strings.TrimSpace(reason)) + } + lines = append(lines, formatCommonLines(data)...) + if strings.TrimSpace(details) != "" { + lines = append(lines, "details: "+strings.TrimSpace(details)) + } + return strings.Join(lines, "\n") } func formatCommonLines(data responseData) []string { From 991297c73715f197313ca478acea1a6811bd0197 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 21:47:08 +0800 Subject: [PATCH 11/20] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20webfetch=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=BE=93=E5=87=BA=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/webfetch/tool_test.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/tools/webfetch/tool_test.go b/internal/tools/webfetch/tool_test.go index f4c1087b..f49da6a3 100644 --- a/internal/tools/webfetch/tool_test.go +++ b/internal/tools/webfetch/tool_test.go @@ -117,7 +117,8 @@ func TestToolExecute(t *testing.T) { toolConfig: defaultConfig, expectErr: reasonEmptyContent, expectContent: []string{ - "webfetch error", + "tool error", + "tool: webfetch", "content_type: text/html", "reason: " + reasonEmptyContent, }, @@ -153,7 +154,8 @@ func TestToolExecute(t *testing.T) { toolConfig: defaultConfig, expectErr: reasonUnsupportedType, expectContent: []string{ - "webfetch error", + "tool error", + "tool: webfetch", "content_type: image/png", "reason: " + reasonUnsupportedType, }, @@ -167,6 +169,8 @@ func TestToolExecute(t *testing.T) { toolConfig: defaultConfig, expectErr: reasonUnsupportedType, expectContent: []string{ + "tool error", + "tool: webfetch", "reason: " + reasonUnsupportedType, }, expectStatus: "200 OK", @@ -195,7 +199,8 @@ func TestToolExecute(t *testing.T) { toolConfig: defaultConfig, expectErr: "unexpected HTTP status 502 Bad Gateway", expectContent: []string{ - "webfetch error", + "tool error", + "tool: webfetch", "status: 502 Bad Gateway", "content_type: text/plain", "reason: unexpected HTTP status 502 Bad Gateway", @@ -211,7 +216,8 @@ func TestToolExecute(t *testing.T) { toolConfig: defaultConfig, expectErr: "url must start with http:// or https://", expectContent: []string{ - "webfetch error", + "tool error", + "tool: webfetch", "reason: " + reasonInvalidURL, }, expectIsError: true, @@ -222,7 +228,8 @@ func TestToolExecute(t *testing.T) { toolConfig: defaultConfig, expectErr: "JSON input", expectContent: []string{ - "webfetch error", + "tool error", + "tool: webfetch", "reason: " + reasonInvalidArguments, }, expectIsError: true, @@ -233,7 +240,8 @@ func TestToolExecute(t *testing.T) { toolConfig: defaultConfig, expectErr: "webfetch: fetch", expectContent: []string{ - "webfetch error", + "tool error", + "tool: webfetch", "reason: " + reasonRequestFailed, }, expectIsError: true, From b56a1ef7b7a99d7366dfadfe894a2be3f96d5965 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Mon, 30 Mar 2026 23:43:14 +0800 Subject: [PATCH 12/20] =?UTF-8?q?pref(provider):=E4=BC=98=E5=8C=96provider?= =?UTF-8?q?=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/builtin_providers.go | 82 ++++ internal/config/builtin_providers_test.go | 180 +++++++++ internal/config/model.go | 8 +- internal/provider/builtin/builtin.go | 17 +- internal/provider/builtin/builtin_test.go | 78 +++- internal/provider/gemini/gemini.go | 31 -- internal/provider/gemini/gemini_test.go | 31 -- internal/provider/openai/openai.go | 29 +- internal/provider/openai/openai_test.go | 48 +-- internal/provider/openll/openll.go | 30 -- internal/provider/openll/openll_test.go | 43 -- internal/provider/registry_test.go | 20 +- internal/provider/service.go | 14 +- internal/provider/service_test.go | 472 ++++++++++++++++++++++ internal/runtime/runtime.go | 2 +- internal/runtime/runtime_test.go | 5 +- internal/tui/update_test.go | 11 +- 17 files changed, 847 insertions(+), 254 deletions(-) create mode 100644 internal/config/builtin_providers.go create mode 100644 internal/config/builtin_providers_test.go delete mode 100644 internal/provider/gemini/gemini.go delete mode 100644 internal/provider/gemini/gemini_test.go delete mode 100644 internal/provider/openll/openll.go delete mode 100644 internal/provider/openll/openll_test.go create mode 100644 internal/provider/service_test.go diff --git a/internal/config/builtin_providers.go b/internal/config/builtin_providers.go new file mode 100644 index 00000000..8d7fa8b9 --- /dev/null +++ b/internal/config/builtin_providers.go @@ -0,0 +1,82 @@ +package config + +const ( + OpenAIName = "openai" + OpenAIDefaultBaseURL = "https://api.openai.com/v1" + OpenAIDefaultModel = "gpt-4.1" + OpenAIDefaultAPIKeyEnv = "OPENAI_API_KEY" + + GeminiName = "gemini" + GeminiDefaultBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai" + GeminiDefaultModel = "gemini-2.5-flash" + GeminiDefaultAPIKeyEnv = "GEMINI_API_KEY" + + OpenLLName = "openll" + OpenLLDefaultBaseURL = "https://www.openll.top/v1" + OpenLLDefaultModel = "gpt-5.4" + OpenLLDefaultAPIKeyEnv = "AI_API_KEY" +) + +var openAIModels = []string{ + OpenAIDefaultModel, + "gpt-4o", + "gpt-5.4", + "gpt-5.3-codex", +} + +var geminiModels = []string{ + GeminiDefaultModel, + "gemini-2.5-pro", + "gemini-2.0-flash", +} + +var openLLModels = []string{ + OpenLLDefaultModel, + "gpt-5.3-codex", + "gpt-5.3-turbo", +} + +// OpenAIProvider 返回 OpenAI provider 的默认配置。 +func OpenAIProvider() ProviderConfig { + return ProviderConfig{ + Name: OpenAIName, + Driver: "openai", + BaseURL: OpenAIDefaultBaseURL, + Model: OpenAIDefaultModel, + Models: append([]string(nil), openAIModels...), + APIKeyEnv: OpenAIDefaultAPIKeyEnv, + } +} + +// GeminiProvider 返回 Gemini provider 的默认配置。 +func GeminiProvider() ProviderConfig { + return ProviderConfig{ + Name: GeminiName, + Driver: "openai", + BaseURL: GeminiDefaultBaseURL, + Model: GeminiDefaultModel, + Models: append([]string(nil), geminiModels...), + APIKeyEnv: GeminiDefaultAPIKeyEnv, + } +} + +// OpenLLProvider 返回 OpenLL provider 的默认配置。 +func OpenLLProvider() ProviderConfig { + return ProviderConfig{ + Name: OpenLLName, + Driver: "openai", + BaseURL: OpenLLDefaultBaseURL, + Model: OpenLLDefaultModel, + Models: append([]string(nil), openLLModels...), + APIKeyEnv: OpenLLDefaultAPIKeyEnv, + } +} + +// DefaultProviders 返回所有内置 provider 配置列表。 +func DefaultProviders() []ProviderConfig { + return []ProviderConfig{ + OpenAIProvider(), + GeminiProvider(), + OpenLLProvider(), + } +} diff --git a/internal/config/builtin_providers_test.go b/internal/config/builtin_providers_test.go new file mode 100644 index 00000000..a9fe0c83 --- /dev/null +++ b/internal/config/builtin_providers_test.go @@ -0,0 +1,180 @@ +package config + +import ( + "testing" +) + +func TestDefaultProvidersReturnsAllBuiltins(t *testing.T) { + t.Parallel() + + providers := DefaultProviders() + if len(providers) != 3 { + t.Fatalf("expected 3 builtin providers, got %d", len(providers)) + } + + expectedNames := []string{OpenAIName, GeminiName, OpenLLName} + for i, provider := range providers { + if provider.Name != expectedNames[i] { + t.Fatalf("expected provider[%d] name %q, got %q", i, expectedNames[i], provider.Name) + } + } +} + +func TestOpenAIProviderConfig(t *testing.T) { + t.Parallel() + + provider := OpenAIProvider() + + if provider.Name != OpenAIName { + t.Fatalf("expected name %q, got %q", OpenAIName, provider.Name) + } + if provider.Driver != "openai" { + t.Fatalf("expected driver %q, got %q", "openai", provider.Driver) + } + if provider.BaseURL != OpenAIDefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", OpenAIDefaultBaseURL, provider.BaseURL) + } + if provider.Model != OpenAIDefaultModel { + t.Fatalf("expected default model %q, got %q", OpenAIDefaultModel, provider.Model) + } + if provider.APIKeyEnv != OpenAIDefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", OpenAIDefaultAPIKeyEnv, provider.APIKeyEnv) + } + if len(provider.Models) == 0 { + t.Fatal("expected non-empty models list") + } + if !ContainsModelID(provider.Models, OpenAIDefaultModel) { + t.Fatalf("expected models to contain default model %q", OpenAIDefaultModel) + } +} + +func TestGeminiProviderConfig(t *testing.T) { + t.Parallel() + + provider := GeminiProvider() + + if provider.Name != GeminiName { + t.Fatalf("expected name %q, got %q", GeminiName, provider.Name) + } + if provider.Driver != "openai" { + t.Fatalf("expected driver %q, got %q", "openai", provider.Driver) + } + if provider.BaseURL != GeminiDefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", GeminiDefaultBaseURL, provider.BaseURL) + } + if provider.Model != GeminiDefaultModel { + t.Fatalf("expected default model %q, got %q", GeminiDefaultModel, provider.Model) + } + if provider.APIKeyEnv != GeminiDefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", GeminiDefaultAPIKeyEnv, provider.APIKeyEnv) + } + if len(provider.Models) == 0 { + t.Fatal("expected non-empty models list") + } + if !ContainsModelID(provider.Models, GeminiDefaultModel) { + t.Fatalf("expected models to contain default model %q", GeminiDefaultModel) + } +} + +func TestOpenLLProviderConfig(t *testing.T) { + t.Parallel() + + provider := OpenLLProvider() + + if provider.Name != OpenLLName { + t.Fatalf("expected name %q, got %q", OpenLLName, provider.Name) + } + if provider.Driver != "openai" { + t.Fatalf("expected driver %q, got %q", "openai", provider.Driver) + } + if provider.BaseURL != OpenLLDefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", OpenLLDefaultBaseURL, provider.BaseURL) + } + if provider.Model != OpenLLDefaultModel { + t.Fatalf("expected default model %q, got %q", OpenLLDefaultModel, provider.Model) + } + if provider.APIKeyEnv != OpenLLDefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", OpenLLDefaultAPIKeyEnv, provider.APIKeyEnv) + } + if len(provider.Models) == 0 { + t.Fatal("expected non-empty models list") + } + if !ContainsModelID(provider.Models, OpenLLDefaultModel) { + t.Fatalf("expected models to contain default model %q", OpenLLDefaultModel) + } +} + +func TestProviderModelsAreImmutable(t *testing.T) { + t.Parallel() + + // Verify that modifying returned models slice doesn't affect future calls + provider1 := OpenAIProvider() + provider1.Models[0] = "modified-model" + + provider2 := OpenAIProvider() + if provider2.Models[0] == "modified-model" { + t.Fatal("expected models slice to be independent between calls") + } + if provider2.Models[0] != OpenAIDefaultModel { + t.Fatalf("expected first model %q, got %q", OpenAIDefaultModel, provider2.Models[0]) + } +} + +func TestDefaultProvidersReturnsIndependentSlices(t *testing.T) { + t.Parallel() + + providers1 := DefaultProviders() + providers1[0].Name = "modified" + + providers2 := DefaultProviders() + if providers2[0].Name == "modified" { + t.Fatal("expected DefaultProviders to return independent slices") + } + if providers2[0].Name != OpenAIName { + t.Fatalf("expected first provider name %q, got %q", OpenAIName, providers2[0].Name) + } +} + +func TestProviderConstants(t *testing.T) { + t.Parallel() + + // Verify all constants are non-empty + if OpenAIName == "" { + t.Fatal("OpenAIName should not be empty") + } + if OpenAIDefaultBaseURL == "" { + t.Fatal("OpenAIDefaultBaseURL should not be empty") + } + if OpenAIDefaultModel == "" { + t.Fatal("OpenAIDefaultModel should not be empty") + } + if OpenAIDefaultAPIKeyEnv == "" { + t.Fatal("OpenAIDefaultAPIKeyEnv should not be empty") + } + + if GeminiName == "" { + t.Fatal("GeminiName should not be empty") + } + if GeminiDefaultBaseURL == "" { + t.Fatal("GeminiDefaultBaseURL should not be empty") + } + if GeminiDefaultModel == "" { + t.Fatal("GeminiDefaultModel should not be empty") + } + if GeminiDefaultAPIKeyEnv == "" { + t.Fatal("GeminiDefaultAPIKeyEnv should not be empty") + } + + if OpenLLName == "" { + t.Fatal("OpenLLName should not be empty") + } + if OpenLLDefaultBaseURL == "" { + t.Fatal("OpenLLDefaultBaseURL should not be empty") + } + if OpenLLDefaultModel == "" { + t.Fatal("OpenLLDefaultModel should not be empty") + } + if OpenLLDefaultAPIKeyEnv == "" { + t.Fatal("OpenLLDefaultAPIKeyEnv should not be empty") + } +} diff --git a/internal/config/model.go b/internal/config/model.go index 3273697a..c0b57f0b 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -108,7 +108,7 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { c.CurrentModel = defaults.CurrentModel } } - if selected, err := c.SelectedProviderConfig(); err == nil && !containsModelID(selected.SupportedModels(), c.CurrentModel) { + if selected, err := c.SelectedProviderConfig(); err == nil && !ContainsModelID(selected.SupportedModels(), c.CurrentModel) { c.CurrentModel = selected.Model } if strings.TrimSpace(c.Workdir) == "" { @@ -168,7 +168,7 @@ func (c *Config) Validate() error { if strings.TrimSpace(selected.Model) == "" { return fmt.Errorf("config: selected provider %q has empty model", selected.Name) } - if !containsModelID(selected.SupportedModels(), c.CurrentModel) { + if !ContainsModelID(selected.SupportedModels(), c.CurrentModel) { return fmt.Errorf("config: current_model %q is not supported by provider %q", c.CurrentModel, selected.Name) } if err := c.Tools.Validate(); err != nil { @@ -220,7 +220,7 @@ func (p ProviderConfig) Validate() error { if len(models) == 0 { return fmt.Errorf("provider %q models is empty", p.Name) } - if !containsModelID(models, p.Model) { + if !ContainsModelID(models, p.Model) { return fmt.Errorf("provider %q default model %q is not in models", p.Name, strings.TrimSpace(p.Model)) } } @@ -492,7 +492,7 @@ func normalizeModelIDs(models []string) []string { return normalized } -func containsModelID(models []string, model string) bool { +func ContainsModelID(models []string, model string) bool { target := strings.ToLower(strings.TrimSpace(model)) if target == "" { return false diff --git a/internal/provider/builtin/builtin.go b/internal/provider/builtin/builtin.go index 3d4fcb61..a2c128b8 100644 --- a/internal/provider/builtin/builtin.go +++ b/internal/provider/builtin/builtin.go @@ -5,21 +5,18 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" - "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" - "neo-code/internal/provider/openll" ) +// DefaultConfig 返回默认配置,包含所有内置 provider。 func DefaultConfig() *config.Config { cfg := config.Default() - defaultProvider := openai.BuiltinConfig() - cfg.Providers = []config.ProviderConfig{ - defaultProvider, - gemini.BuiltinConfig(), - openll.BuiltinConfig(), - } - cfg.SelectedProvider = defaultProvider.Name - cfg.CurrentModel = defaultProvider.Model + providers := config.DefaultProviders() + + cfg.Providers = providers + cfg.SelectedProvider = providers[0].Name + cfg.CurrentModel = providers[0].Model + return cfg } diff --git a/internal/provider/builtin/builtin_test.go b/internal/provider/builtin/builtin_test.go index c047c335..67496213 100644 --- a/internal/provider/builtin/builtin_test.go +++ b/internal/provider/builtin/builtin_test.go @@ -3,9 +3,8 @@ package builtin import ( "testing" - "neo-code/internal/provider/gemini" - "neo-code/internal/provider/openai" - "neo-code/internal/provider/openll" + "neo-code/internal/config" + "neo-code/internal/provider" ) func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { @@ -15,19 +14,72 @@ func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { if len(cfg.Providers) != 3 { t.Fatalf("expected 3 builtin providers, got %d", len(cfg.Providers)) } - if cfg.Providers[0].Name != openai.Name { - t.Fatalf("expected first provider %q, got %q", openai.Name, cfg.Providers[0].Name) + if cfg.Providers[0].Name != config.OpenAIName { + t.Fatalf("expected first provider %q, got %q", config.OpenAIName, cfg.Providers[0].Name) } - if cfg.Providers[1].Name != gemini.Name { - t.Fatalf("expected second provider %q, got %q", gemini.Name, cfg.Providers[1].Name) + if cfg.Providers[1].Name != config.GeminiName { + t.Fatalf("expected second provider %q, got %q", config.GeminiName, cfg.Providers[1].Name) } - if cfg.Providers[2].Name != openll.Name { - t.Fatalf("expected third provider %q, got %q", openll.Name, cfg.Providers[2].Name) + if cfg.Providers[2].Name != config.OpenLLName { + t.Fatalf("expected third provider %q, got %q", config.OpenLLName, cfg.Providers[2].Name) } - if cfg.SelectedProvider != openai.Name { - t.Fatalf("expected selected provider %q, got %q", openai.Name, cfg.SelectedProvider) + if cfg.SelectedProvider != config.OpenAIName { + t.Fatalf("expected selected provider %q, got %q", config.OpenAIName, cfg.SelectedProvider) } - if cfg.CurrentModel != openai.DefaultModel { - t.Fatalf("expected current model %q, got %q", openai.DefaultModel, cfg.CurrentModel) + if cfg.CurrentModel != config.OpenAIDefaultModel { + t.Fatalf("expected current model %q, got %q", config.OpenAIDefaultModel, cfg.CurrentModel) + } +} + +func TestNewRegistry(t *testing.T) { + t.Parallel() + + registry, err := NewRegistry() + if err != nil { + t.Fatalf("NewRegistry() error = %v", err) + } + if registry == nil { + t.Fatal("expected non-nil registry") + } +} + +func TestRegister(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + registry := provider.NewRegistry() + err := Register(registry) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + }) + + t.Run("nil registry", func(t *testing.T) { + t.Parallel() + err := Register(nil) + if err == nil { + t.Fatal("expected error for nil registry") + } + }) +} + +func TestDefaultConfigValidates(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + cfg.ApplyDefaultsFrom(*cfg) + if err := cfg.Validate(); err != nil { + t.Fatalf("expected default config to validate, got %v", err) + } +} + +func TestDefaultConfigWorkdirIsAbsolute(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + cfg.ApplyDefaultsFrom(*cfg) + if cfg.Workdir == "" { + t.Fatal("expected workdir to be set") } } diff --git a/internal/provider/gemini/gemini.go b/internal/provider/gemini/gemini.go deleted file mode 100644 index 0133e3fc..00000000 --- a/internal/provider/gemini/gemini.go +++ /dev/null @@ -1,31 +0,0 @@ -package gemini - -import ( - "neo-code/internal/config" - "neo-code/internal/provider/openai" -) - -const ( - Name = "gemini" - DriverName = openai.DriverName - DefaultBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai" - DefaultModel = "gemini-2.5-flash" - DefaultAPIKeyEnv = "GEMINI_API_KEY" -) - -var builtinModels = []string{ - DefaultModel, - "gemini-2.5-pro", - "gemini-2.0-flash", -} - -func BuiltinConfig() config.ProviderConfig { - return config.ProviderConfig{ - Name: Name, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: DefaultModel, - Models: append([]string(nil), builtinModels...), - APIKeyEnv: DefaultAPIKeyEnv, - } -} diff --git a/internal/provider/gemini/gemini_test.go b/internal/provider/gemini/gemini_test.go deleted file mode 100644 index afab85a0..00000000 --- a/internal/provider/gemini/gemini_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package gemini - -import ( - "testing" - - "neo-code/internal/provider/openai" -) - -func TestBuiltinConfigUsesOpenAICompatibleDriver(t *testing.T) { - t.Parallel() - - cfg := BuiltinConfig() - if cfg.Name != Name { - t.Fatalf("expected provider name %q, got %q", Name, cfg.Name) - } - if cfg.Driver != openai.DriverName { - t.Fatalf("expected driver %q, got %q", openai.DriverName, cfg.Driver) - } - if cfg.BaseURL != DefaultBaseURL { - t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) - } - if cfg.Model != DefaultModel { - t.Fatalf("expected default model %q, got %q", DefaultModel, cfg.Model) - } - if cfg.APIKeyEnv != DefaultAPIKeyEnv { - t.Fatalf("expected API key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) - } - if len(cfg.Models) < 2 { - t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) - } -} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index 4e255880..f4056bc2 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -23,35 +23,12 @@ type Provider struct { client *http.Client } -const ( - Name = "openai" - DriverName = "openai" - DefaultBaseURL = "https://api.openai.com/v1" - DefaultModel = "gpt-4.1" - DefaultAPIKeyEnv = "OPENAI_API_KEY" -) - -var builtinModels = []string{ - DefaultModel, - "gpt-4o", - "gpt-5.4", - "gpt-5.3-codex", -} - -func BuiltinConfig() config.ProviderConfig { - return config.ProviderConfig{ - Name: Name, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: DefaultModel, - Models: append([]string(nil), builtinModels...), - APIKeyEnv: DefaultAPIKeyEnv, - } -} +const DriverName = "openai" +// Driver 返回 OpenAI 协议驱动的定义。 func Driver() domain.DriverDefinition { return domain.DriverDefinition{ - Name: Name, + Name: DriverName, Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (domain.Provider, error) { return New(cfg) }, diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 6843f704..89c16c0c 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -14,10 +14,10 @@ import ( func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig { if strings.TrimSpace(baseURL) == "" { - baseURL = DefaultBaseURL + baseURL = config.OpenAIDefaultBaseURL } if strings.TrimSpace(model) == "" { - model = DefaultModel + model = config.OpenAIDefaultModel } return config.ResolvedProviderConfig{ @@ -26,7 +26,7 @@ func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig Driver: DriverName, BaseURL: baseURL, Model: model, - APIKeyEnv: DefaultAPIKeyEnv, + APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, }, APIKey: "test-key", } @@ -110,26 +110,8 @@ func TestMergeToolCallDeltas(t *testing.T) { } } -func TestBuiltinConfigIncludesProviderModels(t *testing.T) { - t.Parallel() - - cfg := BuiltinConfig() - if cfg.Name != DriverName { - t.Fatalf("expected provider name %q, got %q", DriverName, cfg.Name) - } - if cfg.BaseURL != DefaultBaseURL { - t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) - } - if len(cfg.Models) < 3 { - t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) - } - if !containsString(cfg.Models, "gpt-5.4") { - t.Fatalf("expected builtin models to include gpt-5.4, got %+v", cfg.Models) - } -} - func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { - t.Setenv(DefaultAPIKeyEnv, "test-key") + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { @@ -299,7 +281,7 @@ func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { } func TestProviderChatHTTPErrorResponses(t *testing.T) { - t.Setenv(DefaultAPIKeyEnv, "test-key") + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") tests := []struct { name string @@ -332,14 +314,14 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { })) defer server.Close() - provider, err := New(resolvedConfig(server.URL, DefaultModel)) + provider, err := New(resolvedConfig(server.URL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } provider.client = server.Client() _, err = provider.Chat(context.Background(), domain.ChatRequest{ - Model: DefaultModel, + Model: config.OpenAIDefaultModel, }, make(chan domain.StreamEvent, 1)) if err == nil || !strings.Contains(err.Error(), tt.expectErr) { t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) @@ -351,7 +333,7 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Parallel() - provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + provider, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -386,8 +368,8 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Fatalf("buildRequest() error = %v", err) } - if payload.Model != DefaultModel { - t.Fatalf("expected default model %q, got %q", DefaultModel, payload.Model) + if payload.Model != config.OpenAIDefaultModel { + t.Fatalf("expected default model %q, got %q", config.OpenAIDefaultModel, payload.Model) } if !payload.Stream { t.Fatalf("expected stream=true") @@ -412,7 +394,7 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { func TestParseErrorAndEmitTextDelta(t *testing.T) { t.Parallel() - provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + provider, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -470,7 +452,7 @@ func TestParseErrorAndEmitTextDelta(t *testing.T) { func TestProviderConsumeStreamRejectsDirtyJSON(t *testing.T) { t.Parallel() - provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + provider, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -689,7 +671,7 @@ func TestMergeToolCallDeltasEdgeCases(t *testing.T) { } func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { - t.Setenv(DefaultAPIKeyEnv, "test-key") + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") @@ -717,7 +699,7 @@ func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { })) defer server.Close() - provider, err := New(resolvedConfig(server.URL, DefaultModel)) + provider, err := New(resolvedConfig(server.URL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -725,7 +707,7 @@ func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { events := make(chan domain.StreamEvent, 8) _, err = provider.Chat(context.Background(), domain.ChatRequest{ - Model: DefaultModel, + Model: config.OpenAIDefaultModel, Messages: []domain.Message{{Role: "user", Content: "edit"}}, Tools: []domain.ToolSpec{ {Name: "filesystem_edit", Description: "edit", Schema: map[string]any{"type": "object"}}, diff --git a/internal/provider/openll/openll.go b/internal/provider/openll/openll.go deleted file mode 100644 index ac99cdd9..00000000 --- a/internal/provider/openll/openll.go +++ /dev/null @@ -1,30 +0,0 @@ -package openll - -import ( - "neo-code/internal/config" -) - -const ( - Name = "openll" - DriverName = "openai" - DefaultBaseURL = "https://www.openll.top/v1" - DefaultModel = "gpt-5.4" - DefaultAPIKeyEnv = "AI_API_KEY" -) - -var builtinModels = []string{ - DefaultModel, - "gpt-5.3-codex", - "gpt-5.3-turbo", -} - -func BuiltinConfig() config.ProviderConfig { - return config.ProviderConfig{ - Name: Name, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: DefaultModel, - Models: append([]string(nil), builtinModels...), - APIKeyEnv: DefaultAPIKeyEnv, - } -} diff --git a/internal/provider/openll/openll_test.go b/internal/provider/openll/openll_test.go deleted file mode 100644 index 379b241f..00000000 --- a/internal/provider/openll/openll_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package openll - -import ( - "testing" - - "neo-code/internal/provider/openai" -) - -func TestBuiltinConfigUsesOpenAICompatibleDriver(t *testing.T) { - t.Parallel() - - cfg := BuiltinConfig() - if cfg.Name != Name { - t.Fatalf("expected provider name %q, got %q", Name, cfg.Name) - } - if cfg.Driver != openai.DriverName { - t.Fatalf("expected driver %q, got %q", openai.DriverName, cfg.Driver) - } - if cfg.BaseURL != DefaultBaseURL { - t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) - } - if cfg.Model != DefaultModel { - t.Fatalf("expected default model %q, got %q", DefaultModel, cfg.Model) - } - if cfg.APIKeyEnv != DefaultAPIKeyEnv { - t.Fatalf("expected API key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) - } - if len(cfg.Models) < 3 { - t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) - } - if !containsString(cfg.Models, "gpt-5.4") { - t.Fatalf("expected builtin models to include gpt-5.4, got %+v", cfg.Models) - } -} - -func containsString(items []string, target string) bool { - for _, item := range items { - if item == target { - return true - } - } - return false -} diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 6f209301..ad9b57d8 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -8,9 +8,7 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" "neo-code/internal/provider/builtin" - "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" - "neo-code/internal/provider/openll" ) type stubProvider struct{} @@ -62,9 +60,9 @@ func TestRegistryBuildsRegisteredDriverCaseInsensitively(t *testing.T) { ProviderConfig: config.ProviderConfig{ Name: "openai-main", Driver: "OPENAI", - BaseURL: openai.DefaultBaseURL, - Model: openai.DefaultModel, - APIKeyEnv: openai.DefaultAPIKeyEnv, + BaseURL: config.OpenAIDefaultBaseURL, + Model: config.OpenAIDefaultModel, + APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, }, APIKey: "test-key", }) @@ -124,9 +122,9 @@ func TestServiceListProvidersUsesConfiguredMetadata(t *testing.T) { t.Fatalf("ListProviders() error = %v", err) } expectedModels := map[string]int{ - openai.Name: len(openai.BuiltinConfig().Models), - gemini.Name: len(gemini.BuiltinConfig().Models), - openll.Name: len(openll.BuiltinConfig().Models), + config.OpenAIName: len(config.OpenAIProvider().Models), + config.GeminiName: len(config.GeminiProvider().Models), + config.OpenLLName: len(config.OpenLLProvider().Models), } if len(items) != len(expectedModels) { t.Fatalf("expected only supported providers, got %d", len(items)) @@ -294,9 +292,9 @@ func TestServiceBuildDelegatesToRegistry(t *testing.T) { ProviderConfig: config.ProviderConfig{ Name: "openai-main", Driver: "openai", - BaseURL: openai.DefaultBaseURL, - Model: openai.DefaultModel, - APIKeyEnv: openai.DefaultAPIKeyEnv, + BaseURL: config.OpenAIDefaultBaseURL, + Model: config.OpenAIDefaultModel, + APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, }, APIKey: "test-key", } diff --git a/internal/provider/service.go b/internal/provider/service.go index 8c2f983c..acbac2db 100644 --- a/internal/provider/service.go +++ b/internal/provider/service.go @@ -110,7 +110,7 @@ func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (Provider return err } - if !containsModel(selected.SupportedModels(), modelID) { + if !config.ContainsModelID(selected.SupportedModels(), modelID) { return ErrModelNotFound } @@ -125,16 +125,6 @@ func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (Provider return selection, nil } -func containsModel(models []string, modelID string) bool { - target := normalizeKey(modelID) - for _, model := range models { - if normalizeKey(model) == target { - return true - } - } - return false -} - func selectionFromConfig(cfg config.Config) ProviderSelection { return ProviderSelection{ ProviderID: cfg.SelectedProvider, @@ -143,7 +133,7 @@ func selectionFromConfig(cfg config.Config) ProviderSelection { } func selectModel(currentModel string, models []string, fallback string) string { - if containsModel(models, currentModel) { + if config.ContainsModelID(models, currentModel) { return strings.TrimSpace(currentModel) } return strings.TrimSpace(fallback) diff --git a/internal/provider/service_test.go b/internal/provider/service_test.go new file mode 100644 index 00000000..248dd31f --- /dev/null +++ b/internal/provider/service_test.go @@ -0,0 +1,472 @@ +package provider + +import ( + "context" + "testing" + + "neo-code/internal/config" +) + +func TestNewService(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + registry := NewRegistry() + service := NewService(manager, registry) + if service == nil { + t.Fatal("expected non-nil service") + } + }) + + t.Run("nil manager", func(t *testing.T) { + t.Parallel() + registry := NewRegistry() + service := NewService(nil, registry) + if service == nil { + t.Fatal("expected non-nil service even with nil manager") + } + }) + + t.Run("nil registry", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + service := NewService(manager, nil) + if service == nil { + t.Fatal("expected non-nil service even with nil registry") + } + }) +} + +func TestServiceListProviders(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + registry := newServiceTestRegistry(t) + service := NewService(manager, registry) + + items, err := service.ListProviders(context.Background()) + if err != nil { + t.Fatalf("ListProviders() error = %v", err) + } + if len(items) == 0 { + t.Fatal("expected at least one provider") + } + if items[0].ID != testProviderName { + t.Fatalf("expected first provider ID %q, got %q", testProviderName, items[0].ID) + } + }) + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + _, err := service.ListProviders(context.Background()) + if err == nil { + t.Fatal("expected error for nil service") + } + }) + + t.Run("nil manager", func(t *testing.T) { + t.Parallel() + service := &Service{} + _, err := service.ListProviders(context.Background()) + if err == nil { + t.Fatal("expected error for nil manager") + } + }) + + t.Run("nil registry", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + service := &Service{manager: manager} + _, err := service.ListProviders(context.Background()) + if err == nil { + t.Fatal("expected error for nil registry") + } + }) + + t.Run("canceled context", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + registry := NewRegistry() + service := NewService(manager, registry) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := service.ListProviders(ctx) + if err == nil { + t.Fatal("expected error for canceled context") + } + }) +} + +func TestServiceSelectProvider(t *testing.T) { + t.Run("success", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + registry := newServiceTestRegistry(t) + service := NewService(manager, registry) + + selection, err := service.SelectProvider(context.Background(), testProviderName) + if err != nil { + t.Fatalf("SelectProvider() error = %v", err) + } + if selection.ProviderID != testProviderName { + t.Fatalf("expected provider ID %q, got %q", testProviderName, selection.ProviderID) + } + }) + + t.Run("provider not found", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + registry := NewRegistry() + service := NewService(manager, registry) + + _, err := service.SelectProvider(context.Background(), "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent provider") + } + }) + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + _, err := service.SelectProvider(context.Background(), testProviderName) + if err == nil { + t.Fatal("expected error for nil service") + } + }) +} + +func TestServiceListModels(t *testing.T) { + t.Run("success", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + registry := NewRegistry() + service := NewService(manager, registry) + + models, err := service.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(models) == 0 { + t.Fatal("expected at least one model") + } + }) + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + _, err := service.ListModels(context.Background()) + if err == nil { + t.Fatal("expected error for nil service") + } + }) + + t.Run("canceled context", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + registry := NewRegistry() + service := NewService(manager, registry) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := service.ListModels(ctx) + if err == nil { + t.Fatal("expected error for canceled context") + } + }) +} + +func TestServiceSetCurrentModel(t *testing.T) { + t.Run("success", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + registry := NewRegistry() + service := NewService(manager, registry) + + newModel := "gpt-4o" + selection, err := service.SetCurrentModel(context.Background(), newModel) + if err != nil { + t.Fatalf("SetCurrentModel() error = %v", err) + } + if selection.ModelID != newModel { + t.Fatalf("expected model ID %q, got %q", newModel, selection.ModelID) + } + }) + + t.Run("empty model ID", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + registry := NewRegistry() + service := NewService(manager, registry) + + _, err := service.SetCurrentModel(context.Background(), "") + if err == nil { + t.Fatal("expected error for empty model ID") + } + }) + + t.Run("model not found", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + registry := NewRegistry() + service := NewService(manager, registry) + + _, err := service.SetCurrentModel(context.Background(), "nonexistent-model") + if err == nil { + t.Fatal("expected error for nonexistent model") + } + }) + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + _, err := service.SetCurrentModel(context.Background(), "model") + if err == nil { + t.Fatal("expected error for nil service") + } + }) +} + +func TestServiceBuild(t *testing.T) { + t.Parallel() + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + _, err := service.Build(context.Background(), config.ResolvedProviderConfig{}) + if err == nil { + t.Fatal("expected error for nil service") + } + }) + + t.Run("nil manager", func(t *testing.T) { + t.Parallel() + service := &Service{} + _, err := service.Build(context.Background(), config.ResolvedProviderConfig{}) + if err == nil { + t.Fatal("expected error for nil manager") + } + }) + + t.Run("nil registry", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + service := &Service{manager: manager} + _, err := service.Build(context.Background(), config.ResolvedProviderConfig{}) + if err == nil { + t.Fatal("expected error for nil registry") + } + }) +} + +func TestServiceValidate(t *testing.T) { + t.Parallel() + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + err := service.validate() + if err == nil { + t.Fatal("expected error for nil service") + } + }) + + t.Run("nil manager", func(t *testing.T) { + t.Parallel() + service := &Service{} + err := service.validate() + if err == nil { + t.Fatal("expected error for nil manager") + } + }) + + t.Run("nil registry", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + service := &Service{manager: manager} + err := service.validate() + if err == nil { + t.Fatal("expected error for nil registry") + } + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + registry := NewRegistry() + service := NewService(manager, registry) + err := service.validate() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) +} + +func TestSelectModelHelper(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + currentModel string + models []string + fallback string + expected string + }{ + { + name: "current model in list", + currentModel: "gpt-4o", + models: []string{"gpt-4.1", "gpt-4o", "gpt-5.4"}, + fallback: "gpt-4.1", + expected: "gpt-4o", + }, + { + name: "current model not in list", + currentModel: "unknown-model", + models: []string{"gpt-4.1", "gpt-4o", "gpt-5.4"}, + fallback: "gpt-4.1", + expected: "gpt-4.1", + }, + { + name: "empty current model", + currentModel: "", + models: []string{"gpt-4.1", "gpt-4o", "gpt-5.4"}, + fallback: "gpt-4.1", + expected: "gpt-4.1", + }, + { + name: "current model with whitespace", + currentModel: " gpt-4o ", + models: []string{"gpt-4.1", "gpt-4o", "gpt-5.4"}, + fallback: "gpt-4.1", + expected: "gpt-4o", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := selectModel(tt.currentModel, tt.models, tt.fallback) + if result != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestModelDescriptorsHelper(t *testing.T) { + t.Parallel() + + t.Run("empty models", func(t *testing.T) { + t.Parallel() + result := modelDescriptors(nil) + if result != nil { + t.Fatalf("expected nil for empty models, got %v", result) + } + }) + + t.Run("non-empty models", func(t *testing.T) { + t.Parallel() + models := []string{"gpt-4.1", "gpt-4o", "gpt-5.4"} + result := modelDescriptors(models) + if len(result) != 3 { + t.Fatalf("expected 3 descriptors, got %d", len(result)) + } + for i, desc := range result { + if desc.ID != models[i] { + t.Fatalf("expected ID %q, got %q", models[i], desc.ID) + } + if desc.Name != models[i] { + t.Fatalf("expected name %q, got %q", models[i], desc.Name) + } + } + }) + + t.Run("models with empty strings", func(t *testing.T) { + t.Parallel() + models := []string{"gpt-4.1", "", "gpt-4o", " ", "gpt-5.4"} + result := modelDescriptors(models) + if len(result) != 3 { + t.Fatalf("expected 3 descriptors after filtering empty strings, got %d", len(result)) + } + }) +} + +func testDefaultConfig() *config.Config { + cfg := config.Default() + providers := config.DefaultProviders() + + cfg.Providers = providers + cfg.SelectedProvider = providers[0].Name + cfg.CurrentModel = providers[0].Model + + return cfg +} + +func newServiceTestRegistry(t *testing.T) *Registry { + t.Helper() + + registry := NewRegistry() + if err := registry.Register(DriverDefinition{ + Name: testProviderName, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) { + return serviceTestProvider{}, nil + }, + }); err != nil { + t.Fatalf("register test driver: %v", err) + } + return registry +} + +type serviceTestProvider struct{} + +func (serviceTestProvider) Chat(ctx context.Context, req ChatRequest, events chan<- StreamEvent) (ChatResponse, error) { + return ChatResponse{}, nil +} + +const ( + testProviderName = "openai" + testAPIKeyEnv = "OPENAI_API_KEY" +) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 193e7295..d5ebeab6 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -120,7 +120,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { go s.forwardProviderEvents(ctx, input.RunID, session.ID, streamEvents, streamDone) resp, err := modelProvider.Chat(ctx, provider.ChatRequest{ - Model: cfg.CurrentModel, + Model: resolvedProvider.Model, SystemPrompt: s.systemPrompt(), Messages: s.trimMessages(session.Messages), Tools: s.toolRegistry.GetSpecs(), diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index a9f581fc..254a254c 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -11,7 +11,6 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" "neo-code/internal/provider/builtin" - "neo-code/internal/provider/openai" "neo-code/internal/tools" ) @@ -1003,8 +1002,8 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { func newRuntimeConfigManager(t *testing.T) *config.Manager { t.Helper() - restoreRuntimeEnv(t, openai.DefaultAPIKeyEnv) - if err := os.Setenv(openai.DefaultAPIKeyEnv, "test-key"); err != nil { + restoreRuntimeEnv(t, config.OpenAIDefaultAPIKeyEnv) + if err := os.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key"); err != nil { t.Fatalf("set env: %v", err) } manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 5a62ad72..9ffea7ba 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -14,7 +14,6 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" "neo-code/internal/provider/builtin" - provideropenai "neo-code/internal/provider/openai" agentruntime "neo-code/internal/runtime" "neo-code/internal/tools" ) @@ -317,7 +316,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { app.openModelPicker() app.closePicker() - app.selectCurrentModel(provideropenai.DefaultModel) + app.selectCurrentModel(config.OpenAIDefaultModel) app.appendAssistantChunk("hello") app.appendAssistantChunk(" world") if !app.lastAssistantMatches("hello world") { @@ -498,7 +497,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { } manager := newTestConfigManager(t) - msg := runModelSelection(newTestProviderService(t, manager), provideropenai.DefaultModel)() + msg := runModelSelection(newTestProviderService(t, manager), config.OpenAIDefaultModel)() if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil { t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) } @@ -808,11 +807,11 @@ func TestAppUpdateProviderPickerEnterAppliesSelection(t *testing.T) { if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.Providers = append(cfg.Providers, config.ProviderConfig{ Name: "openai-alt", - Driver: provideropenai.DriverName, - BaseURL: provideropenai.DefaultBaseURL, + Driver: "openai", + BaseURL: config.OpenAIDefaultBaseURL, Model: "gpt-4o", Models: []string{"gpt-4o"}, - APIKeyEnv: provideropenai.DefaultAPIKeyEnv, + APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, }) cfg.CurrentModel = "unsupported-current" return nil From 78bd2d0ac8dbeea46f1198471be63b6f50f17d8a Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Mon, 30 Mar 2026 23:49:07 +0800 Subject: [PATCH 13/20] =?UTF-8?q?test:=20=E8=A1=A5=E9=BD=90=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E4=B8=8E=E5=86=85=E7=BD=AE=20provider=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../provider/builtin/builtin_more_test.go | 88 +++++++++ internal/tools/format_test.go | 168 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 internal/provider/builtin/builtin_more_test.go create mode 100644 internal/tools/format_test.go diff --git a/internal/provider/builtin/builtin_more_test.go b/internal/provider/builtin/builtin_more_test.go new file mode 100644 index 00000000..afd82714 --- /dev/null +++ b/internal/provider/builtin/builtin_more_test.go @@ -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) + } +} diff --git a/internal/tools/format_test.go b/internal/tools/format_test.go new file mode 100644 index 00000000..a2a19a6b --- /dev/null +++ b/internal/tools/format_test.go @@ -0,0 +1,168 @@ +package tools + +import ( + "errors" + "strings" + "testing" +) + +func TestApplyOutputLimit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result ToolResult + limit int + wantContent string + wantTruncated bool + wantMetadataNil bool + wantPreserveValue any + }{ + { + name: "no limit keeps content", + result: ToolResult{ + Content: "hello", + }, + limit: 0, + wantContent: "hello", + wantTruncated: false, + wantMetadataNil: true, + }, + { + name: "content within limit keeps metadata", + result: ToolResult{ + Content: "hello", + Metadata: map[string]any{"path": "a.txt"}, + }, + limit: 10, + wantContent: "hello", + wantTruncated: false, + wantMetadataNil: false, + wantPreserveValue: "a.txt", + }, + { + name: "content over limit truncates and marks metadata", + result: ToolResult{ + Content: "hello world", + }, + limit: 5, + wantContent: "hello" + truncatedSuffix, + wantTruncated: true, + wantMetadataNil: false, + }, + { + name: "existing truncated true is preserved", + result: ToolResult{ + Content: "hello world", + Metadata: map[string]any{"truncated": true}, + }, + limit: 5, + wantContent: "hello" + truncatedSuffix, + wantTruncated: true, + wantMetadataNil: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := ApplyOutputLimit(tt.result, tt.limit) + if got.Content != tt.wantContent { + t.Fatalf("expected content %q, got %q", tt.wantContent, got.Content) + } + + if tt.wantMetadataNil { + if got.Metadata != nil { + t.Fatalf("expected nil metadata, got %#v", got.Metadata) + } + return + } + + if got.Metadata == nil { + t.Fatal("expected metadata to be initialized") + } + if truncated, _ := got.Metadata["truncated"].(bool); truncated != tt.wantTruncated { + t.Fatalf("expected truncated=%v, got %#v", tt.wantTruncated, got.Metadata["truncated"]) + } + if tt.wantPreserveValue != nil && got.Metadata["path"] != tt.wantPreserveValue { + t.Fatalf("expected path metadata %v, got %v", tt.wantPreserveValue, got.Metadata["path"]) + } + }) + } +} + +func TestFormatHelpers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + toolName string + reason string + details string + err error + wantReason string + wantBody []string + }{ + { + name: "format trims fields", + toolName: " bash ", + reason: " failed ", + details: " bad input ", + err: errors.New("bash: failed"), + wantReason: "failed", + wantBody: []string{"tool error", "tool: bash", "reason: failed", "details: bad input"}, + }, + { + name: "normalize without tool prefix keeps message", + toolName: "webfetch", + reason: "unsupported content type", + details: "", + err: errors.New("network unavailable"), + wantReason: "network unavailable", + wantBody: []string{"tool error", "tool: webfetch", "reason: unsupported content type"}, + }, + { + name: "empty fields collapse cleanly", + toolName: "", + reason: "", + details: "", + err: nil, + wantReason: "", + wantBody: []string{"tool error"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := NormalizeErrorReason(tt.toolName, tt.err); got != tt.wantReason { + t.Fatalf("expected reason %q, got %q", tt.wantReason, got) + } + + body := FormatError(tt.toolName, tt.reason, tt.details) + for _, fragment := range tt.wantBody { + if !strings.Contains(body, fragment) { + t.Fatalf("expected body to contain %q, got %q", fragment, body) + } + } + + result := NewErrorResult(tt.toolName, tt.reason, tt.details, map[string]any{"k": "v"}) + if !result.IsError { + t.Fatal("expected error result") + } + if result.Name != tt.toolName { + t.Fatalf("expected name %q, got %q", tt.toolName, result.Name) + } + if result.Metadata["k"] != "v" { + t.Fatalf("expected metadata to be preserved, got %#v", result.Metadata) + } + if result.Content != body { + t.Fatalf("expected content %q, got %q", body, result.Content) + } + }) + } +} From 46b936483aa0c417d02b9d5c8be6d7e7fc194532 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Tue, 31 Mar 2026 00:25:08 +0800 Subject: [PATCH 14/20] =?UTF-8?q?test:=20=E8=A1=A5=E9=BD=90=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20patch=20=E8=A6=86=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/bash/tool_test.go | 104 ++++++++++++++++++ internal/tools/filesystem/edit_test.go | 74 +++++++++++++ internal/tools/filesystem/glob_test.go | 75 +++++++++++++ internal/tools/filesystem/grep_test.go | 75 +++++++++++++ internal/tools/filesystem/read_file_test.go | 71 ++++++++++++ .../tools/filesystem/test_helpers_test.go | 10 ++ internal/tools/filesystem/write_file_test.go | 24 ++++ internal/tools/format_test.go | 20 ++++ 8 files changed, 453 insertions(+) diff --git a/internal/tools/bash/tool_test.go b/internal/tools/bash/tool_test.go index 90fd6297..ad3a0402 100644 --- a/internal/tools/bash/tool_test.go +++ b/internal/tools/bash/tool_test.go @@ -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'" @@ -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" diff --git a/internal/tools/filesystem/edit_test.go b/internal/tools/filesystem/edit_test.go index dac4507d..d8c499e4 100644 --- a/internal/tools/filesystem/edit_test.go +++ b/internal/tools/filesystem/edit_test.go @@ -142,3 +142,77 @@ func TestEditToolSearchStringNotFound(t *testing.T) { t.Fatalf("expected search_string not found error, got %v", execErr) } } + +func TestEditToolErrorFormattingAndContext(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + mustWriteFile(t, filepath.Join(workspace, "main.go"), "package main\n") + + tool := NewEdit(workspace) + tests := []struct { + name string + ctx func() context.Context + arguments []byte + expectErr string + expectContent []string + }{ + { + name: "invalid json arguments", + ctx: context.Background, + arguments: []byte(`{invalid`), + expectErr: "invalid character", + expectContent: []string{"tool error", "tool: filesystem_edit", "reason: invalid arguments"}, + }, + { + name: "empty search string", + ctx: context.Background, + arguments: mustMarshalFSArgs(t, map[string]string{ + "path": "main.go", + "search_string": "", + "replace_string": "new", + }), + expectErr: "search_string is required", + expectContent: []string{"tool error", "tool: filesystem_edit", "reason: search_string is required"}, + }, + { + name: "canceled context", + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + arguments: mustMarshalFSArgs(t, map[string]string{ + "path": "main.go", + "search_string": "package", + "replace_string": "module", + }), + expectErr: "context canceled", + expectContent: []string{"tool error", "tool: filesystem_edit", "reason: context canceled"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := tool.Execute(tt.ctx(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: tt.arguments, + Workdir: workspace, + }) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.expectErr)) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + for _, fragment := range tt.expectContent { + if !strings.Contains(result.Content, fragment) { + t.Fatalf("expected content containing %q, got %q", fragment, result.Content) + } + } + if !result.IsError { + t.Fatalf("expected error result, got %#v", result) + } + }) + } +} diff --git a/internal/tools/filesystem/glob_test.go b/internal/tools/filesystem/glob_test.go index c4615ea9..5ac8b30d 100644 --- a/internal/tools/filesystem/glob_test.go +++ b/internal/tools/filesystem/glob_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "path/filepath" + "strconv" "strings" "testing" @@ -106,3 +107,77 @@ func TestBuildGlobMatcherRejectsInvalidUTF8(t *testing.T) { t.Fatalf("expected invalid utf-8 error, got %v", err) } } + +func TestGlobToolErrorFormattingAndTruncation(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + for i := 0; i < 1500; i++ { + mustWriteFile(t, filepath.Join(workspace, "many", strings.Repeat("a", 20), strings.Repeat("b", 20), "file"+strconv.Itoa(i)+".txt"), "x") + } + + tool := NewGlob(workspace) + tests := []struct { + name string + ctx func() context.Context + arguments []byte + expectErr string + expectContent []string + expectTruncate bool + }{ + { + name: "invalid json arguments", + ctx: context.Background, + arguments: []byte(`{invalid`), + expectErr: "invalid character", + expectContent: []string{"tool error", "tool: filesystem_glob", "reason: invalid arguments"}, + }, + { + name: "canceled context", + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + arguments: mustMarshalFSArgs(t, map[string]string{"pattern": "**/*.txt"}), + expectErr: "context canceled", + expectContent: []string{"tool error", "tool: filesystem_glob", "reason: context canceled"}, + }, + { + name: "long output is truncated", + ctx: context.Background, + arguments: mustMarshalFSArgs(t, map[string]string{"pattern": "**/*.txt"}), + expectContent: []string{"...[truncated]"}, + expectTruncate: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := tool.Execute(tt.ctx(), 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 containing %q, got %q", fragment, result.Content) + } + } + if truncated, _ := result.Metadata["truncated"].(bool); truncated != tt.expectTruncate { + t.Fatalf("expected truncated=%v, got %#v", tt.expectTruncate, result.Metadata["truncated"]) + } + }) + } +} diff --git a/internal/tools/filesystem/grep_test.go b/internal/tools/filesystem/grep_test.go index 45763037..3eec737f 100644 --- a/internal/tools/filesystem/grep_test.go +++ b/internal/tools/filesystem/grep_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "path/filepath" + "strconv" "strings" "testing" @@ -105,3 +106,77 @@ func TestGrepToolExecute(t *testing.T) { }) } } + +func TestGrepToolErrorFormattingAndTruncation(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + for i := 0; i < 180; i++ { + mustWriteFile(t, filepath.Join(workspace, "bulk", "file"+strconv.Itoa(i)+".txt"), "needle "+strings.Repeat("x", 500)+"\n") + } + + tool := NewGrep(workspace) + tests := []struct { + name string + ctx func() context.Context + arguments []byte + expectErr string + expectContent []string + expectTruncate bool + }{ + { + name: "invalid json arguments", + ctx: context.Background, + arguments: []byte(`{invalid`), + expectErr: "invalid character", + expectContent: []string{"tool error", "tool: filesystem_grep", "reason: invalid arguments"}, + }, + { + name: "canceled context", + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + arguments: mustMarshalFSArgs(t, map[string]any{"pattern": "needle"}), + expectErr: "context canceled", + expectContent: []string{"tool error", "tool: filesystem_grep", "reason: context canceled"}, + }, + { + name: "long result is truncated", + ctx: context.Background, + arguments: mustMarshalFSArgs(t, map[string]any{"pattern": "needle"}), + expectContent: []string{"...[truncated]"}, + expectTruncate: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := tool.Execute(tt.ctx(), 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 containing %q, got %q", fragment, result.Content) + } + } + if truncated, _ := result.Metadata["truncated"].(bool); truncated != tt.expectTruncate { + t.Fatalf("expected truncated=%v, got %#v", tt.expectTruncate, result.Metadata["truncated"]) + } + }) + } +} diff --git a/internal/tools/filesystem/read_file_test.go b/internal/tools/filesystem/read_file_test.go index b325ebbf..a124272a 100644 --- a/internal/tools/filesystem/read_file_test.go +++ b/internal/tools/filesystem/read_file_test.go @@ -100,3 +100,74 @@ func TestReadFileToolExecute(t *testing.T) { }) } } + +func TestReadFileToolErrorFormattingAndTruncation(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + largeContent := strings.Repeat("abcdefghij", 7000) + largePath := filepath.Join(workspace, "large.txt") + if err := os.WriteFile(largePath, []byte(largeContent), 0o644); err != nil { + t.Fatalf("write large file: %v", err) + } + + tool := New(workspace) + tests := []struct { + name string + arguments []byte + expectErr string + expectContent []string + expectTruncate bool + }{ + { + name: "invalid json arguments", + arguments: []byte(`{invalid`), + expectErr: "invalid character", + expectContent: []string{"tool error", "tool: filesystem_read_file", "reason: invalid arguments"}, + }, + { + name: "large file is truncated", + arguments: mustMarshalFSArgs(t, map[string]string{"path": "large.txt"}), + expectContent: []string{"...[truncated]"}, + expectTruncate: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + chunks := 0 + result, err := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: tt.arguments, + Workdir: workspace, + EmitChunk: func(chunk []byte) { + if len(chunk) > 0 { + chunks++ + } + }, + }) + + 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 containing %q, got %q", fragment, result.Content) + } + } + if truncated, _ := result.Metadata["truncated"].(bool); truncated != tt.expectTruncate { + t.Fatalf("expected truncated=%v, got %#v", tt.expectTruncate, result.Metadata["truncated"]) + } + if tt.expectTruncate && chunks == 0 { + t.Fatalf("expected chunk emitter to receive truncated content") + } + }) + } +} diff --git a/internal/tools/filesystem/test_helpers_test.go b/internal/tools/filesystem/test_helpers_test.go index dc96d2ca..d769bb1b 100644 --- a/internal/tools/filesystem/test_helpers_test.go +++ b/internal/tools/filesystem/test_helpers_test.go @@ -1,6 +1,7 @@ package filesystem import ( + "encoding/json" "os" "path/filepath" "testing" @@ -15,3 +16,12 @@ func mustWriteFile(t *testing.T, path string, content string) { t.Fatalf("write %s: %v", path, err) } } + +func mustMarshalFSArgs(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + return data +} diff --git a/internal/tools/filesystem/write_file_test.go b/internal/tools/filesystem/write_file_test.go index 775062a4..91debc3a 100644 --- a/internal/tools/filesystem/write_file_test.go +++ b/internal/tools/filesystem/write_file_test.go @@ -110,3 +110,27 @@ func TestWriteFileToolMetadataAndExecute(t *testing.T) { }) } } + +func TestWriteFileToolInvalidArgumentsFormatting(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + tool := NewWrite(workspace) + + result, err := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: []byte(`{invalid`), + Workdir: workspace, + }) + if err == nil || !strings.Contains(err.Error(), "invalid character") { + t.Fatalf("expected invalid json error, got %v", err) + } + for _, fragment := range []string{"tool error", "tool: filesystem_write_file", "reason: invalid arguments"} { + if !strings.Contains(result.Content, fragment) { + t.Fatalf("expected content containing %q, got %q", fragment, result.Content) + } + } + if !result.IsError { + t.Fatalf("expected error result, got %#v", result) + } +} diff --git a/internal/tools/format_test.go b/internal/tools/format_test.go index a2a19a6b..06f7428e 100644 --- a/internal/tools/format_test.go +++ b/internal/tools/format_test.go @@ -61,6 +61,17 @@ func TestApplyOutputLimit(t *testing.T) { wantTruncated: true, wantMetadataNil: false, }, + { + name: "existing truncated false is overwritten", + result: ToolResult{ + Content: "hello world", + Metadata: map[string]any{"truncated": false}, + }, + limit: 5, + wantContent: "hello" + truncatedSuffix, + wantTruncated: true, + wantMetadataNil: false, + }, } for _, tt := range tests { @@ -132,6 +143,15 @@ func TestFormatHelpers(t *testing.T) { wantReason: "", wantBody: []string{"tool error"}, }, + { + name: "tool name empty keeps raw reason", + toolName: "", + reason: "boom", + details: "", + err: errors.New("bash: failed"), + wantReason: "bash: failed", + wantBody: []string{"tool error", "reason: boom"}, + }, } for _, tt := range tests { From 84cc47ecd67773728bc7e061e96bff2adc3bf29e Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 31 Mar 2026 11:12:50 +0800 Subject: [PATCH 15/20] =?UTF-8?q?feat:=E5=AE=9E=E7=8E=B0=E4=BA=86=E5=A4=9A?= =?UTF-8?q?=E8=A1=8C=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/app.go | 29 ++++++--- internal/tui/keymap.go | 18 +++--- internal/tui/update.go | 43 ++++++++++++- internal/tui/update_test.go | 117 ++++++++++++++++++++++++++++++++++-- internal/tui/view.go | 13 +--- 5 files changed, 186 insertions(+), 34 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index fbfcd4bd..501fcfac 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -28,7 +28,7 @@ type App struct { providerPicker list.Model modelPicker list.Model transcript viewport.Model - input textinput.Model + input textarea.Model activeMessages []provider.Message focus panel width int @@ -63,14 +63,27 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime sessionList.FilterInput.Prompt = "Filter: " sessionList.FilterInput.Placeholder = "Type to search sessions" - input := textinput.New() + input := textarea.New() input.Placeholder = "Ask NeoCode to inspect, edit, or build. Type / to browse commands." - input.Prompt = "" input.CharLimit = 24000 - input.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) - input.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) - input.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.ShowLineNumbers = false + input.SetPromptFunc(composerPromptWidth, func(line int) string { + return "> " + }) + input.FocusedStyle.Base = lipgloss.NewStyle() + input.FocusedStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)).Bold(true) + input.FocusedStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) + input.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.FocusedStyle.CursorLine = lipgloss.NewStyle() + input.FocusedStyle.CursorLineNumber = lipgloss.NewStyle() + input.BlurredStyle.Base = lipgloss.NewStyle() + input.BlurredStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)).Bold(true) + input.BlurredStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) + input.BlurredStyle.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.BlurredStyle.CursorLine = lipgloss.NewStyle() + input.BlurredStyle.CursorLineNumber = lipgloss.NewStyle() input.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) + input.SetHeight(composerMinHeight) input.Focus() spin := spinner.New() @@ -130,5 +143,5 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime } func (a App) Init() tea.Cmd { - return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textinput.Blink, a.spinner.Tick) + return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textarea.Blink, a.spinner.Tick) } diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index 891be1a8..5e09da42 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -23,12 +23,12 @@ type keyMap struct { func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( - key.WithKeys("enter", "ctrl+s"), - key.WithHelp("Enter/Ctrl+S", "发送(输入框)"), + key.WithKeys("ctrl+j"), + key.WithHelp("Ctrl+J", "发送(输入框)"), ), CancelAgent: key.NewBinding( key.WithKeys("ctrl+w"), - key.WithHelp("Ctrl+w", "中止"), + key.WithHelp("Ctrl+W", "中止"), ), NewSession: key.NewBinding( key.WithKeys("ctrl+n"), @@ -60,27 +60,27 @@ func newKeyMap() keyMap { ), ScrollUp: key.NewBinding( key.WithKeys("up", "k"), - key.WithHelp("Up/k", "向上滚动"), + key.WithHelp("Up/K", "向上滚动"), ), ScrollDown: key.NewBinding( key.WithKeys("down", "j"), - key.WithHelp("Down/j", "向下滚动"), + key.WithHelp("Down/J", "向下滚动"), ), PageUp: key.NewBinding( key.WithKeys("pgup", "b"), - key.WithHelp("PgUp/b", "向上翻页"), + key.WithHelp("PgUp/B", "向上翻页"), ), PageDown: key.NewBinding( key.WithKeys("pgdown", "f"), - key.WithHelp("PgDn/f", "向下翻页"), + key.WithHelp("PgDn/F", "向下翻页"), ), Top: key.NewBinding( key.WithKeys("g", "home"), - key.WithHelp("g/Home", "跳到顶部"), + key.WithHelp("G/Home", "跳到顶部"), ), Bottom: key.NewBinding( key.WithKeys("G", "end"), - key.WithHelp("G/End", "跳到底部"), + key.WithHelp("Shift+G/End", "跳到底部"), ), } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 37692d10..45338223 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -26,6 +26,12 @@ type localCommandResultMsg struct { err error } +const ( + composerMinHeight = 1 + composerMaxHeight = 5 + composerPromptWidth = 2 +) + func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var spinCmd tea.Cmd @@ -143,6 +149,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.InputText = "" a.focus = panelInput a.applyFocus() + a.resizeComponents() a.rebuildTranscript() return a, tea.Batch(cmds...) } @@ -185,6 +192,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.input.Reset() a.state.InputText = "" + a.resizeComponents() switch strings.ToLower(input) { case slashCommandProviderPick: @@ -229,9 +237,14 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } + if typed.Type == tea.KeyEnter { + a.growComposerForNewline() + } + var cmd tea.Cmd a.input, cmd = a.input.Update(msg) a.state.InputText = a.input.Value() + a.normalizeComposerHeight() a.resizeComponents() cmds = append(cmds, cmd) return a, tea.Batch(cmds...) @@ -523,8 +536,8 @@ func (a *App) resizeComponents() { a.sessions.SetSize(sidebarBodyWidth, sidebarBodyHeight) menuHeight := a.commandMenuHeight(max(24, lay.rightWidth)) a.transcript.Width = max(24, lay.rightWidth) - promptInnerWidth := max(8, lay.rightWidth-a.styles.inputBoxFocused.GetHorizontalFrameSize()) - a.input.Width = max(4, promptInnerWidth-lipgloss.Width("> ")) + a.input.SetWidth(a.composerInnerWidth(lay.rightWidth)) + a.input.SetHeight(a.composerHeight()) promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) @@ -532,6 +545,32 @@ func (a *App) resizeComponents() { a.rebuildTranscript() } +func (a App) composerBoxWidth(totalWidth int) int { + return max(8, totalWidth-2) +} + +func (a App) composerInnerWidth(totalWidth int) int { + return max(4, a.composerBoxWidth(totalWidth)-a.styles.inputBoxFocused.GetHorizontalFrameSize()) +} + +func (a App) composerHeight() int { + return clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) +} + +func (a *App) growComposerForNewline() { + nextHeight := clamp(a.input.LineCount()+1, composerMinHeight, composerMaxHeight) + if nextHeight > a.input.Height() { + a.input.SetHeight(nextHeight) + } +} + +func (a *App) normalizeComposerHeight() { + targetHeight := clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) + if targetHeight != a.input.Height() { + a.input.SetHeight(targetHeight) + } +} + func (a *App) rebuildTranscript() { width := max(24, a.transcript.Width) if len(a.activeMessages) == 0 { diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 5a62ad72..88d5bd7b 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -93,7 +93,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { }, { name: "provider command opens picker and does not start runtime", - input: "/provider", + input: "/provider\n", assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { t.Helper() if len(runtime.runInputs) != 0 { @@ -138,7 +138,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { app.input.SetValue(tt.input) app.state.InputText = tt.input - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) app = model.(App) for _, msg := range collectTeaMessages(cmd) { @@ -372,6 +372,9 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if lipgloss.Width(app.renderPrompt(80)) != 80 { t.Fatalf("expected prompt width 80, got %d", lipgloss.Width(app.renderPrompt(80))) } + if !strings.Contains(app.renderHelp(80), "Ctrl+J") { + t.Fatalf("expected help to render the new send shortcut") + } sidebar := app.renderSidebar(26, 12) if lipgloss.Width(sidebar) != 26 || lipgloss.Height(sidebar) != 12 { t.Fatalf("expected sidebar to respect requested dimensions, got %dx%d", lipgloss.Width(sidebar), lipgloss.Height(sidebar)) @@ -385,6 +388,30 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if strings.TrimSpace(app.renderPrompt(80)) == "" { t.Fatalf("expected prompt to render a visible border") } + app.input.SetValue("one") + app.state.InputText = "one" + app.resizeComponents() + if app.input.Height() != 1 { + t.Fatalf("expected single-line composer height 1, got %d", app.input.Height()) + } + if strings.Count(app.renderPrompt(80), "> ") < 1 { + t.Fatalf("expected single-line prompt to render composer prefix") + } + app.input.SetValue("one\ntwo") + app.state.InputText = app.input.Value() + app.resizeComponents() + if app.input.Height() != 2 { + t.Fatalf("expected two-line composer height 2, got %d", app.input.Height()) + } + if strings.Count(app.renderPrompt(80), "> ") < 2 { + t.Fatalf("expected multi-line prompt to repeat composer prefix") + } + app.input.SetValue(strings.Join([]string{"1", "2", "3", "4", "5", "6"}, "\n")) + app.state.InputText = app.input.Value() + app.resizeComponents() + if app.input.Height() != composerMaxHeight { + t.Fatalf("expected composer height capped at %d, got %d", composerMaxHeight, app.input.Height()) + } if app.focusLabel() == "" || app.statusBadge("ready") == "" { t.Fatalf("expected status helpers to render") } @@ -698,12 +725,65 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "plain input send starts runtime", + name: "enter inserts newline without sending", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue("inspect repo") app.state.InputText = "inspect repo" + app.resizeComponents() + }, + msg: tea.KeyMsg{Type: tea.KeyEnter}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if len(runtime.runInputs) != 0 { + t.Fatalf("expected enter not to send input, got %+v", runtime.runInputs) + } + if app.state.InputText != "inspect repo\n" { + t.Fatalf("expected newline to be inserted, got %q", app.state.InputText) + } + if app.input.Height() != 2 { + t.Fatalf("expected composer height to grow to 2, got %d", app.input.Height()) + } + prompt := app.renderPrompt(80) + if !strings.Contains(prompt, "inspect repo") { + t.Fatalf("expected first line to remain visible after newline, got %q", prompt) + } + if strings.Count(prompt, "> ") < 2 { + t.Fatalf("expected both lines to keep prompt prefix, got %q", prompt) + } + }, + }, + { + name: "second enter grows composer to third line", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue("line1\nline2") + app.state.InputText = app.input.Value() + app.resizeComponents() }, msg: tea.KeyMsg{Type: tea.KeyEnter}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if len(runtime.runInputs) != 0 { + t.Fatalf("expected second enter not to send input") + } + if app.input.Height() != 3 { + t.Fatalf("expected composer height to grow to 3, got %d", app.input.Height()) + } + prompt := app.renderPrompt(80) + if !strings.Contains(prompt, "line1") || !strings.Contains(prompt, "line2") { + t.Fatalf("expected previous lines to remain visible, got %q", prompt) + } + if strings.Count(prompt, "> ") < 3 { + t.Fatalf("expected all three lines to keep prompt prefix, got %q", prompt) + } + }, + }, + { + name: "plain multiline input ctrl+j starts runtime", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue("inspect repo\nwith details") + app.state.InputText = "inspect repo\nwith details" + }, + msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if !app.state.IsAgentRunning { @@ -712,7 +792,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleUser { t.Fatalf("expected user message appended") } - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "inspect repo" { + if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "inspect repo\nwith details" { t.Fatalf("expected runtime command to execute once, got %+v", runtime.runInputs) } finished := false @@ -726,6 +806,35 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { } }, }, + { + name: "blank multiline input ctrl+j stays local", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue(" \n ") + app.state.InputText = " \n " + }, + msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if len(runtime.runInputs) != 0 || app.state.IsAgentRunning { + t.Fatalf("expected blank multiline input not to send") + } + }, + }, + { + name: "delete newline shrinks composer height", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue("line1\n") + app.state.InputText = app.input.Value() + app.resizeComponents() + }, + msg: tea.KeyMsg{Type: tea.KeyBackspace}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.input.Height() != 1 { + t.Fatalf("expected composer height to shrink to 1, got %d", app.input.Height()) + } + }, + }, { name: "quit returns quit command", msg: tea.KeyMsg{Type: tea.KeyCtrlU}, diff --git a/internal/tui/view.go b/internal/tui/view.go index a20bc2c4..2038c8ac 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -168,18 +168,9 @@ func (a App) renderPrompt(width int) string { } // 计算边框和内边距占用的空间 - boxWidth := max(8, width-2) + boxWidth := a.composerBoxWidth(width) - prefix := a.styles.inputPrefix.Render(">") - inputContent := a.input.View() - - content := lipgloss.JoinHorizontal( - lipgloss.Left, - prefix, - lipgloss.NewStyle().Width(1).Render(" "), - a.styles.inputLine.Width(max(4, boxWidth-2-lipgloss.Width(prefix)-1)).Render(inputContent), - ) - return box.Width(boxWidth).Render(content) + return box.Width(boxWidth).Render(a.input.View()) } func (a App) renderSidebarHeader(width int) string { From 2fa603bc0cfbc4de5c2ace611a2c42e76dafbeca Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 31 Mar 2026 13:54:39 +0800 Subject: [PATCH 16/20] =?UTF-8?q?docs(guides):=E9=87=8D=E6=96=B0=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=B8=A4=E4=BB=BDprovider=E6=8C=87=E5=AF=BC=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/adding-providers.md | 270 ++++++++++++++++++-------- docs/guides/configuration.md | 330 +++++++++++++++++++++++++++----- 2 files changed, 469 insertions(+), 131 deletions(-) diff --git a/docs/guides/adding-providers.md b/docs/guides/adding-providers.md index bd9d0a88..03fe7441 100644 --- a/docs/guides/adding-providers.md +++ b/docs/guides/adding-providers.md @@ -4,121 +4,143 @@ ## 架构概览 -NeoCode 的 provider 分为两层: +NeoCode 的 provider 架构采用**集中式配置 + 驱动复用**的设计: -- **配置层**(`ProviderConfig`):提供 `base_url`、模型列表、API Key 环境变量等元数据 -- **驱动层**(`DriverDefinition`):负责实际的 API 协议差异 +### 两层分离 -一个驱动可被多个 provider 复用(如 `gemini` 和 `openll` 都复用 `openai` 驱动)。 +- **配置层**(`internal/config/builtin_providers.go`):集中管理所有 provider 的元数据 + - Provider 名称、Driver 名称 + - Base URL、默认模型、模型列表 + - API Key 环境变量名 -## 方式一:复用已有驱动(推荐) +- **驱动层**(`internal/provider/openai/` 等):负责实际的 API 协议实现 + - 请求构造、响应解析 + - 流式输出、Tool Call 处理 -适用于 OpenAI 兼容接口。只需添加配置文件,无需编写新的驱动代码。 +### 驱动复用 -### 示例:添加 DeepSeek - -**1. 创建 `internal/provider/deepseek/deepseek.go`:** +一个驱动可被多个 provider 复用。当前所有内置 provider 都复用 `openai` 驱动: ```go -package deepseek +// internal/config/builtin_providers.go +OpenAIProvider() // Driver: "openai" +GeminiProvider() // Driver: "openai" (OpenAI-compatible API) +OpenLLProvider() // Driver: "openai" (OpenAI-compatible API) +``` -import ( - "neo-code/internal/config" -) +## 方式一:添加 OpenAI 兼容 Provider(推荐) + +适用于 OpenAI 兼容接口(大多数第三方服务)。只需在配置层添加,无需编写新驱动。 + +### 步骤:添加 DeepSeek +**1. 在 `internal/config/builtin_providers.go` 添加常量和配置:** + +```go const ( - Name = "deepseek" - DriverName = "openai" // 复用 openai 驱动 - DefaultBaseURL = "https://api.deepseek.com/v1" - DefaultModel = "deepseek-chat" - DefaultAPIKeyEnv = "DEEPSEEK_API_KEY" + // ... 现有常量 ... + + DeepSeekName = "deepseek" + DeepSeekDefaultBaseURL = "https://api.deepseek.com/v1" + DeepSeekDefaultModel = "deepseek-chat" + DeepSeekDefaultAPIKeyEnv = "DEEPSEEK_API_KEY" ) -var builtinModels = []string{ - DefaultModel, +var deepSeekModels = []string{ + DeepSeekDefaultModel, "deepseek-coder", } -// BuiltinConfig 返回该 provider 的内建配置。 -func BuiltinConfig() config.ProviderConfig { - return config.ProviderConfig{ - Name: Name, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: DefaultModel, - Models: append([]string(nil), builtinModels...), - APIKeyEnv: DefaultAPIKeyEnv, +// DeepSeekProvider 返回 DeepSeek provider 的默认配置。 +func DeepSeekProvider() ProviderConfig { + return ProviderConfig{ + Name: DeepSeekName, + Driver: "openai", // 复用 openai 驱动 + BaseURL: DeepSeekDefaultBaseURL, + Model: DeepSeekDefaultModel, + Models: append([]string(nil), deepSeekModels...), + APIKeyEnv: DeepSeekDefaultAPIKeyEnv, } } ``` -**2. 在 `internal/provider/builtin/builtin.go` 中添加该 provider:** +**2. 在 `DefaultProviders()` 中注册:** ```go -import "neo-code/internal/provider/deepseek" - -func DefaultConfig() *config.Config { - cfg := config.Default() - defaultProvider := openai.BuiltinConfig() - cfg.Providers = []config.ProviderConfig{ - defaultProvider, - gemini.BuiltinConfig(), - openll.BuiltinConfig(), - deepseek.BuiltinConfig(), // 新增 +func DefaultProviders() []ProviderConfig { + return []ProviderConfig{ + OpenAIProvider(), + GeminiProvider(), + OpenLLProvider(), + DeepSeekProvider(), // 新增 } - // ... } ``` +**3. 设置环境变量并测试:** + +```bash +export DEEPSEEK_API_KEY="your-api-key" +go run ./cmd/neocode +``` + +### 优点 + +- ✅ 无需编写新代码,只需配置 +- ✅ 自动继承 openai 驱动的所有功能(流式、Tool Call) +- ✅ 配置集中管理,易于维护 +- ✅ 用户无需修改 YAML 文件 + ## 方式二:实现新驱动 适用于协议不兼容的厂商(如 Anthropic、Google 原生 API)。 -### 示例:添加 Anthropic +### 步骤:添加 Anthropic -**1. 在 `internal/provider/anthropic/anthropic.go` 中实现核心类型:** +**1. 在 `internal/provider/anthropic/anthropic.go` 实现驱动:** ```go package anthropic import ( "context" + "net/http" + "neo-code/internal/config" domain "neo-code/internal/provider" ) const ( - Name = "anthropic" - DriverName = "anthropic" - DefaultBaseURL = "https://api.anthropic.com/v1" - DefaultModel = "claude-sonnet-4-20250514" - DefaultAPIKeyEnv = "ANTHROPIC_API_KEY" + Name = "anthropic" + DefaultBaseURL = "https://api.anthropic.com/v1" ) +// Provider 实现 domain.Provider 接口 type Provider struct { cfg config.ResolvedProviderConfig client *http.Client } -// New 构造函数,接收已解析的配置。 +// New 构造函数 func New(cfg config.ResolvedProviderConfig) (*Provider, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("anthropic provider: %w", err) } - // ... - return &Provider{cfg: cfg, client: &http.Client{}}, nil + return &Provider{ + cfg: cfg, + client: &http.Client{Timeout: 60 * time.Second}, + }, nil } -// Chat 实现 domain.Provider 接口。 -// 必须支持流式输出(通过 events channel)和 tool calls。 +// Chat 实现流式对话接口 func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { - // 1. 将 domain.ChatRequest 转换为厂商特定的请求格式 - // 2. 调用厂商 API(流式 SSE) + // 1. 将 domain.ChatRequest 转换为 Anthropic API 格式 + // 2. 调用 Anthropic API(流式 SSE) // 3. 解析响应,推送 StreamEventTextDelta / StreamEventToolCallStart // 4. 返回 domain.ChatResponse } -// Driver 返回驱动定义,供 Registry 注册使用。 +// Driver 返回驱动定义 func Driver() domain.DriverDefinition { return domain.DriverDefinition{ Name: Name, @@ -127,24 +149,15 @@ func Driver() domain.DriverDefinition { }, } } - -// BuiltinConfig 返回内建配置。 -func BuiltinConfig() config.ProviderConfig { - return config.ProviderConfig{ - Name: Name, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: DefaultModel, - Models: []string{DefaultModel, "claude-opus-4-20250514"}, - APIKeyEnv: DefaultAPIKeyEnv, - } -} ``` -**2. 在 `internal/provider/builtin/builtin.go` 中注册驱动:** +**2. 在 `internal/provider/builtin/builtin.go` 注册驱动:** ```go -import "neo-code/internal/provider/anthropic" +import ( + "neo-code/internal/provider/anthropic" + "neo-code/internal/provider/openai" +) func Register(registry *provider.Registry) error { if registry == nil { @@ -153,33 +166,120 @@ func Register(registry *provider.Registry) error { if err := registry.Register(openai.Driver()); err != nil { return err } - return registry.Register(anthropic.Driver()) // 新增驱动注册 + return registry.Register(anthropic.Driver()) // 新增 +} +``` + +**3. 在 `internal/config/builtin_providers.go` 添加配置:** + +```go +const ( + // ... 现有常量 ... + + AnthropicName = "anthropic" + AnthropicDefaultBaseURL = "https://api.anthropic.com/v1" + AnthropicDefaultModel = "claude-sonnet-4-20250514" + AnthropicDefaultAPIKeyEnv = "ANTHROPIC_API_KEY" +) + +var anthropicModels = []string{ + AnthropicDefaultModel, + "claude-opus-4-20250514", + "claude-3-5-sonnet-20241022", +} + +func AnthropicProvider() ProviderConfig { + return ProviderConfig{ + Name: AnthropicName, + Driver: "anthropic", // 使用新的 anthropic 驱动 + BaseURL: AnthropicDefaultBaseURL, + Model: AnthropicDefaultModel, + Models: append([]string(nil), anthropicModels...), + APIKeyEnv: AnthropicDefaultAPIKeyEnv, + } +} + +func DefaultProviders() []ProviderConfig { + return []ProviderConfig{ + OpenAIProvider(), + GeminiProvider(), + OpenLLProvider(), + AnthropicProvider(), // 新增 + } } ``` ## 关键接口与类型 +### 核心接口 + | 类型 | 位置 | 说明 | |------|------|------| -| `Provider` 接口 | `internal/provider/provider.go` | 核心接口,定义 `Chat` 方法 | -| `ChatRequest` | `internal/provider/types.go` | 请求结构:`Model`、`SystemPrompt`、`Messages`、`Tools` | -| `ChatResponse` | `internal/provider/types.go` | 响应结构:`Message`、`FinishReason`、`Usage` | -| `StreamEvent` | `internal/provider/provider.go` | 流式事件:`text_delta` 和 `tool_call_start` | -| `ProviderConfig` | `internal/config/model.go` | 配置层:`Name`、`Driver`、`BaseURL`、`Model`、`Models`、`APIKeyEnv` | -| `DriverDefinition` | `internal/provider/registry.go` | 驱动层:`Name` + `Build` 构造函数 | +| `Provider` | `internal/provider/types.go` | 核心接口,定义 `Chat` 方法 | +| `DriverDefinition` | `internal/provider/registry.go` | 驱动定义:`Name` + `Build` 构造函数 | | `Registry` | `internal/provider/registry.go` | 驱动注册中心 | +### 数据结构 + +| 类型 | 位置 | 说明 | +|------|------|------| +| `ChatRequest` | `internal/provider/types.go` | 请求:`Model`、`SystemPrompt`、`Messages`、`Tools` | +| `ChatResponse` | `internal/provider/types.go` | 响应:`Message`、`FinishReason`、`Usage` | +| `StreamEvent` | `internal/provider/types.go` | 流式事件:`TextDelta`、`ToolCallStart` | +| `ProviderConfig` | `internal/config/model.go` | 配置:`Name`、`Driver`、`BaseURL`、`Model`、`Models`、`APIKeyEnv` | + ## 设计约束 -**必须遵守**: +### 必须遵守 + +✅ **配置集中管理** +- 所有内置 provider 配置统一在 `internal/config/builtin_providers.go` +- 不再为每个 provider 创建独立的包 + +✅ **API Key 安全** +- 只从环境变量读取,不写入 `config.yaml` +- 不硬编码在源码中 + +✅ **驱动职责清晰** +- 驱动只负责协议构造与响应解析 +- 不持有模型列表、base_url 等配置信息 + +✅ **架构分层** +- 厂商差异收敛在 `internal/provider/` 内 +- `runtime`、`tui` 等上层模块只依赖统一的 `Provider` 接口 +- `base_url` 不在 TUI 中展示给用户 + +### 最佳实践 + +1. **优先复用现有驱动** + - 大多数 OpenAI 兼容服务无需编写新驱动 + - 只需在配置层添加即可 + +2. **配置即代码** + - provider 配置随代码版本发布 + - 用户无需手动配置 providers 列表 + +3. **测试覆盖** + - 新驱动必须添加完整的单元测试 + - 使用 `httptest.NewServer` 模拟 HTTP 调用 + - 不使用真实 API Key + +## 示例:当前内置 Provider + +```go +// internal/config/builtin_providers.go + +func DefaultProviders() []ProviderConfig { + return []ProviderConfig{ + OpenAIProvider(), // OpenAI 官方 API + GeminiProvider(), // Google Gemini (OpenAI-compatible) + OpenLLProvider(), // OpenLL 服务 (OpenAI-compatible) + } +} +``` -- **API Key 只从环境变量读取**,不写入 `config.yaml`,不硬编码在源码中 -- **驱动层不持有模型列表**,`Models` 完全由配置层(`ProviderConfig.Models`)控制 -- **厂商差异收敛在 `internal/provider/` 内**,`runtime`、`tui` 等上层模块只依赖统一的 `Provider` 接口 -- **`base_url` 不向用户暴露**,用户在 TUI 中只能看到 provider 名称和模型列表 +所有内置 provider 都复用 `openai` 驱动,配置集中在 `builtin_providers.go`。 ## 相关文档 -- [Provider Interface Target Design](../provider-interface-target-design.md) -- [Provider Module Interface](../provider-module-interface.md) -- [Provider Schema Strategy](../provider-schema-strategy.md) +- [Provider Architecture Optimization PR](../provider-architecture-optimization-pr.md) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 01a4d1e0..bc2a834f 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -4,26 +4,42 @@ ## Provider 策略 -NeoCode 采用"内建 Provider 优先"的策略: +NeoCode 采用"内置 Provider 优先"的策略: -- 内建 provider 定义随代码版本发布 +### 核心原则 + +✅ **配置集中管理** +- 所有内置 provider 定义集中在 `internal/config/builtin_providers.go` +- 配置随代码版本发布,自动更新 + +✅ **最小持久化** - `config.yaml` 不再持久化完整 `providers` 列表 -- `config.yaml` 只保存当前选择状态和通用运行配置 -- 运行时的 `providers` 完全来自代码内建定义 -- API Key 只从环境变量读取,不写入 YAML -- 当前内建 provider 包括 `openai` 和 `gemini` -- `gemini` 复用 OpenAI-compatible driver,请求地址指向 Gemini 的兼容接口 -- provider 实例自己定义 `base_url`、默认模型、可选模型列表和 `api_key_env` -- `base_url` 不在 TUI 中展示给用户 -- driver 只负责协议构造与响应解析,不决定 `models`、`base_url` 或 `api_key_env` +- 只保存当前选择状态和通用运行配置 +- 运行时的 `providers` 完全来自代码内置定义 + +✅ **安全第一** +- API Key 只从环境变量读取,永不写入 YAML +- 不硬编码在源码中 +- 支持通过 `.env` 文件管理 + +### 当前内置 Provider + +| Provider | Driver | 说明 | +|----------|--------|------| +| `openai` | `openai` | OpenAI 官方 API | +| `gemini` | `openai` | Google Gemini (OpenAI-compatible API) | +| `openll` | `openai` | OpenLL 服务 (OpenAI-compatible API) | + +所有内置 provider 都复用 `openai` 驱动,支持流式输出和 Tool Call。 -### 设计理念 +### 设计优势 -这意味着: +这种方式意味着: -- 新用户启动后会自动拿到当前版本最新的内建 provider -- 未来代码新增 provider 时,新用户不需要修改 YAML -- 老配置文件中的 `providers` / `provider_overrides` 会在加载时被清理为新的最小状态格式 +- ✅ 新用户启动后自动获得当前版本最新的内置 provider +- ✅ 未来代码新增 provider 时,用户无需修改 YAML +- ✅ 老配置文件中的 `providers` / `provider_overrides` 会在加载时被清理 +- ✅ 配置文件始终保持简洁,只包含必要的运行时状态 ## 配置文件 @@ -33,57 +49,279 @@ NeoCode 采用"内建 Provider 优先"的策略: ~/.neocode/config.yaml ``` -### 配置结构示例 +### 完整配置示例 ```yaml selected_provider: openai -current_model: gpt-5.4 -workdir: . -shell: powershell +current_model: gpt-4.1 +workdir: /Users/username/projects/myproject +shell: bash max_loops: 8 -tool_timeout_sec: 20 + +tools: + webfetch: + max_response_bytes: 1048576 + supported_content_types: + - text/html + - text/plain + - application/json ``` ### 字段说明 -| 字段 | 类型 | 说明 | -|------|------|------| -| `selected_provider` | string | 当前选择的 provider 名称 | -| `current_model` | string | 当前选择的模型名称 | -| `workdir` | string | 工作目录路径 | -| `shell` | string | Shell 类型(如 `powershell`、`bash`) | -| `max_loops` | int | Agent 推理循环最大轮数 | -| `tool_timeout_sec` | int | 工具执行超时时间(秒) | +#### 基础配置 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `selected_provider` | string | `openai` | 当前选择的 provider 名称 | +| `current_model` | string | 取决于 provider | 当前选择的模型名称 | +| `workdir` | string | `.` (当前目录) | 工作目录的绝对路径 | +| `shell` | string | `bash` (Linux/Mac)
`powershell` (Windows) | Shell 类型 | +| `max_loops` | int | `8` | Agent 推理循环最大轮数 | + +#### 工具配置 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `tools.webfetch.max_response_bytes` | int | `1048576` (1MB) | WebFetch 工具最大响应字节数 | +| `tools.webfetch.supported_content_types` | []string | `[text/html, text/plain, application/json]` | 支持的内容类型 | + +### 配置文件特点 -### 环境变量 +**自动管理**: +- 首次启动时自动创建默认配置 +- `workdir` 自动转换为绝对路径 +- 无效配置会在启动时报错 -每个 provider 对应的环境变量: +**最小持久化**: +- 不保存 `providers` 列表(由代码内置提供) +- 不保存 `base_url`、`models` 等 provider 元数据 +- 只保存用户的选择状态和自定义设置 -| Provider | 环境变量 | 说明 | -|----------|----------|------| -| `openai` | `OPENAI_API_KEY` | OpenAI API Key | -| `gemini` | `GEMINI_API_KEY` | Google Gemini API Key | +## 环境变量 -**安全提示**: -- API Key 仅从环境变量读取,永不写入配置文件 -- 不要将 API Key 硬编码在代码中 -- 建议使用 `.env` 文件或系统环境变量管理密钥 +每个 provider 对应的 API Key 环境变量: + +| Provider | 环境变量 | 获取方式 | +|----------|----------|----------| +| `openai` | `OPENAI_API_KEY` | [OpenAI Platform](https://platform.openai.com/api-keys) | +| `gemini` | `GEMINI_API_KEY` | [Google AI Studio](https://aistudio.google.com/apikey) | +| `openll` | `AI_API_KEY` | OpenLL 服务提供商 | + +### 环境变量管理 + +#### 方式一:系统环境变量 + +**Linux/macOS**: +```bash +export OPENAI_API_KEY="sk-..." +export GEMINI_API_KEY="AI..." +export AI_API_KEY="your-key" +``` + +**Windows PowerShell**: +```powershell +$env:OPENAI_API_KEY="sk-..." +$env:GEMINI_API_KEY="AI..." +$env:AI_API_KEY="your-key" +``` + +#### 方式二:.env 文件 + +**项目根目录 `.env`**: +```env +OPENAI_API_KEY=sk-... +GEMINI_API_KEY=AI... +AI_API_KEY=your-key +``` + +**NeoCode 管理目录 `~/.neocode/.env`**: +```env +OPENAI_API_KEY=sk-... +GEMINI_API_KEY=AI... +``` + +NeoCode 会自动加载这两个位置的 `.env` 文件。 + +### 安全最佳实践 + +⚠️ **重要安全提示**: + +1. **永不提交 API Key** + - 确保 `.env` 文件在 `.gitignore` 中 + - 不要将包含 API Key 的文件提交到版本控制 + +2. **使用环境变量** + - API Key 仅从环境变量读取 + - 永不写入配置文件 + - 不硬编码在代码中 + +3. **密钥轮换** + - 定期更换 API Key + - 不要在多个环境使用同一个 Key ## Slash Commands NeoCode 提供以下 slash 命令用于快速切换配置: -- `/provider` — 打开 provider 选择器 -- `/model` — 打开当前 provider 的模型选择器 +### /provider - Provider 选择器 + +``` +/provider +``` + +打开 provider 选择器,列出所有可用的内置 provider。 + +**界面示例**: +``` +? Select a provider: + ❯ openai + gemini + openll +``` + +### /model - 模型选择器 + +``` +/model +``` + +打开当前 provider 的模型选择器。 + +**界面示例**(选择 openai provider 后): +``` +? Select a model: + ❯ gpt-4.1 + gpt-4o + gpt-5.4 + gpt-5.3-codex +``` ## 配置管理 配置管理由 `internal/config` 模块负责: -- YAML 加载与保存 -- `.env` 文件集成 -- 默认值管理 -- 并发安全访问 -- 配置校验 +### 核心功能 + +- ✅ YAML 加载与保存 +- ✅ `.env` 文件自动加载 +- ✅ 默认值管理 +- ✅ 并发安全访问 +- ✅ 配置校验 + +### 配置流程 + +``` +启动 + ↓ +加载 ~/.neocode/config.yaml + ↓ +应用内置 defaults (来自 builtin_providers.go) + ↓ +合并 .env 文件环境变量 + ↓ +验证配置完整性 + ↓ +运行时使用 +``` + +### 配置更新 + +当用户通过 TUI 切换 provider 或 model 时: + +1. 更新内存中的配置 +2. 立即持久化到 `config.yaml` +3. 下次启动自动恢复选择状态 + +## 添加自定义 Provider + +如需添加自定义 provider(如企业内部服务),请参考: + +👉 [adding-providers.md](./adding-providers.md) + +### 快速步骤 + +**OpenAI 兼容服务**(推荐): +1. 在 `internal/config/builtin_providers.go` 添加配置函数 +2. 在 `DefaultProviders()` 中注册 +3. 设置对应的环境变量 + +**自定义协议**: +1. 在 `internal/provider/yourprovider/` 实现驱动 +2. 在 `internal/provider/builtin/builtin.go` 注册驱动 +3. 在 `internal/config/builtin_providers.go` 添加配置 + +## 配置示例场景 + +### 场景一:使用 Gemini + +```bash +# 1. 设置环境变量 +export GEMINI_API_KEY="your-gemini-api-key" + +# 2. 启动 NeoCode +go run ./cmd/neocode + +# 3. 在 TUI 中切换 +/provider # 选择 gemini +/model # 选择 gemini-2.5-flash +``` + +### 场景二:使用 OpenLL + +```bash +# 1. 设置环境变量 +export AI_API_KEY="your-openll-api-key" + +# 2. 启动并切换 +go run ./cmd/neocode +# 在 TUI 中: /provider → openll +``` + +### 场景三:自定义工作目录 + +```yaml +# ~/.neocode/config.yaml +selected_provider: openai +current_model: gpt-4.1 +workdir: /Users/username/projects/myproject +shell: bash +max_loops: 10 +``` + +## 故障排查 + +### 配置加载失败 + +**错误**:`config validation failed: providers is empty` + +**解决**: +- 确保使用最新版本的代码 +- 删除 `~/.neocode/config.yaml` 让系统重新生成 + +### API Key 未找到 + +**错误**:`environment variable OPENAI_API_KEY is empty` + +**解决**: +```bash +# 检查环境变量 +echo $OPENAI_API_KEY + +# 或检查 .env 文件 +cat ~/.neocode/.env +``` + +### Provider 不存在 + +**错误**:`provider not found: xxx` + +**解决**: +- 检查 provider 名称拼写 +- 使用 `/provider` 命令查看所有可用 provider + +## 相关文档 -详细设计参见 [`config-management-detail-design.md`](../config-management-detail-design.md)。 +- [配置管理详细设计](../config-management-detail-design.md) +- [Provider 架构优化 PR](../provider-architecture-optimization-pr.md) +- [添加新 Provider](./adding-providers.md) From f70e0f94d855067b61841b4c94eabecdc64273fa Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 31 Mar 2026 20:27:24 +0800 Subject: [PATCH 17/20] =?UTF-8?q?fix=EF=BC=9A=E6=94=B9=E6=88=90Enter?= =?UTF-8?q?=E5=8F=91=E9=80=81=EF=BC=8Cctrl+j=E6=8D=A2=E8=A1=8C=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/keymap.go | 17 ++++++---- internal/tui/update.go | 3 +- internal/tui/update_test.go | 64 +++++++++++++++++++++++++++++-------- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index 5e09da42..ece5b54d 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -4,6 +4,7 @@ import "github.com/charmbracelet/bubbles/key" type keyMap struct { Send key.Binding + Newline key.Binding CancelAgent key.Binding NewSession key.Binding NextPanel key.Binding @@ -23,8 +24,12 @@ type keyMap struct { func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("Enter", "发送(输入框)"), + ), + Newline: key.NewBinding( key.WithKeys("ctrl+j"), - key.WithHelp("Ctrl+J", "发送(输入框)"), + key.WithHelp("Ctrl+J", "换行(输入框)"), ), CancelAgent: key.NewBinding( key.WithKeys("ctrl+w"), @@ -86,14 +91,14 @@ func newKeyMap() keyMap { } func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Send, k.CancelAgent, k.NewSession, k.ToggleHelp, k.Quit} + return []key.Binding{k.Send, k.Newline, k.CancelAgent, k.ToggleHelp, k.Quit} } func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Send, k.CancelAgent, k.NewSession, k.OpenSession}, - {k.FocusInput, k.NextPanel, k.PrevPanel, k.ToggleHelp}, - {k.Quit, k.ScrollUp, k.ScrollDown, k.PageUp}, - {k.PageDown, k.Top, k.Bottom}, + {k.Send, k.Newline, k.CancelAgent, k.NewSession}, + {k.OpenSession, k.FocusInput, k.NextPanel, k.PrevPanel}, + {k.ToggleHelp, k.Quit, k.ScrollUp, k.ScrollDown}, + {k.PageUp, k.PageDown, k.Top, k.Bottom}, } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 45338223..10a6a9cf 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -237,8 +237,9 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } - if typed.Type == tea.KeyEnter { + if key.Matches(typed, a.keys.Newline) { a.growComposerForNewline() + msg = tea.KeyMsg{Type: tea.KeyEnter} } var cmd tea.Cmd diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 88d5bd7b..5843496b 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -138,7 +138,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { app.input.SetValue(tt.input) app.state.InputText = tt.input - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) app = model.(App) for _, msg := range collectTeaMessages(cmd) { @@ -372,8 +372,20 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if lipgloss.Width(app.renderPrompt(80)) != 80 { t.Fatalf("expected prompt width 80, got %d", lipgloss.Width(app.renderPrompt(80))) } + if got := newKeyMap().Send.Help().Key; got != "Enter" { + t.Fatalf("expected send shortcut help to use Enter, got %q", got) + } + if got := newKeyMap().Send.Keys(); len(got) != 1 || got[0] != "enter" { + t.Fatalf("expected send binding to use enter, got %+v", got) + } + if got := newKeyMap().Newline.Help().Key; got != "Ctrl+J" { + t.Fatalf("expected newline shortcut help to use Ctrl+J, got %q", got) + } + if got := newKeyMap().Newline.Keys(); len(got) != 1 || got[0] != "ctrl+j" { + t.Fatalf("expected newline binding to use ctrl+j, got %+v", got) + } if !strings.Contains(app.renderHelp(80), "Ctrl+J") { - t.Fatalf("expected help to render the new send shortcut") + t.Fatalf("expected footer help to render newline shortcut") } sidebar := app.renderSidebar(26, 12) if lipgloss.Width(sidebar) != 26 || lipgloss.Height(sidebar) != 12 { @@ -382,8 +394,8 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if !strings.Contains(app.renderSidebar(26, 12), sidebarTitle) || !strings.Contains(app.renderSidebar(26, 12), sidebarOpenHint) { t.Fatalf("expected updated sidebar header text") } - if strings.Contains(app.renderPrompt(80), "Enter/Ctrl+S") { - t.Fatalf("expected composer hint line to be removed") + if strings.Contains(app.renderPrompt(80), "Enter sends, Ctrl+J inserts a newline") { + t.Fatalf("expected keyboard hint to move out of placeholder text") } if strings.TrimSpace(app.renderPrompt(80)) == "" { t.Fatalf("expected prompt to render a visible border") @@ -725,17 +737,17 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "enter inserts newline without sending", + name: "ctrl+j inserts newline without sending", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue("inspect repo") app.state.InputText = "inspect repo" app.resizeComponents() }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, + msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if len(runtime.runInputs) != 0 { - t.Fatalf("expected enter not to send input, got %+v", runtime.runInputs) + t.Fatalf("expected ctrl+j not to send input, got %+v", runtime.runInputs) } if app.state.InputText != "inspect repo\n" { t.Fatalf("expected newline to be inserted, got %q", app.state.InputText) @@ -753,17 +765,17 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "second enter grows composer to third line", + name: "second ctrl+j grows composer to third line", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue("line1\nline2") app.state.InputText = app.input.Value() app.resizeComponents() }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, + msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if len(runtime.runInputs) != 0 { - t.Fatalf("expected second enter not to send input") + t.Fatalf("expected second ctrl+j not to send input") } if app.input.Height() != 3 { t.Fatalf("expected composer height to grow to 3, got %d", app.input.Height()) @@ -778,12 +790,12 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "plain multiline input ctrl+j starts runtime", + name: "plain multiline input enter starts runtime", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue("inspect repo\nwith details") app.state.InputText = "inspect repo\nwith details" }, - msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, + msg: tea.KeyMsg{Type: tea.KeyEnter}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if !app.state.IsAgentRunning { @@ -807,16 +819,40 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "blank multiline input ctrl+j stays local", + name: "blank input enter stays local", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue(" \n ") + app.state.InputText = " \n " + }, + msg: tea.KeyMsg{Type: tea.KeyEnter}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if len(runtime.runInputs) != 0 || app.state.IsAgentRunning { + t.Fatalf("expected blank input enter not to send") + } + if app.state.InputText != " \n " { + t.Fatalf("expected blank input to stay unchanged on enter, got %q", app.state.InputText) + } + }, + }, + { + name: "blank input ctrl+j inserts newline locally", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue(" \n ") app.state.InputText = " \n " + app.resizeComponents() }, msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if len(runtime.runInputs) != 0 || app.state.IsAgentRunning { - t.Fatalf("expected blank multiline input not to send") + t.Fatalf("expected blank input ctrl+j not to send") + } + if app.state.InputText != " \n \n" { + t.Fatalf("expected ctrl+j to insert newline into blank input, got %q", app.state.InputText) + } + if app.input.Height() != 3 { + t.Fatalf("expected composer height to grow to 3, got %d", app.input.Height()) } }, }, From 11798ef5025334bb797796dc615c156a1bb8e4cd Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 31 Mar 2026 11:12:50 +0800 Subject: [PATCH 18/20] =?UTF-8?q?feat:=E5=AE=9E=E7=8E=B0=E4=BA=86=E5=A4=9A?= =?UTF-8?q?=E8=A1=8C=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/app.go | 29 ++++++--- internal/tui/keymap.go | 18 +++--- internal/tui/update.go | 43 ++++++++++++- internal/tui/update_test.go | 117 ++++++++++++++++++++++++++++++++++-- internal/tui/view.go | 13 +--- 5 files changed, 186 insertions(+), 34 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index fbfcd4bd..501fcfac 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -28,7 +28,7 @@ type App struct { providerPicker list.Model modelPicker list.Model transcript viewport.Model - input textinput.Model + input textarea.Model activeMessages []provider.Message focus panel width int @@ -63,14 +63,27 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime sessionList.FilterInput.Prompt = "Filter: " sessionList.FilterInput.Placeholder = "Type to search sessions" - input := textinput.New() + input := textarea.New() input.Placeholder = "Ask NeoCode to inspect, edit, or build. Type / to browse commands." - input.Prompt = "" input.CharLimit = 24000 - input.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) - input.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) - input.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.ShowLineNumbers = false + input.SetPromptFunc(composerPromptWidth, func(line int) string { + return "> " + }) + input.FocusedStyle.Base = lipgloss.NewStyle() + input.FocusedStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)).Bold(true) + input.FocusedStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) + input.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.FocusedStyle.CursorLine = lipgloss.NewStyle() + input.FocusedStyle.CursorLineNumber = lipgloss.NewStyle() + input.BlurredStyle.Base = lipgloss.NewStyle() + input.BlurredStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)).Bold(true) + input.BlurredStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) + input.BlurredStyle.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.BlurredStyle.CursorLine = lipgloss.NewStyle() + input.BlurredStyle.CursorLineNumber = lipgloss.NewStyle() input.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) + input.SetHeight(composerMinHeight) input.Focus() spin := spinner.New() @@ -130,5 +143,5 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime } func (a App) Init() tea.Cmd { - return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textinput.Blink, a.spinner.Tick) + return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textarea.Blink, a.spinner.Tick) } diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index 891be1a8..5e09da42 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -23,12 +23,12 @@ type keyMap struct { func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( - key.WithKeys("enter", "ctrl+s"), - key.WithHelp("Enter/Ctrl+S", "发送(输入框)"), + key.WithKeys("ctrl+j"), + key.WithHelp("Ctrl+J", "发送(输入框)"), ), CancelAgent: key.NewBinding( key.WithKeys("ctrl+w"), - key.WithHelp("Ctrl+w", "中止"), + key.WithHelp("Ctrl+W", "中止"), ), NewSession: key.NewBinding( key.WithKeys("ctrl+n"), @@ -60,27 +60,27 @@ func newKeyMap() keyMap { ), ScrollUp: key.NewBinding( key.WithKeys("up", "k"), - key.WithHelp("Up/k", "向上滚动"), + key.WithHelp("Up/K", "向上滚动"), ), ScrollDown: key.NewBinding( key.WithKeys("down", "j"), - key.WithHelp("Down/j", "向下滚动"), + key.WithHelp("Down/J", "向下滚动"), ), PageUp: key.NewBinding( key.WithKeys("pgup", "b"), - key.WithHelp("PgUp/b", "向上翻页"), + key.WithHelp("PgUp/B", "向上翻页"), ), PageDown: key.NewBinding( key.WithKeys("pgdown", "f"), - key.WithHelp("PgDn/f", "向下翻页"), + key.WithHelp("PgDn/F", "向下翻页"), ), Top: key.NewBinding( key.WithKeys("g", "home"), - key.WithHelp("g/Home", "跳到顶部"), + key.WithHelp("G/Home", "跳到顶部"), ), Bottom: key.NewBinding( key.WithKeys("G", "end"), - key.WithHelp("G/End", "跳到底部"), + key.WithHelp("Shift+G/End", "跳到底部"), ), } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 37692d10..45338223 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -26,6 +26,12 @@ type localCommandResultMsg struct { err error } +const ( + composerMinHeight = 1 + composerMaxHeight = 5 + composerPromptWidth = 2 +) + func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var spinCmd tea.Cmd @@ -143,6 +149,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.InputText = "" a.focus = panelInput a.applyFocus() + a.resizeComponents() a.rebuildTranscript() return a, tea.Batch(cmds...) } @@ -185,6 +192,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.input.Reset() a.state.InputText = "" + a.resizeComponents() switch strings.ToLower(input) { case slashCommandProviderPick: @@ -229,9 +237,14 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } + if typed.Type == tea.KeyEnter { + a.growComposerForNewline() + } + var cmd tea.Cmd a.input, cmd = a.input.Update(msg) a.state.InputText = a.input.Value() + a.normalizeComposerHeight() a.resizeComponents() cmds = append(cmds, cmd) return a, tea.Batch(cmds...) @@ -523,8 +536,8 @@ func (a *App) resizeComponents() { a.sessions.SetSize(sidebarBodyWidth, sidebarBodyHeight) menuHeight := a.commandMenuHeight(max(24, lay.rightWidth)) a.transcript.Width = max(24, lay.rightWidth) - promptInnerWidth := max(8, lay.rightWidth-a.styles.inputBoxFocused.GetHorizontalFrameSize()) - a.input.Width = max(4, promptInnerWidth-lipgloss.Width("> ")) + a.input.SetWidth(a.composerInnerWidth(lay.rightWidth)) + a.input.SetHeight(a.composerHeight()) promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) @@ -532,6 +545,32 @@ func (a *App) resizeComponents() { a.rebuildTranscript() } +func (a App) composerBoxWidth(totalWidth int) int { + return max(8, totalWidth-2) +} + +func (a App) composerInnerWidth(totalWidth int) int { + return max(4, a.composerBoxWidth(totalWidth)-a.styles.inputBoxFocused.GetHorizontalFrameSize()) +} + +func (a App) composerHeight() int { + return clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) +} + +func (a *App) growComposerForNewline() { + nextHeight := clamp(a.input.LineCount()+1, composerMinHeight, composerMaxHeight) + if nextHeight > a.input.Height() { + a.input.SetHeight(nextHeight) + } +} + +func (a *App) normalizeComposerHeight() { + targetHeight := clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) + if targetHeight != a.input.Height() { + a.input.SetHeight(targetHeight) + } +} + func (a *App) rebuildTranscript() { width := max(24, a.transcript.Width) if len(a.activeMessages) == 0 { diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 9ffea7ba..cc24ef5f 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -92,7 +92,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { }, { name: "provider command opens picker and does not start runtime", - input: "/provider", + input: "/provider\n", assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { t.Helper() if len(runtime.runInputs) != 0 { @@ -137,7 +137,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { app.input.SetValue(tt.input) app.state.InputText = tt.input - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) app = model.(App) for _, msg := range collectTeaMessages(cmd) { @@ -371,6 +371,9 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if lipgloss.Width(app.renderPrompt(80)) != 80 { t.Fatalf("expected prompt width 80, got %d", lipgloss.Width(app.renderPrompt(80))) } + if !strings.Contains(app.renderHelp(80), "Ctrl+J") { + t.Fatalf("expected help to render the new send shortcut") + } sidebar := app.renderSidebar(26, 12) if lipgloss.Width(sidebar) != 26 || lipgloss.Height(sidebar) != 12 { t.Fatalf("expected sidebar to respect requested dimensions, got %dx%d", lipgloss.Width(sidebar), lipgloss.Height(sidebar)) @@ -384,6 +387,30 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if strings.TrimSpace(app.renderPrompt(80)) == "" { t.Fatalf("expected prompt to render a visible border") } + app.input.SetValue("one") + app.state.InputText = "one" + app.resizeComponents() + if app.input.Height() != 1 { + t.Fatalf("expected single-line composer height 1, got %d", app.input.Height()) + } + if strings.Count(app.renderPrompt(80), "> ") < 1 { + t.Fatalf("expected single-line prompt to render composer prefix") + } + app.input.SetValue("one\ntwo") + app.state.InputText = app.input.Value() + app.resizeComponents() + if app.input.Height() != 2 { + t.Fatalf("expected two-line composer height 2, got %d", app.input.Height()) + } + if strings.Count(app.renderPrompt(80), "> ") < 2 { + t.Fatalf("expected multi-line prompt to repeat composer prefix") + } + app.input.SetValue(strings.Join([]string{"1", "2", "3", "4", "5", "6"}, "\n")) + app.state.InputText = app.input.Value() + app.resizeComponents() + if app.input.Height() != composerMaxHeight { + t.Fatalf("expected composer height capped at %d, got %d", composerMaxHeight, app.input.Height()) + } if app.focusLabel() == "" || app.statusBadge("ready") == "" { t.Fatalf("expected status helpers to render") } @@ -697,12 +724,65 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "plain input send starts runtime", + name: "enter inserts newline without sending", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue("inspect repo") app.state.InputText = "inspect repo" + app.resizeComponents() + }, + msg: tea.KeyMsg{Type: tea.KeyEnter}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if len(runtime.runInputs) != 0 { + t.Fatalf("expected enter not to send input, got %+v", runtime.runInputs) + } + if app.state.InputText != "inspect repo\n" { + t.Fatalf("expected newline to be inserted, got %q", app.state.InputText) + } + if app.input.Height() != 2 { + t.Fatalf("expected composer height to grow to 2, got %d", app.input.Height()) + } + prompt := app.renderPrompt(80) + if !strings.Contains(prompt, "inspect repo") { + t.Fatalf("expected first line to remain visible after newline, got %q", prompt) + } + if strings.Count(prompt, "> ") < 2 { + t.Fatalf("expected both lines to keep prompt prefix, got %q", prompt) + } + }, + }, + { + name: "second enter grows composer to third line", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue("line1\nline2") + app.state.InputText = app.input.Value() + app.resizeComponents() }, msg: tea.KeyMsg{Type: tea.KeyEnter}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if len(runtime.runInputs) != 0 { + t.Fatalf("expected second enter not to send input") + } + if app.input.Height() != 3 { + t.Fatalf("expected composer height to grow to 3, got %d", app.input.Height()) + } + prompt := app.renderPrompt(80) + if !strings.Contains(prompt, "line1") || !strings.Contains(prompt, "line2") { + t.Fatalf("expected previous lines to remain visible, got %q", prompt) + } + if strings.Count(prompt, "> ") < 3 { + t.Fatalf("expected all three lines to keep prompt prefix, got %q", prompt) + } + }, + }, + { + name: "plain multiline input ctrl+j starts runtime", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue("inspect repo\nwith details") + app.state.InputText = "inspect repo\nwith details" + }, + msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if !app.state.IsAgentRunning { @@ -711,7 +791,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleUser { t.Fatalf("expected user message appended") } - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "inspect repo" { + if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "inspect repo\nwith details" { t.Fatalf("expected runtime command to execute once, got %+v", runtime.runInputs) } finished := false @@ -725,6 +805,35 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { } }, }, + { + name: "blank multiline input ctrl+j stays local", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue(" \n ") + app.state.InputText = " \n " + }, + msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if len(runtime.runInputs) != 0 || app.state.IsAgentRunning { + t.Fatalf("expected blank multiline input not to send") + } + }, + }, + { + name: "delete newline shrinks composer height", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue("line1\n") + app.state.InputText = app.input.Value() + app.resizeComponents() + }, + msg: tea.KeyMsg{Type: tea.KeyBackspace}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.input.Height() != 1 { + t.Fatalf("expected composer height to shrink to 1, got %d", app.input.Height()) + } + }, + }, { name: "quit returns quit command", msg: tea.KeyMsg{Type: tea.KeyCtrlU}, diff --git a/internal/tui/view.go b/internal/tui/view.go index a20bc2c4..2038c8ac 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -168,18 +168,9 @@ func (a App) renderPrompt(width int) string { } // 计算边框和内边距占用的空间 - boxWidth := max(8, width-2) + boxWidth := a.composerBoxWidth(width) - prefix := a.styles.inputPrefix.Render(">") - inputContent := a.input.View() - - content := lipgloss.JoinHorizontal( - lipgloss.Left, - prefix, - lipgloss.NewStyle().Width(1).Render(" "), - a.styles.inputLine.Width(max(4, boxWidth-2-lipgloss.Width(prefix)-1)).Render(inputContent), - ) - return box.Width(boxWidth).Render(content) + return box.Width(boxWidth).Render(a.input.View()) } func (a App) renderSidebarHeader(width int) string { From 858dda91eb68739ab46d2fb5eba0646964dd22b7 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 31 Mar 2026 20:27:24 +0800 Subject: [PATCH 19/20] =?UTF-8?q?fix=EF=BC=9A=E6=94=B9=E6=88=90Enter?= =?UTF-8?q?=E5=8F=91=E9=80=81=EF=BC=8Cctrl+j=E6=8D=A2=E8=A1=8C=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/keymap.go | 17 ++++++---- internal/tui/update.go | 3 +- internal/tui/update_test.go | 64 +++++++++++++++++++++++++++++-------- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index 5e09da42..ece5b54d 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -4,6 +4,7 @@ import "github.com/charmbracelet/bubbles/key" type keyMap struct { Send key.Binding + Newline key.Binding CancelAgent key.Binding NewSession key.Binding NextPanel key.Binding @@ -23,8 +24,12 @@ type keyMap struct { func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("Enter", "发送(输入框)"), + ), + Newline: key.NewBinding( key.WithKeys("ctrl+j"), - key.WithHelp("Ctrl+J", "发送(输入框)"), + key.WithHelp("Ctrl+J", "换行(输入框)"), ), CancelAgent: key.NewBinding( key.WithKeys("ctrl+w"), @@ -86,14 +91,14 @@ func newKeyMap() keyMap { } func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Send, k.CancelAgent, k.NewSession, k.ToggleHelp, k.Quit} + return []key.Binding{k.Send, k.Newline, k.CancelAgent, k.ToggleHelp, k.Quit} } func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Send, k.CancelAgent, k.NewSession, k.OpenSession}, - {k.FocusInput, k.NextPanel, k.PrevPanel, k.ToggleHelp}, - {k.Quit, k.ScrollUp, k.ScrollDown, k.PageUp}, - {k.PageDown, k.Top, k.Bottom}, + {k.Send, k.Newline, k.CancelAgent, k.NewSession}, + {k.OpenSession, k.FocusInput, k.NextPanel, k.PrevPanel}, + {k.ToggleHelp, k.Quit, k.ScrollUp, k.ScrollDown}, + {k.PageUp, k.PageDown, k.Top, k.Bottom}, } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 45338223..10a6a9cf 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -237,8 +237,9 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } - if typed.Type == tea.KeyEnter { + if key.Matches(typed, a.keys.Newline) { a.growComposerForNewline() + msg = tea.KeyMsg{Type: tea.KeyEnter} } var cmd tea.Cmd diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index cc24ef5f..97a8721d 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -137,7 +137,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { app.input.SetValue(tt.input) app.state.InputText = tt.input - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) app = model.(App) for _, msg := range collectTeaMessages(cmd) { @@ -371,8 +371,20 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if lipgloss.Width(app.renderPrompt(80)) != 80 { t.Fatalf("expected prompt width 80, got %d", lipgloss.Width(app.renderPrompt(80))) } + if got := newKeyMap().Send.Help().Key; got != "Enter" { + t.Fatalf("expected send shortcut help to use Enter, got %q", got) + } + if got := newKeyMap().Send.Keys(); len(got) != 1 || got[0] != "enter" { + t.Fatalf("expected send binding to use enter, got %+v", got) + } + if got := newKeyMap().Newline.Help().Key; got != "Ctrl+J" { + t.Fatalf("expected newline shortcut help to use Ctrl+J, got %q", got) + } + if got := newKeyMap().Newline.Keys(); len(got) != 1 || got[0] != "ctrl+j" { + t.Fatalf("expected newline binding to use ctrl+j, got %+v", got) + } if !strings.Contains(app.renderHelp(80), "Ctrl+J") { - t.Fatalf("expected help to render the new send shortcut") + t.Fatalf("expected footer help to render newline shortcut") } sidebar := app.renderSidebar(26, 12) if lipgloss.Width(sidebar) != 26 || lipgloss.Height(sidebar) != 12 { @@ -381,8 +393,8 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if !strings.Contains(app.renderSidebar(26, 12), sidebarTitle) || !strings.Contains(app.renderSidebar(26, 12), sidebarOpenHint) { t.Fatalf("expected updated sidebar header text") } - if strings.Contains(app.renderPrompt(80), "Enter/Ctrl+S") { - t.Fatalf("expected composer hint line to be removed") + if strings.Contains(app.renderPrompt(80), "Enter sends, Ctrl+J inserts a newline") { + t.Fatalf("expected keyboard hint to move out of placeholder text") } if strings.TrimSpace(app.renderPrompt(80)) == "" { t.Fatalf("expected prompt to render a visible border") @@ -724,17 +736,17 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "enter inserts newline without sending", + name: "ctrl+j inserts newline without sending", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue("inspect repo") app.state.InputText = "inspect repo" app.resizeComponents() }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, + msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if len(runtime.runInputs) != 0 { - t.Fatalf("expected enter not to send input, got %+v", runtime.runInputs) + t.Fatalf("expected ctrl+j not to send input, got %+v", runtime.runInputs) } if app.state.InputText != "inspect repo\n" { t.Fatalf("expected newline to be inserted, got %q", app.state.InputText) @@ -752,17 +764,17 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "second enter grows composer to third line", + name: "second ctrl+j grows composer to third line", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue("line1\nline2") app.state.InputText = app.input.Value() app.resizeComponents() }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, + msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if len(runtime.runInputs) != 0 { - t.Fatalf("expected second enter not to send input") + t.Fatalf("expected second ctrl+j not to send input") } if app.input.Height() != 3 { t.Fatalf("expected composer height to grow to 3, got %d", app.input.Height()) @@ -777,12 +789,12 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "plain multiline input ctrl+j starts runtime", + name: "plain multiline input enter starts runtime", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue("inspect repo\nwith details") app.state.InputText = "inspect repo\nwith details" }, - msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, + msg: tea.KeyMsg{Type: tea.KeyEnter}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if !app.state.IsAgentRunning { @@ -806,16 +818,40 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "blank multiline input ctrl+j stays local", + name: "blank input enter stays local", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue(" \n ") + app.state.InputText = " \n " + }, + msg: tea.KeyMsg{Type: tea.KeyEnter}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if len(runtime.runInputs) != 0 || app.state.IsAgentRunning { + t.Fatalf("expected blank input enter not to send") + } + if app.state.InputText != " \n " { + t.Fatalf("expected blank input to stay unchanged on enter, got %q", app.state.InputText) + } + }, + }, + { + name: "blank input ctrl+j inserts newline locally", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue(" \n ") app.state.InputText = " \n " + app.resizeComponents() }, msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if len(runtime.runInputs) != 0 || app.state.IsAgentRunning { - t.Fatalf("expected blank multiline input not to send") + t.Fatalf("expected blank input ctrl+j not to send") + } + if app.state.InputText != " \n \n" { + t.Fatalf("expected ctrl+j to insert newline into blank input, got %q", app.state.InputText) + } + if app.input.Height() != 3 { + t.Fatalf("expected composer height to grow to 3, got %d", app.input.Height()) } }, }, From ac28f240120e52cadb14f541dc8105dffd255c1d Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 31 Mar 2026 20:55:51 +0800 Subject: [PATCH 20/20] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8Dtest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/builtin/builtin_more_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/provider/builtin/builtin_more_test.go b/internal/provider/builtin/builtin_more_test.go index afd82714..50e8ccf3 100644 --- a/internal/provider/builtin/builtin_more_test.go +++ b/internal/provider/builtin/builtin_more_test.go @@ -10,7 +10,7 @@ import ( "neo-code/internal/provider/openai" ) -func TestRegister(t *testing.T) { +func TestRegisterRegistersOpenAIDriver(t *testing.T) { t.Parallel() tests := []struct { @@ -44,14 +44,14 @@ func TestRegister(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !tt.reg.Supports(openai.Name) { - t.Fatalf("expected registry to support %q", openai.Name) + if !tt.reg.Supports(openai.DriverName) { + t.Fatalf("expected registry to support %q", openai.DriverName) } }) } } -func TestNewRegistry(t *testing.T) { +func TestNewRegistryIncludesOpenAIDriver(t *testing.T) { t.Parallel() reg, err := NewRegistry() @@ -61,8 +61,8 @@ func TestNewRegistry(t *testing.T) { if reg == nil { t.Fatal("expected registry") } - if !reg.Supports(openai.Name) { - t.Fatalf("expected builtin registry to support %q", openai.Name) + if !reg.Supports(openai.DriverName) { + t.Fatalf("expected builtin registry to support %q", openai.DriverName) } } @@ -76,7 +76,7 @@ func TestNewRegistryBuildsRegisteredDriver(t *testing.T) { _, err = reg.Build(context.Background(), config.ResolvedProviderConfig{ ProviderConfig: config.ProviderConfig{ - Driver: openai.Name, + Driver: openai.DriverName, }, }) if err == nil {