From 86f69f6dd3a78fa09f3c2be38663155bd2b705bc Mon Sep 17 00:00:00 2001 From: loveuer Date: Wed, 24 Jun 2026 10:13:28 +0800 Subject: [PATCH] =?UTF-8?q?wip:=20P1=20feature=20batch=20=E2=80=94=20retry?= =?UTF-8?q?,=20search,=20code=20symbols,=20diff/status/test=20commands,=20?= =?UTF-8?q?tool=20output=20head+tail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retry logic for transient LLM errors (openai.go) - file_search context_lines + max_results (file.go) - FormatResult now preserves head+tail with omitted bytes (tools.go) - /status /diff /test slash commands wired (workflow.go, app.go) - CodeSymbolsTool for Go package/type/func listing (code.go) - README updated with v0.0.4 features --- README.md | 11 +- cmd/agentu/main.go | 1 + internal/llm/openai.go | 85 ++++++++++---- internal/llm/openai_test.go | 31 +++++ internal/tools/code.go | 219 +++++++++++++++++++++++++++++++++++ internal/tools/file.go | 71 ++++++++++-- internal/tools/tools.go | 47 +++++++- internal/tools/tools_test.go | 69 ++++++++++- internal/tui/app.go | 48 +++++--- internal/tui/app_test.go | 56 +++++++++ internal/tui/workflow.go | 143 +++++++++++++++++++++++ 11 files changed, 724 insertions(+), 57 deletions(-) create mode 100644 internal/tools/code.go create mode 100644 internal/tui/workflow.go diff --git a/README.md b/README.md index f7a24d1..15957a1 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ go run ./cmd/agentu [flags] - `--theme light|dark` selects the TUI theme. Default: `light`. - `--plain` uses the simple line-based REPL instead of the TUI. - `--yolo` enables automatic file writes and shell execution. +- `--resume ` resumes a saved session from `~/.agentu/sessions`. ## TUI Controls @@ -103,10 +104,17 @@ go run ./cmd/agentu [flags] Slash commands: - `/clear` resets conversation history. +- `/status` shows changed files with `git status --short`. +- `/diff [--stat] [path...]` shows the unstaged git diff. +- `/test [command...]` runs a project test command; defaults to `go test ./...` for Go modules or `npm test` for Node projects. - `/model` shows current provider, model, thinking, and switch usage. - `/model provider ` switches provider for the current session. - `/model model ` or `/model ` switches model for the current session. - `/model thinking ` changes thinking for the current session. +- `/sessions` lists saved sessions. +- `/resume [id]` resumes by ID or opens an interactive picker. +- `/rename ` renames the current session. +- `/new` saves the current session and starts a fresh one. - `/exit` or `/quit` exits. ## Local Tools @@ -115,7 +123,8 @@ Read-only tools are available by default: - `file_read` - `file_list` -- `file_search` +- `file_search` with optional `context_lines` and `max_results` +- `code_symbols` for Go packages, imports, types, functions, and methods Web tools are also available in read-only mode: diff --git a/cmd/agentu/main.go b/cmd/agentu/main.go index 56e1034..befe41d 100644 --- a/cmd/agentu/main.go +++ b/cmd/agentu/main.go @@ -114,6 +114,7 @@ func run() error { Yolo: *yolo, ThemeMode: themeMode, ModelManager: modelManager, + WorkingDir: cfg.Agent.WorkingDir, }) printExitSession(modelManager) return err diff --git a/internal/llm/openai.go b/internal/llm/openai.go index 47f1375..0854ada 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -10,8 +10,13 @@ import ( "io" "net/http" "strings" + "time" ) +const maxChatAttempts = 3 + +const retryDelay = 100 * time.Millisecond + type OpenAICompatibleClient struct { baseURL string apiKey string @@ -36,29 +41,69 @@ func (c *OpenAICompatibleClient) ChatStream(ctx context.Context, req ChatRequest return fmt.Errorf("marshal chat request: %w", err) } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("create chat request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Accept", "text/event-stream") - if c.apiKey != "" { - httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) - } + var lastErr error + for attempt := 1; attempt <= maxChatAttempts; attempt++ { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create chat request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + if c.apiKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + } - resp, err := c.httpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("send chat request: %w", err) - } - defer resp.Body.Close() + resp, err := c.httpClient.Do(httpReq) + if err != nil { + lastErr = fmt.Errorf("send chat request: %w", err) + if !shouldRetryRequest(ctx, attempt, 0) { + return lastErr + } + if waitErr := waitBeforeRetry(ctx); waitErr != nil { + return waitErr + } + continue + } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - limit := io.LimitReader(resp.Body, 4096) - data, _ := io.ReadAll(limit) - return fmt.Errorf("chat request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(data))) - } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + limit := io.LimitReader(resp.Body, 4096) + data, _ := io.ReadAll(limit) + _ = resp.Body.Close() + lastErr = fmt.Errorf("chat request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(data))) + if !shouldRetryRequest(ctx, attempt, resp.StatusCode) { + return lastErr + } + if waitErr := waitBeforeRetry(ctx); waitErr != nil { + return waitErr + } + continue + } - return readSSE(resp.Body, emit) + defer resp.Body.Close() + return readSSE(resp.Body, emit) + } + return lastErr +} + +func shouldRetryRequest(ctx context.Context, attempt int, status int) bool { + if ctx.Err() != nil || attempt >= maxChatAttempts { + return false + } + if status == 0 { + return true + } + return status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= 500 +} + +func waitBeforeRetry(ctx context.Context) error { + timer := time.NewTimer(retryDelay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } } func readSSE(r io.Reader, emit func(StreamEvent) error) error { diff --git a/internal/llm/openai_test.go b/internal/llm/openai_test.go index 62915ca..3cc38ab 100644 --- a/internal/llm/openai_test.go +++ b/internal/llm/openai_test.go @@ -66,6 +66,37 @@ func TestOpenAICompatibleClientStreamsContentAndToolCalls(t *testing.T) { } } +func TestOpenAICompatibleClientRetriesTransientHTTPError(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts == 1 { + http.Error(w, "temporary", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":"ok"},"finish_reason":""}]}` + "\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + client := NewOpenAICompatibleClient(server.URL, "sk-test", server.Client()) + var content strings.Builder + err := client.ChatStream(context.Background(), ChatRequest{Model: "test"}, func(event StreamEvent) error { + content.WriteString(event.Content) + return nil + }) + if err != nil { + t.Fatal(err) + } + if attempts != 2 { + t.Fatalf("attempts = %d, want 2", attempts) + } + if content.String() != "ok" { + t.Fatalf("content = %q", content.String()) + } +} + func mustReadBody(t *testing.T, r *http.Request) string { t.Helper() data, err := io.ReadAll(r.Body) diff --git a/internal/tools/code.go b/internal/tools/code.go new file mode 100644 index 0000000..4920223 --- /dev/null +++ b/internal/tools/code.go @@ -0,0 +1,219 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "agentu/internal/llm" +) + +const defaultCodeSymbolFileLimit = 50 + +type CodeSymbolsTool struct { + workingDir string +} + +func NewCodeSymbolsTool(workingDir string) *CodeSymbolsTool { + return &CodeSymbolsTool{workingDir: workingDir} +} + +func (t *CodeSymbolsTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: "code_symbols", + Description: "List Go code symbols (packages, imports, types, functions, methods) for a file or directory without reading full file contents.", + Parameters: JSONSchema(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Go file or directory to inspect. Defaults to the configured agent working directory."}, + "max_files": {"type": "integer", "description": "Maximum number of Go files to inspect when path is a directory. Defaults to 50."} + }, + "additionalProperties": false +}`), + }, + } +} + +type codeSymbolsArgs struct { + Path string `json:"path"` + MaxFiles int `json:"max_files"` +} + +func (t *CodeSymbolsTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args codeSymbolsArgs + if err := json.Unmarshal(raw, &args); err != nil { + return "", fmt.Errorf("parse arguments: %w", err) + } + path, err := resolvePath(t.workingDir, args.Path) + if err != nil { + return "", err + } + limit := args.MaxFiles + if limit <= 0 { + limit = defaultCodeSymbolFileLimit + } + + files, err := goFiles(ctx, path, limit) + if err != nil { + return "", err + } + if len(files) == 0 { + return "no Go files found", nil + } + + fset := token.NewFileSet() + var out strings.Builder + for i, file := range files { + if ctxErr := ctx.Err(); ctxErr != nil { + return FormatResult(out.String()), ctxErr + } + if i > 0 { + out.WriteString("\n") + } + if err := appendGoSymbols(&out, fset, file); err != nil { + fmt.Fprintf(&out, "%s\nerror: %v\n", file, err) + } + if out.Len() > MaxToolOutputBytes { + break + } + } + return FormatResult(out.String()), nil +} + +func goFiles(ctx context.Context, path string, limit int) ([]string, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.IsDir() { + if strings.HasSuffix(path, ".go") { + return []string{path}, nil + } + return nil, fmt.Errorf("path is not a Go file: %s", path) + } + + var files []string + stop := errors.New("file limit reached") + err = filepath.WalkDir(path, func(current string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + if d.IsDir() { + switch d.Name() { + case ".git", "vendor", "node_modules": + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(current, ".go") { + files = append(files, current) + if len(files) >= limit { + return stop + } + } + return nil + }) + if err != nil && !errors.Is(err, stop) { + return nil, err + } + sort.Strings(files) + return files, nil +} + +func appendGoSymbols(out *strings.Builder, fset *token.FileSet, path string) error { + file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly|parser.ParseComments) + if err != nil { + return err + } + full, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return err + } + + fmt.Fprintf(out, "%s\n", path) + fmt.Fprintf(out, " package %s\n", full.Name.Name) + if len(file.Imports) > 0 { + imports := make([]string, 0, len(file.Imports)) + for _, imp := range file.Imports { + imports = append(imports, strings.Trim(imp.Path.Value, "\"")) + } + fmt.Fprintf(out, " imports: %s\n", strings.Join(imports, ", ")) + } + + var types, funcs, methods []string + for _, decl := range full.Decls { + switch d := decl.(type) { + case *ast.GenDecl: + if d.Tok != token.TYPE { + continue + } + for _, spec := range d.Specs { + if ts, ok := spec.(*ast.TypeSpec); ok { + kind := typeKind(ts.Type) + types = append(types, fmt.Sprintf("%d: type %s %s", fset.Position(ts.Pos()).Line, ts.Name.Name, kind)) + } + } + case *ast.FuncDecl: + line := fset.Position(d.Pos()).Line + if d.Recv == nil { + funcs = append(funcs, fmt.Sprintf("%d: func %s", line, d.Name.Name)) + continue + } + methods = append(methods, fmt.Sprintf("%d: method %s.%s", line, receiverName(d.Recv), d.Name.Name)) + } + } + appendSymbolGroup(out, "types", types) + appendSymbolGroup(out, "funcs", funcs) + appendSymbolGroup(out, "methods", methods) + return nil +} + +func appendSymbolGroup(out *strings.Builder, title string, values []string) { + if len(values) == 0 { + return + } + fmt.Fprintf(out, " %s:\n", title) + for _, value := range values { + fmt.Fprintf(out, " - %s\n", value) + } +} + +func typeKind(expr ast.Expr) string { + switch expr.(type) { + case *ast.StructType: + return "struct" + case *ast.InterfaceType: + return "interface" + case *ast.FuncType: + return "func" + default: + return "alias" + } +} + +func receiverName(recv *ast.FieldList) string { + if recv == nil || len(recv.List) == 0 { + return "?" + } + var b bytes.Buffer + if err := printer.Fprint(&b, token.NewFileSet(), recv.List[0].Type); err != nil { + return "?" + } + return b.String() +} diff --git a/internal/tools/file.go b/internal/tools/file.go index 0c806c9..01220b4 100644 --- a/internal/tools/file.go +++ b/internal/tools/file.go @@ -223,7 +223,9 @@ func (t *FileSearchTool) Definition() llm.Tool { "properties": { "query": {"type": "string", "description": "Text or regular expression to search for."}, "path": {"type": "string", "description": "Optional directory or file to search. Defaults to the configured agent working directory."}, - "glob": {"type": "string", "description": "Optional file glob, for example '*.go'."} + "glob": {"type": "string", "description": "Optional file glob, for example '*.go'."}, + "context_lines": {"type": "integer", "description": "Optional number of context lines around each match. Defaults to 0."}, + "max_results": {"type": "integer", "description": "Optional maximum matches to return. Defaults to 100."} }, "required": ["query"], "additionalProperties": false @@ -233,9 +235,11 @@ func (t *FileSearchTool) Definition() llm.Tool { } type fileSearchArgs struct { - Query string `json:"query"` - Path string `json:"path"` - Glob string `json:"glob"` + Query string `json:"query"` + Path string `json:"path"` + Glob string `json:"glob"` + ContextLines int `json:"context_lines"` + MaxResults int `json:"max_results"` } func (t *FileSearchTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { @@ -256,8 +260,31 @@ func (t *FileSearchTool) Execute(ctx context.Context, raw json.RawMessage) (stri return t.walkSearch(ctx, args, path) } +func normalizedSearchOptions(args fileSearchArgs) (contextLines int, maxResults int) { + contextLines = args.ContextLines + if contextLines < 0 { + contextLines = 0 + } + if contextLines > 5 { + contextLines = 5 + } + maxResults = args.MaxResults + if maxResults <= 0 { + maxResults = 100 + } + if maxResults > 1000 { + maxResults = 1000 + } + return contextLines, maxResults +} + func (t *FileSearchTool) ripgrep(ctx context.Context, args fileSearchArgs, path string) (string, error) { rgArgs := []string{"--line-number", "--color", "never"} + contextLines, maxResults := normalizedSearchOptions(args) + if contextLines > 0 { + rgArgs = append(rgArgs, "--context", fmt.Sprint(contextLines)) + } + rgArgs = append(rgArgs, "--max-count", fmt.Sprint(maxResults)) if args.Glob != "" { rgArgs = append(rgArgs, "--glob", args.Glob) } @@ -282,7 +309,10 @@ func (t *FileSearchTool) ripgrep(ctx context.Context, args fileSearchArgs, path } func (t *FileSearchTool) walkSearch(ctx context.Context, args fileSearchArgs, root string) (string, error) { + contextLines, maxResults := normalizedSearchOptions(args) var out bytes.Buffer + matchCount := 0 + stop := errors.New("result limit reached") err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil @@ -312,16 +342,22 @@ func (t *FileSearchTool) walkSearch(ctx context.Context, args fileSearchArgs, ro } lines := strings.Split(string(data), "\n") for i, line := range lines { - if strings.Contains(line, args.Query) { - fmt.Fprintf(&out, "%s:%d:%s\n", path, i+1, line) - if out.Len() > MaxToolOutputBytes { - return errors.New("output limit reached") - } + if !strings.Contains(line, args.Query) { + continue + } + matchCount++ + appendSearchMatch(&out, path, lines, i, contextLines) + if matchCount >= maxResults { + fmt.Fprintf(&out, "[stopped after %d matches; narrow path/query or raise max_results]\n", maxResults) + return stop + } + if out.Len() > MaxToolOutputBytes { + return stop } } return nil }) - if err != nil && err.Error() != "output limit reached" { + if err != nil && !errors.Is(err, stop) { return "", err } if out.Len() == 0 { @@ -329,3 +365,18 @@ func (t *FileSearchTool) walkSearch(ctx context.Context, args fileSearchArgs, ro } return FormatResult(out.String()), nil } + +func appendSearchMatch(out *bytes.Buffer, path string, lines []string, matchIndex int, contextLines int) { + start := max(0, matchIndex-contextLines) + end := min(len(lines)-1, matchIndex+contextLines) + if contextLines > 0 { + fmt.Fprintf(out, "-- %s:%d --\n", path, matchIndex+1) + } + for i := start; i <= end; i++ { + marker := ":" + if i == matchIndex { + marker = "*" + } + fmt.Fprintf(out, "%s%s%d:%s\n", path, marker, i+1, lines[i]) + } +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go index 3879505..3b94f50 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -5,12 +5,15 @@ import ( "encoding/json" "fmt" "sort" + "strings" "agentu/internal/llm" ) const MaxToolOutputBytes = 64 * 1024 +const truncationNoticeBudget = 160 + type Tool interface { Definition() llm.Tool Execute(ctx context.Context, args json.RawMessage) (string, error) @@ -62,11 +65,13 @@ func ReadOnlyBuiltins(workingDir string) *Registry { fileRead := NewFileReadTool(workingDir) fileList := NewFileListTool(workingDir) fileSearch := NewFileSearchTool(workingDir) + codeSymbols := NewCodeSymbolsTool(workingDir) - registry := NewRegistry(fileRead, fileList, fileSearch) + registry := NewRegistry(fileRead, fileList, fileSearch, codeSymbols) registry.RegisterAlias("file.read", fileRead) registry.RegisterAlias("file.list", fileList) registry.RegisterAlias("file.search", fileSearch) + registry.RegisterAlias("code.symbols", codeSymbols) return registry } @@ -83,8 +88,8 @@ func Builtins(workingDir string) *Registry { func AgentInstructions(workingDir string, yolo bool) string { searchInstructions := webSearchInstructions() - readOnlyTools := "file_list, file_read, file_search, web_search, web_fetch" - yoloTools := "file_list, file_read, file_search, file_write, shell_run, web_search, web_fetch" + readOnlyTools := "file_list, file_read, file_search, code_symbols, web_search, web_fetch" + yoloTools := "file_list, file_read, file_search, code_symbols, file_write, shell_run, web_search, web_fetch" if yolo { return fmt.Sprintf(`Local project context: - The project working directory is %q. @@ -92,6 +97,7 @@ func AgentInstructions(workingDir string, yolo bool) string { - Do not call tools for greetings, small talk, simple Q&A, or general questions that do not require local project or command context. - Do not explore the project preemptively. Do not call file_list just to discover context unless the user asks about project files or the answer truly depends on local files. - When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files. +- When the user asks about code structure, symbols, functions, methods, types, or definitions in Go files, use code_symbols before reading whole files. - When the user asks to run, execute, test, check, debug, or inspect a shell command, use shell_run. This includes local commands written in backticks, such as go, make, git, pwd, ls, ps, and similar terminal commands. - Do not use file tools as a substitute for a command execution request. - Do not start interactive or long-running sessions. Prefer bounded, non-interactive local commands with clear completion conditions. @@ -105,6 +111,7 @@ func AgentInstructions(workingDir string, yolo bool) string { - Do not call tools for greetings, small talk, simple Q&A, or general questions that do not require local project context. - Do not explore the project preemptively. Do not call file_list just to discover context unless the user asks about project files or the answer truly depends on local files. - When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files. +- When the user asks about code structure, symbols, functions, methods, types, or definitions in Go files, use code_symbols before reading whole files. - Shell command execution is disabled in this mode. When the user asks to run, execute, test, check, debug, or inspect a shell command, do not use file tools as a substitute; explain that they need to restart agentu with --yolo to enable shell_run. - %s - Tool paths should normally be relative to the project working directory. @@ -120,10 +127,40 @@ func JSONSchema(schema string) json.RawMessage { } func FormatResult(output string) string { - if len(output) <= MaxToolOutputBytes { + return FormatResultLimit(output, MaxToolOutputBytes) +} + +func FormatResultLimit(output string, limit int) string { + if limit <= 0 || len(output) <= limit { return output } - return output[:MaxToolOutputBytes] + fmt.Sprintf("\n\n[truncated after %d bytes]", MaxToolOutputBytes) + if limit <= truncationNoticeBudget { + return output[:limit] + } + notice := fmt.Sprintf("\n\n[truncated: showing head and tail; omitted %d bytes]\n\n", len(output)-limit) + keep := limit - len(notice) + if keep <= 0 { + return output[:limit] + } + headLen := keep / 2 + tailLen := keep - headLen + head := trimHeadAtLine(output[:headLen]) + tail := trimTailAtLine(output[len(output)-tailLen:]) + return head + notice + tail +} + +func trimHeadAtLine(value string) string { + if idx := strings.LastIndexByte(value, '\n'); idx > 0 { + return value[:idx+1] + } + return value +} + +func trimTailAtLine(value string) string { + if idx := strings.IndexByte(value, '\n'); idx >= 0 && idx+1 < len(value) { + return value[idx+1:] + } + return value } func FormatError(err error) string { diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index df8768b..08773df 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -43,6 +43,41 @@ func TestFileTools(t *testing.T) { } } +func TestFileSearchSupportsContextAndMaxResults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "notes.txt") + content := strings.Join([]string{"before", "needle one", "middle", "needle two", "after"}, "\n") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + search := NewFileSearchTool(dir) + out, err := search.walkSearch(context.Background(), fileSearchArgs{Query: "needle", ContextLines: 1, MaxResults: 1}, dir) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"-- ", "before", "needle one", "middle", "stopped after 1 matches"} { + if !strings.Contains(out, want) { + t.Fatalf("search output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "needle two") { + t.Fatalf("search should stop after max_results:\n%s", out) + } +} + +func TestFormatResultKeepsHeadAndTail(t *testing.T) { + input := strings.Join([]string{"head-1", "head-2", strings.Repeat("x", 220), "tail-1", "tail-2"}, "\n") + out := FormatResultLimit(input, 220) + for _, want := range []string{"head-1", "truncated: showing head and tail", "tail-2"} { + if !strings.Contains(out, want) { + t.Fatalf("formatted output missing %q:\n%s", want, out) + } + } + if len(out) > 220 { + t.Fatalf("formatted output len = %d, want <= 220", len(out)) + } +} + func TestShellRunTool(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "x.txt"), []byte("x"), 0o644); err != nil { @@ -69,12 +104,12 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) { } } - for _, want := range []string{"file_list", "file_read", "file_search"} { + for _, want := range []string{"file_list", "file_read", "file_search", "code_symbols"} { if !slices.Contains(names, want) { t.Fatalf("definitions missing %s: %#v", want, names) } } - for _, alias := range []string{"file.list", "file.read", "file.search"} { + for _, alias := range []string{"file.list", "file.read", "file.search", "code.symbols"} { if _, ok := registry.Get(alias); !ok { t.Fatalf("alias missing: %s", alias) } @@ -84,6 +119,34 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) { } } +func TestCodeSymbolsToolListsGoSymbols(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sample.go") + if err := os.WriteFile(path, []byte(`package sample + +import "context" + +type Worker struct{} +type Runner interface{ Run(context.Context) error } + +func NewWorker() *Worker { return &Worker{} } +func (w *Worker) Run(ctx context.Context) error { return nil } +`), 0o644); err != nil { + t.Fatal(err) + } + + tool := NewCodeSymbolsTool(dir) + out, err := tool.Execute(context.Background(), json.RawMessage(`{"path":"sample.go"}`)) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"package sample", "imports: context", "type Worker struct", "type Runner interface", "func NewWorker", "method *Worker.Run"} { + if !strings.Contains(out, want) { + t.Fatalf("symbols output missing %q:\n%s", want, out) + } + } +} + func TestRegistryCanExposeWebSearch(t *testing.T) { registry := ReadOnlyBuiltins(t.TempDir()) registry.Register(NewBuiltinSearchTool(nil)) @@ -132,7 +195,7 @@ func TestAgentInstructionsExplainCommandModes(t *testing.T) { func TestAgentInstructionsExplainWebSearch(t *testing.T) { withSearch := AgentInstructions("/tmp/project", false) - for _, want := range []string{"use web_search", "Use web_fetch", "latest information", "source URLs", "Available tools: file_list, file_read, file_search, web_search, web_fetch"} { + for _, want := range []string{"use web_search", "Use web_fetch", "latest information", "source URLs", "Available tools: file_list, file_read, file_search, code_symbols, web_search, web_fetch"} { if !strings.Contains(withSearch, want) { t.Fatalf("web search instructions missing %q:\n%s", want, withSearch) } diff --git a/internal/tui/app.go b/internal/tui/app.go index 71e91d5..ae546d6 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -28,6 +28,7 @@ type Options struct { Yolo bool ThemeMode ThemeMode ModelManager ModelManager + WorkingDir string } type ModelManager interface { @@ -69,13 +70,14 @@ type message struct { } type model struct { - ctx context.Context - agent *agent.Agent - modelName string - yolo bool - models ModelManager - theme theme - styles styles + ctx context.Context + agent *agent.Agent + modelName string + yolo bool + models ModelManager + workingDir string + theme theme + styles styles viewport viewport.Model input textarea.Model @@ -126,6 +128,9 @@ var slashCommands = []slashCommand{ {Name: "/resume", Description: "resume session"}, {Name: "/rename", Description: "rename session"}, {Name: "/new", Description: "new session"}, + {Name: "/status", Description: "show changed files"}, + {Name: "/diff", Description: "show unstaged diff"}, + {Name: "/test", Description: "run project tests"}, } func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model { @@ -155,17 +160,18 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model { vp.SetContent("") return model{ - ctx: ctx, - agent: assistant, - modelName: opts.ModelName, - yolo: opts.Yolo, - models: opts.ModelManager, - theme: th, - styles: st, - viewport: vp, - input: input, - spinner: spin, - status: "ready", + ctx: ctx, + agent: assistant, + modelName: opts.ModelName, + yolo: opts.Yolo, + models: opts.ModelManager, + workingDir: opts.WorkingDir, + theme: th, + styles: st, + viewport: vp, + input: input, + spinner: spin, + status: "ready", } } @@ -763,6 +769,12 @@ func (m model) handleLocalCommand(input string) (string, error) { return m.handleRenameCommand(fields[1:]) case "/new": return m.handleNewCommand() + case "/status": + return m.handleStatusCommand(fields[1:]) + case "/diff": + return m.handleDiffCommand(fields[1:]) + case "/test": + return m.handleTestCommand(fields[1:]) default: return "", fmt.Errorf("unknown command: %s", fields[0]) } diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 99b035f..2eb8fe6 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -2,6 +2,9 @@ package tui import ( "context" + "os" + "os/exec" + "path/filepath" "strings" "testing" @@ -271,6 +274,59 @@ func TestSlashCommandSuggestions(t *testing.T) { } } +func TestWorkflowStatusDiffAndTestCommands(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + dir := t.TempDir() + runInDir(t, dir, "git", "init") + runInDir(t, dir, "git", "config", "user.email", "agentu@example.test") + runInDir(t, dir, "git", "config", "user.name", "agentu") + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/p1\n\ngo 1.26\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main_test.go"), []byte("package p1\n\nimport \"testing\"\n\nfunc TestOK(t *testing.T) {}\n"), 0o644); err != nil { + t.Fatal(err) + } + runInDir(t, dir, "git", "add", ".") + runInDir(t, dir, "git", "commit", "-m", "init") + if err := os.WriteFile(filepath.Join(dir, "main_test.go"), []byte("package p1\n\nimport \"testing\"\n\nfunc TestOK(t *testing.T) { t.Log(\"changed\") }\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := newModel(context.Background(), nil, Options{ModelName: "test-model", WorkingDir: dir}) + status, err := m.handleStatusCommand(nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(status, "main_test.go") { + t.Fatalf("status missing changed file:\n%s", status) + } + diff, err := m.handleDiffCommand(nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(diff, "changed") { + t.Fatalf("diff missing changed content:\n%s", diff) + } + out, err := m.handleTestCommand(nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "$ go test ./...") { + t.Fatalf("test output missing default command:\n%s", out) + } +} + +func runInDir(t *testing.T, dir string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s %v failed: %v\n%s", name, args, err, output) + } +} + func TestModelCommand(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) m.input.SetValue("/model") diff --git a/internal/tui/workflow.go b/internal/tui/workflow.go new file mode 100644 index 0000000..bc23d3b --- /dev/null +++ b/internal/tui/workflow.go @@ -0,0 +1,143 @@ +package tui + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "agentu/internal/tools" +) + +const workflowCommandTimeout = 2 * time.Minute + +func (m model) handleStatusCommand(args []string) (string, error) { + if len(args) != 0 { + return "", fmt.Errorf("usage: /status") + } + out, err := m.runGitCommand("status", "--short") + if err != nil { + return out, err + } + if strings.TrimSpace(out) == "" { + return "Working tree clean.", nil + } + return "Changed files:\n" + out, nil +} + +func (m model) handleDiffCommand(args []string) (string, error) { + if len(args) > 0 && args[0] == "--stat" { + out, err := m.runGitCommand(append([]string{"diff", "--stat", "--"}, args[1:]...)...) + if err != nil { + return out, err + } + if strings.TrimSpace(out) == "" { + return "No unstaged diff.", nil + } + return out, nil + } + out, err := m.runGitCommand(append([]string{"diff", "--"}, args...)...) + if err != nil { + return out, err + } + if strings.TrimSpace(out) == "" { + return "No unstaged diff.", nil + } + return out, nil +} + +func (m model) handleTestCommand(args []string) (string, error) { + command := strings.TrimSpace(strings.Join(args, " ")) + if command == "" { + var err error + command, err = defaultTestCommand(m.workflowDir()) + if err != nil { + return "", err + } + } + out, err := m.runShellCommand(command) + if err != nil { + if strings.TrimSpace(out) != "" { + return out, err + } + return "", err + } + if strings.TrimSpace(out) == "" { + return fmt.Sprintf("$ %s\n(no output)", command), nil + } + return out, nil +} + +func (m model) runGitCommand(args ...string) (string, error) { + ctx, cancel := context.WithTimeout(m.ctx, workflowCommandTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", m.workflowDir()}, args...)...) + output, err := cmd.CombinedOutput() + text := tools.FormatResult(string(output)) + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return text, fmt.Errorf("git command timed out: %w", ctx.Err()) + } + if err != nil { + if strings.TrimSpace(text) != "" { + return text, fmt.Errorf("git command failed: %w", err) + } + return "", fmt.Errorf("git command failed: %w", err) + } + return text, nil +} + +func (m model) runShellCommand(command string) (string, error) { + ctx, cancel := context.WithTimeout(m.ctx, workflowCommandTimeout) + defer cancel() + + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + cmd := exec.CommandContext(ctx, shell, "-lc", command) + cmd.Dir = m.workflowDir() + + var combined bytes.Buffer + cmd.Stdout = &combined + cmd.Stderr = &combined + err := cmd.Run() + text := strings.TrimRight(tools.FormatResult(combined.String()), "\n") + if text != "" { + text = fmt.Sprintf("$ %s\n%s", command, text) + } + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return text, fmt.Errorf("command timed out: %w", ctx.Err()) + } + if err != nil { + return text, fmt.Errorf("command failed: %w", err) + } + return text, nil +} + +func (m model) workflowDir() string { + if strings.TrimSpace(m.workingDir) == "" { + return "." + } + return m.workingDir +} + +func defaultTestCommand(dir string) (string, error) { + if fileExists(filepath.Join(dir, "go.mod")) { + return "go test ./...", nil + } + if fileExists(filepath.Join(dir, "package.json")) { + return "npm test", nil + } + return "", fmt.Errorf("no default test command found; use /test ") +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +}