From 3d403bd685f33c5f1df0b7b66412587fd8e63f16 Mon Sep 17 00:00:00 2001 From: loveuer Date: Wed, 24 Jun 2026 19:47:14 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20code-agent=20capabilities?= =?UTF-8?q?=20=E2=80=94=20file=5Fedit,=20git=20tools,=20parallel=20executi?= =?UTF-8?q?on,=20tool=20output=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tools: - file_edit: surgical line-range or pattern edits (yolo only) - git_status: show changed files (read-only) - git_diff: show unstaged/staged diff (read-only) - git_log: show recent commits (read-only) Agent improvements: - parallel execution for independent read-only tool calls - mutating tools (file_write, file_edit, shell_run) run sequentially - tool output preview in logs with [output] marker TUI improvements: - tool cards now show truncated output preview (up to 8 lines) - mergeToolLog updates placeholder cards with results - toolStatusText keeps activity bar single-line Documentation: - README updated with git tools and file_edit descriptions --- README.md | 10 +- internal/agent/agent.go | 113 +++++++++++--- internal/agent/agent_test.go | 173 +++++++++++++++++++++- internal/tools/file.go | 1 + internal/tools/file_edit.go | 178 ++++++++++++++++++++++ internal/tools/file_edit_test.go | 136 +++++++++++++++++ internal/tools/git.go | 246 +++++++++++++++++++++++++++++++ internal/tools/git_test.go | 142 ++++++++++++++++++ internal/tools/shell.go | 1 + internal/tools/tools.go | 30 +++- internal/tools/tools_test.go | 28 +++- internal/tui/app.go | 79 ++++++++-- internal/tui/app_test.go | 37 +++++ 13 files changed, 1135 insertions(+), 39 deletions(-) create mode 100644 internal/tools/file_edit.go create mode 100644 internal/tools/file_edit_test.go create mode 100644 internal/tools/git.go create mode 100644 internal/tools/git_test.go diff --git a/README.md b/README.md index 26eae04..fe25b7e 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,9 @@ Read-only tools are available by default: - `file_list` - `file_search` with optional `context_lines` and `max_results` - `code_symbols` for Go packages, imports, types, functions, and methods +- `git_status` shows changed files +- `git_diff` shows unstaged or staged diff output +- `git_log` shows recent commit history Web tools are also available in read-only mode: @@ -133,8 +136,9 @@ Web tools are also available in read-only mode: `--yolo` additionally enables: -- `file_write` -- `shell_run` +- `file_write` for creating or replacing whole files +- `file_edit` for surgical line-range or pattern edits to existing files +- `shell_run` for non-interactive shell commands Use `--yolo` for prompts that ask agentu to run or inspect local commands, for example: @@ -143,6 +147,8 @@ example: 请帮我运行 `go test ./...` 并总结结果 ``` + + ## Development Run the verification suite: diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 4f70855..c282966 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -7,18 +7,22 @@ import ( "io" "sort" "strings" + "sync" "time" - "agentu/pkg/llm" "agentu/internal/tools" + "agentu/pkg/llm" ) const ( - defaultMaxToolRounds = 50 - doomLoopThreshold = 3 - compactRecentMessages = 6 - summaryMessageLimit = 8000 - contextSummaryPrefix = "[context summary]" + defaultMaxToolRounds = 50 + doomLoopThreshold = 3 + compactRecentMessages = 6 + summaryMessageLimit = 8000 + contextSummaryPrefix = "[context summary]" + toolLogArgumentLimit = 400 + toolLogOutputPreviewBytes = 4096 + toolLogOutputMarker = "[output]" summarizerSystemPrompt = "You are a conversation summarizer. Summarize the following conversation history into a concise but detailed summary. Preserve:\n" + "- Key facts, decisions, and conclusions\n" + @@ -29,6 +33,11 @@ const ( "Output only the summary, no preamble." ) +type toolExecutionResult struct { + call llm.ToolCall + output string +} + type Agent struct { provider llm.Provider providerName string @@ -364,14 +373,15 @@ func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs i } } - for _, call := range calls { - result := a.executeTool(ctx, call, logs) + results := a.executeTools(ctx, calls, logs) + for _, result := range results { a.messages = append(a.messages, llm.Message{ Role: llm.RoleTool, - ToolCallID: call.ID, - Content: result, + ToolCallID: result.call.ID, + Content: result.output, }) } + } return fmt.Errorf("tool round limit reached after %d rounds; the task may be too complex for a single turn", a.maxToolRounds) @@ -433,11 +443,8 @@ func (a *Agent) toolDefinitions() []llm.Tool { return a.toolRegistry.Definitions() } -func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writer) string { +func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall) string { name := call.Function.Name - if logs != nil { - fmt.Fprintf(logs, "\n[tool] %s %s\n", name, compact(call.Function.Arguments, 400)) - } if a.toolRegistry == nil { return tools.FormatError(fmt.Errorf("tool registry is disabled")) @@ -461,12 +468,84 @@ func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writ } return tools.FormatError(err) } - if logs != nil { - fmt.Fprintf(logs, "[tool] %s done\n", name) - } return output } +func (a *Agent) hasMutatingCall(calls []llm.ToolCall) bool { + if a.toolRegistry == nil { + return false + } + for _, call := range calls { + if tool, ok := a.toolRegistry.Get(call.Function.Name); ok { + if tools.IsMutating(tool) { + return true + } + } + } + return false +} + +func (a *Agent) executeTools(ctx context.Context, calls []llm.ToolCall, logs io.Writer) []toolExecutionResult { + results := make([]toolExecutionResult, len(calls)) + + // Log all tool starts sequentially so TUI gets ordered placeholders. + if logs != nil { + for _, call := range calls { + logToolStart(logs, call) + } + } + + if a.hasMutatingCall(calls) { + // Sequential: avoid racing mutating tools. + for i, call := range calls { + output := a.executeTool(ctx, call) + results[i] = toolExecutionResult{call: call, output: output} + if logs != nil { + logToolOutput(logs, call, output) + } + } + return results + } + + // Concurrent: independent read-only tools. + var mu sync.Mutex + var wg sync.WaitGroup + for i, call := range calls { + wg.Add(1) + go func(idx int, c llm.ToolCall) { + defer wg.Done() + output := a.executeTool(ctx, c) + results[idx] = toolExecutionResult{call: c, output: output} + if logs != nil { + mu.Lock() + logToolOutput(logs, c, output) + mu.Unlock() + } + }(i, call) + } + wg.Wait() + return results +} + +func logToolStart(logs io.Writer, call llm.ToolCall) { + fmt.Fprintf(logs, "\n[tool] %s %s\n", call.Function.Name, compact(call.Function.Arguments, toolLogArgumentLimit)) +} + +func logToolOutput(logs io.Writer, call llm.ToolCall, output string) { + fmt.Fprintf(logs, "\n[tool] %s %s\n%s\n%s\n", call.Function.Name, compact(call.Function.Arguments, toolLogArgumentLimit), toolLogOutputMarker, toolOutputPreview(output)) +} + +func toolOutputPreview(output string) string { + output = strings.TrimSpace(output) + if output == "" { + return "(no output)" + } + if len(output) <= toolLogOutputPreviewBytes { + return output + } + return output[:toolLogOutputPreviewBytes] + fmt.Sprintf("\n[truncated: showing first %d bytes; omitted %d bytes]", toolLogOutputPreviewBytes, len(output)-toolLogOutputPreviewBytes) +} + type toolCallBuilder struct { index int id string diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 7db492a..c51180c 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "agentu/pkg/llm" "agentu/internal/tools" + "agentu/pkg/llm" ) type fakeProvider struct { @@ -340,3 +340,174 @@ func TestAgentHardLimitStopsVaryingCalls(t *testing.T) { t.Fatalf("calls = %d, want %d", provider.calls, limit) } } + +// slowTool blocks on a channel before returning. +type slowTool struct { + name string + started chan<- string + release <-chan struct{} +} + +func (t *slowTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: t.name, + Description: "slow test tool", + Parameters: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string"}}}`), + }, + } +} + +func (t *slowTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args struct { + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &args); err != nil { + return "", err + } + t.started <- t.name + <-t.release + return args.Text, nil +} + +// mutatingSlowTool is like slowTool but implements MutatingTool. +type mutatingSlowTool struct { + slowTool +} + +func (*mutatingSlowTool) Mutates() bool { return true } + +func TestAgentExecutesIndependentToolCallsInParallel(t *testing.T) { + started := make(chan string, 2) + release := make(chan struct{}) + + tool1 := &slowTool{name: "slow_one", started: started, release: release} + tool2 := &slowTool{name: "slow_two", started: started, release: release} + + provider := ¶llelProvider{ + responses: [][]llm.ToolCallDelta{ + { + {Index: 0, ID: "call_1", Type: "function", Name: "slow_one", Arguments: `{"text":"one"}`}, + {Index: 1, ID: "call_2", Type: "function", Name: "slow_two", Arguments: `{"text":"two"}`}, + }, + }, + finalContent: "done", + } + + a := New(Options{ + Provider: provider, + Model: "test", + SystemPrompt: "system", + ToolRegistry: tools.NewRegistry(tool1, tool2), + }) + + var out strings.Builder + done := make(chan error, 1) + go func() { + done <- a.RunTurn(context.Background(), "run both", &out, nil) + }() + + // Both tools must start before we release them. + names := make(map[string]bool) + for i := 0; i < 2; i++ { + select { + case name := <-started: + names[name] = true + case <-make(chan struct{}): + // Use a timer in real code, but for test simplicity we rely on deadlock detection. + t.Fatal("timed out waiting for tool to start") + } + } + if !names["slow_one"] || !names["slow_two"] { + t.Fatalf("expected both tools to start, got %v", names) + } + close(release) + + if err := <-done; err != nil { + t.Fatal(err) + } + if out.String() != "done" { + t.Fatalf("out = %q", out.String()) + } + // Verify tool results were in order. + msgs := a.Messages() + var toolMsgs []string + for _, m := range msgs { + if m.Role == llm.RoleTool { + toolMsgs = append(toolMsgs, m.Content) + } + } + if len(toolMsgs) != 2 || toolMsgs[0] != "one" || toolMsgs[1] != "two" { + t.Fatalf("tool messages = %v, want [one two]", toolMsgs) + } +} + +func TestAgentRunsMutatingToolCallsSequentially(t *testing.T) { + started := make(chan string, 2) + release := make(chan struct{}) + + tool1 := &mutatingSlowTool{slowTool{name: "mut_one", started: started, release: release}} + tool2 := &mutatingSlowTool{slowTool{name: "mut_two", started: started, release: release}} + + provider := ¶llelProvider{ + responses: [][]llm.ToolCallDelta{ + { + {Index: 0, ID: "call_1", Type: "function", Name: "mut_one", Arguments: `{"text":"one"}`}, + {Index: 1, ID: "call_2", Type: "function", Name: "mut_two", Arguments: `{"text":"two"}`}, + }, + }, + finalContent: "done", + } + + a := New(Options{ + Provider: provider, + Model: "test", + SystemPrompt: "system", + ToolRegistry: tools.NewRegistry(tool1, tool2), + }) + + var out strings.Builder + done := make(chan error, 1) + go func() { + done <- a.RunTurn(context.Background(), "run both", &out, nil) + }() + + // Only the first tool should start. + select { + case name := <-started: + if name != "mut_one" { + t.Fatalf("expected mut_one to start first, got %q", name) + } + case <-make(chan struct{}): + t.Fatal("timed out waiting for first tool to start") + } + + // Second tool should NOT have started yet. + select { + case name := <-started: + t.Fatalf("second tool started too early: %q", name) + default: + // Expected: second tool hasn't started. + } + + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +// parallelProvider emits a batch of tool calls on the first request, then returns finalContent. +type parallelProvider struct { + responses [][]llm.ToolCallDelta + finalContent string + calls int +} + +func (p *parallelProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error { + p.calls++ + if p.calls <= len(p.responses) { + return emit(llm.StreamEvent{ToolCalls: p.responses[p.calls-1]}) + } + return emit(llm.StreamEvent{Content: p.finalContent}) +} diff --git a/internal/tools/file.go b/internal/tools/file.go index 5394c56..9f3c78c 100644 --- a/internal/tools/file.go +++ b/internal/tools/file.go @@ -81,6 +81,7 @@ type FileWriteTool struct { func NewFileWriteTool(workingDir string) *FileWriteTool { return &FileWriteTool{workingDir: workingDir} } +func (*FileWriteTool) Mutates() bool { return true } func (t *FileWriteTool) Definition() llm.Tool { return llm.Tool{ diff --git a/internal/tools/file_edit.go b/internal/tools/file_edit.go new file mode 100644 index 0000000..3c84535 --- /dev/null +++ b/internal/tools/file_edit.go @@ -0,0 +1,178 @@ +package tools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "agentu/pkg/llm" +) + +type FileEditTool struct { + workingDir string +} + +func NewFileEditTool(workingDir string) *FileEditTool { + return &FileEditTool{workingDir: workingDir} +} + +func (*FileEditTool) Mutates() bool { return true } + +func (t *FileEditTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: "file_edit", + Description: "Edit an existing UTF-8 text file by replacing an inclusive 1-based line range or an exact old text pattern.", + Parameters: JSONSchema(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Existing file path relative to the configured agent working directory."}, + "start_line": {"type": "integer", "description": "1-based first line for a line-range edit."}, + "end_line": {"type": "integer", "description": "Optional 1-based final line; defaults to start_line when omitted."}, + "old": {"type": "string", "description": "Exact text to replace for a pattern edit; not regex."}, + "new": {"type": "string", "description": "Replacement text; may be empty string for deletion."}, + "replace_all": {"type": "boolean", "description": "When true, replace every exact old occurrence; otherwise require exactly one occurrence."} + }, + "required": ["path", "new"], + "additionalProperties": false +}`), + }, + } +} + +type fileEditArgs struct { + Path string `json:"path"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + Old string `json:"old"` + New *string `json:"new"` + ReplaceAll bool `json:"replace_all"` +} + +func (t *FileEditTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args fileEditArgs + if err := json.Unmarshal(raw, &args); err != nil { + return "", fmt.Errorf("parse arguments: %w", err) + } + if strings.TrimSpace(args.Path) == "" { + return "", errors.New("path is required") + } + if args.New == nil { + return "", errors.New("new is required") + } + path, err := resolvePath(t.workingDir, args.Path) + if err != nil { + return "", err + } + + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("cannot access %s: %w", args.Path, err) + } + if info.IsDir() { + return "", fmt.Errorf("%s is a directory, not a file", args.Path) + } + perm := info.Mode().Perm() + + hasLine := args.StartLine > 0 || args.EndLine > 0 + hasOld := strings.TrimSpace(args.Old) != "" + + if hasLine && hasOld { + return "", errors.New("provide either start_line/end_line or old, not both") + } + if !hasLine && !hasOld { + return "", errors.New("provide either start_line/end_line or old") + } + + if err := ctx.Err(); err != nil { + return "", err + } + + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + content := string(data) + + var result string + if hasOld { + result, err = editByPattern(content, args.Path, args.Old, *args.New, args.ReplaceAll) + } else { + result, err = editByLineRange(content, args.Path, args.StartLine, args.EndLine, *args.New) + } + if err != nil { + return "", err + } + + if err := ctx.Err(); err != nil { + return "", err + } + if err := os.WriteFile(path, []byte(result), perm); err != nil { + return "", err + } + + switch { + case hasOld: + count := strings.Count(content, args.Old) + if args.ReplaceAll { + return fmt.Sprintf("edited %s: replaced %d occurrences", args.Path, count), nil + } + return fmt.Sprintf("edited %s: replaced 1 occurrence", args.Path), nil + default: + endLine := args.EndLine + if endLine == 0 { + endLine = args.StartLine + } + return fmt.Sprintf("edited %s: replaced lines %d-%d", args.Path, args.StartLine, endLine), nil + } +} + +func editByLineRange(content, displayPath string, startLine, endLine int, replacement string) (string, error) { + if startLine < 1 { + return "", errors.New("start_line must be >= 1") + } + if endLine == 0 { + endLine = startLine + } + if endLine < startLine { + return "", fmt.Errorf("end_line (%d) must be >= start_line (%d)", endLine, startLine) + } + + lines := strings.Split(content, "\n") + if startLine > len(lines) { + return "", fmt.Errorf("line range %d-%d is outside file with %d lines", startLine, endLine, len(lines)) + } + if endLine > len(lines) { + return "", fmt.Errorf("line range %d-%d is outside file with %d lines", startLine, endLine, len(lines)) + } + + var b strings.Builder + b.WriteString(strings.Join(lines[:startLine-1], "\n")) + if startLine > 1 { + b.WriteString("\n") + } + b.WriteString(replacement) + if endLine < len(lines) { + if !strings.HasSuffix(replacement, "\n") && replacement != "" { + b.WriteString("\n") + } + b.WriteString(strings.Join(lines[endLine:], "\n")) + } + + return b.String(), nil +} + +func editByPattern(content, displayPath, old, replacement string, replaceAll bool) (string, error) { + count := strings.Count(content, old) + if count == 0 { + return "", errors.New("old text not found") + } + if !replaceAll && count != 1 { + return "", fmt.Errorf("old text matched %d occurrences; set replace_all=true or provide a more specific old value", count) + } + return strings.ReplaceAll(content, old, replacement), nil +} diff --git a/internal/tools/file_edit_test.go b/internal/tools/file_edit_test.go new file mode 100644 index 0000000..0683208 --- /dev/null +++ b/internal/tools/file_edit_test.go @@ -0,0 +1,136 @@ +package tools + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFileEditReplacesLineRange(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "notes.txt") + if err := os.WriteFile(path, []byte("one\ntwo\nthree\n"), 0o644); err != nil { + t.Fatal(err) + } + + edit := NewFileEditTool(dir) + out, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","start_line":2,"end_line":2,"new":"TWO\n"}`)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "replaced lines 2-2") { + t.Fatalf("output = %q", out) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "one\nTWO\nthree\n" { + t.Fatalf("file content = %q", string(got)) + } +} + +func TestFileEditReplacesExactPattern(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "notes.txt") + if err := os.WriteFile(path, []byte("alpha beta alpha"), 0o644); err != nil { + t.Fatal(err) + } + + edit := NewFileEditTool(dir) + out, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"beta","new":"BETA"}`)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "replaced 1 occurrence") { + t.Fatalf("output = %q", out) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "alpha BETA alpha" { + t.Fatalf("file content = %q", string(got)) + } +} + +func TestFileEditRejectsAmbiguousPattern(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "notes.txt") + if err := os.WriteFile(path, []byte("x x"), 0o644); err != nil { + t.Fatal(err) + } + + edit := NewFileEditTool(dir) + _, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"x","new":"y"}`)) + if err == nil { + t.Fatal("expected error for ambiguous pattern") + } + if !strings.Contains(err.Error(), "matched 2 occurrences") { + t.Fatalf("error = %v", err) + } +} + +func TestFileEditReplaceAll(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "notes.txt") + if err := os.WriteFile(path, []byte("x x"), 0o644); err != nil { + t.Fatal(err) + } + + edit := NewFileEditTool(dir) + out, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"x","new":"y","replace_all":true}`)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "replaced 2 occurrences") { + t.Fatalf("output = %q", out) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "y y" { + t.Fatalf("file content = %q", string(got)) + } +} + +func TestFileEditRejectsEscapingPath(t *testing.T) { + dir := t.TempDir() + edit := NewFileEditTool(dir) + _, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"../outside.txt","old":"x","new":"y"}`)) + if err == nil { + t.Fatal("expected error for escaping path") + } + if !strings.Contains(err.Error(), "escapes") { + t.Fatalf("error = %v", err) + } +} + +func TestFileEditRejectsMissingNew(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "notes.txt") + if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + edit := NewFileEditTool(dir) + _, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"hello"}`)) + if err == nil { + t.Fatal("expected error for missing new") + } + if !strings.Contains(err.Error(), "new is required") { + t.Fatalf("error = %v", err) + } +} + +func TestFileEditRejectsNonExistentFile(t *testing.T) { + dir := t.TempDir() + edit := NewFileEditTool(dir) + _, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"missing.txt","old":"x","new":"y"}`)) + if err == nil { + t.Fatal("expected error for non-existent file") + } +} diff --git a/internal/tools/git.go b/internal/tools/git.go new file mode 100644 index 0000000..2750ed9 --- /dev/null +++ b/internal/tools/git.go @@ -0,0 +1,246 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "strings" + + "agentu/pkg/llm" +) + +const ( + defaultGitLogMaxCount = 20 + maxGitLogMaxCount = 100 +) + +func runGit(ctx context.Context, workingDir string, args ...string) (string, error) { + gitArgs := append([]string{"-C", workingDir}, args...) + cmd := exec.CommandContext(ctx, "git", gitArgs...) + output, err := cmd.CombinedOutput() + text := FormatResult(string(output)) + if err != nil { + if strings.Contains(err.Error(), "context deadline exceeded") || strings.Contains(err.Error(), "signal: killed") { + if text != "" { + return text, fmt.Errorf("git command timed out: %w", err) + } + return "", fmt.Errorf("git command timed out: %w", err) + } + if text != "" { + return text, fmt.Errorf("git command failed: %w", err) + } + return "", fmt.Errorf("git command failed: %w", err) + } + return text, nil +} + +func gitPathspec(baseDir, path string) (string, error) { + if strings.TrimSpace(path) == "" { + return "", nil + } + resolved, err := resolvePath(baseDir, path) + if err != nil { + return "", err + } + rel, err := filepath.Rel(baseDir, resolved) + if err != nil { + return "", err + } + return filepath.ToSlash(filepath.Clean(rel)), nil +} + +func normalizeGitLogMaxCount(value int) int { + if value <= 0 { + return defaultGitLogMaxCount + } + if value > maxGitLogMaxCount { + return maxGitLogMaxCount + } + return value +} + +// git_status + +type GitStatusTool struct { + workingDir string +} + +func NewGitStatusTool(workingDir string) *GitStatusTool { + return &GitStatusTool{workingDir: workingDir} +} + +func (t *GitStatusTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: "git_status", + Description: "Show changed files using git status --short.", + Parameters: JSONSchema(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Optional path to limit status to. Relative paths are resolved from the configured agent working directory."} + }, + "additionalProperties": false +}`), + }, + } +} + +type gitStatusArgs struct { + Path string `json:"path"` +} + +func (t *GitStatusTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args gitStatusArgs + if len(raw) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return "", fmt.Errorf("parse arguments: %w", err) + } + } + gitArgs := []string{"status", "--short"} + if args.Path != "" { + pathspec, err := gitPathspec(t.workingDir, args.Path) + if err != nil { + return "", err + } + gitArgs = append(gitArgs, "--", pathspec) + } + text, err := runGit(ctx, t.workingDir, gitArgs...) + if err != nil { + return text, err + } + if strings.TrimSpace(text) == "" { + return "Working tree clean.", nil + } + return "Changed files:\n" + text, nil +} + +// git_diff + +type GitDiffTool struct { + workingDir string +} + +func NewGitDiffTool(workingDir string) *GitDiffTool { + return &GitDiffTool{workingDir: workingDir} +} + +func (t *GitDiffTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: "git_diff", + Description: "Show unstaged or staged git diff output, optionally as --stat.", + Parameters: JSONSchema(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Optional path to limit diff to. Relative paths are resolved from the configured agent working directory."}, + "staged": {"type": "boolean", "description": "Show staged diff instead of unstaged."}, + "stat": {"type": "boolean", "description": "Show diffstat summary instead of full diff."} + }, + "additionalProperties": false +}`), + }, + } +} + +type gitDiffArgs struct { + Path string `json:"path"` + Staged bool `json:"staged"` + Stat bool `json:"stat"` +} + +func (t *GitDiffTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args gitDiffArgs + if len(raw) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return "", fmt.Errorf("parse arguments: %w", err) + } + } + gitArgs := []string{"diff"} + if args.Staged { + gitArgs = append(gitArgs, "--cached") + } + if args.Stat { + gitArgs = append(gitArgs, "--stat") + } + if args.Path != "" { + pathspec, err := gitPathspec(t.workingDir, args.Path) + if err != nil { + return "", err + } + gitArgs = append(gitArgs, "--", pathspec) + } + text, err := runGit(ctx, t.workingDir, gitArgs...) + if err != nil { + return text, err + } + if strings.TrimSpace(text) == "" { + if args.Staged { + return "No staged diff.", nil + } + return "No unstaged diff.", nil + } + return text, nil +} + +// git_log + +type GitLogTool struct { + workingDir string +} + +func NewGitLogTool(workingDir string) *GitLogTool { + return &GitLogTool{workingDir: workingDir} +} + +func (t *GitLogTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: "git_log", + Description: "Show recent git commits with git log --oneline --decorate.", + Parameters: JSONSchema(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Optional path to limit log to. Relative paths are resolved from the configured agent working directory."}, + "max_count": {"type": "integer", "description": "Maximum number of commits to show. Defaults to 20, capped at 100."} + }, + "additionalProperties": false +}`), + }, + } +} + +type gitLogArgs struct { + Path string `json:"path"` + MaxCount int `json:"max_count"` +} + +func (t *GitLogTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args gitLogArgs + if len(raw) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return "", fmt.Errorf("parse arguments: %w", err) + } + } + maxCount := normalizeGitLogMaxCount(args.MaxCount) + gitArgs := []string{"log", "--oneline", "--decorate", fmt.Sprintf("-n%d", maxCount)} + if args.Path != "" { + pathspec, err := gitPathspec(t.workingDir, args.Path) + if err != nil { + return "", err + } + gitArgs = append(gitArgs, "--", pathspec) + } + text, err := runGit(ctx, t.workingDir, gitArgs...) + if err != nil { + return text, err + } + if strings.TrimSpace(text) == "" { + return "No git history.", nil + } + return text, nil +} diff --git a/internal/tools/git_test.go b/internal/tools/git_test.go new file mode 100644 index 0000000..b193775 --- /dev/null +++ b/internal/tools/git_test.go @@ -0,0 +1,142 @@ +package tools + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } +} + +func runGitForTest(t *testing.T, dir string, args ...string) { + t.Helper() + gitArgs := append([]string{"-C", dir}, args...) + cmd := exec.Command("git", gitArgs...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } +} + +func initTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + runGitForTest(t, dir, "init") + runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "initial") + return dir +} + +func TestGitStatusToolShowsChangedFiles(t *testing.T) { + requireGit(t) + dir := initTestRepo(t) + + path := filepath.Join(dir, "tracked.txt") + if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + runGitForTest(t, dir, "add", "tracked.txt") + runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "add tracked") + + if err := os.WriteFile(path, []byte("world"), 0o644); err != nil { + t.Fatal(err) + } + + tool := NewGitStatusTool(dir) + out, err := tool.Execute(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Changed files:") { + t.Fatalf("output missing 'Changed files:': %q", out) + } + if !strings.Contains(out, "M tracked.txt") { + t.Fatalf("output missing 'M tracked.txt': %q", out) + } +} + +func TestGitDiffToolShowsUnstagedDiff(t *testing.T) { + requireGit(t) + dir := initTestRepo(t) + + path := filepath.Join(dir, "tracked.txt") + if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + runGitForTest(t, dir, "add", "tracked.txt") + runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "add tracked") + + if err := os.WriteFile(path, []byte("world"), 0o644); err != nil { + t.Fatal(err) + } + + tool := NewGitDiffTool(dir) + out, err := tool.Execute(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "diff --git") { + t.Fatalf("output missing 'diff --git': %q", out) + } +} + +func TestGitDiffToolShowsStat(t *testing.T) { + requireGit(t) + dir := initTestRepo(t) + + path := filepath.Join(dir, "tracked.txt") + if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + runGitForTest(t, dir, "add", "tracked.txt") + runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "add tracked") + + if err := os.WriteFile(path, []byte("world"), 0o644); err != nil { + t.Fatal(err) + } + + tool := NewGitDiffTool(dir) + out, err := tool.Execute(context.Background(), json.RawMessage(`{"stat":true}`)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "tracked.txt") { + t.Fatalf("output missing 'tracked.txt': %q", out) + } +} + +func TestGitLogToolShowsCommits(t *testing.T) { + requireGit(t) + dir := initTestRepo(t) + + tool := NewGitLogTool(dir) + out, err := tool.Execute(context.Background(), json.RawMessage(`{"max_count":1}`)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "initial") { + t.Fatalf("output missing 'initial': %q", out) + } +} + +func TestGitToolsRejectEscapingPath(t *testing.T) { + requireGit(t) + dir := initTestRepo(t) + + tool := NewGitStatusTool(dir) + _, err := tool.Execute(context.Background(), json.RawMessage(`{"path":"../outside"}`)) + if err == nil { + t.Fatal("expected error for escaping path") + } + if !strings.Contains(err.Error(), "escapes") { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/tools/shell.go b/internal/tools/shell.go index df4e0cd..84fd57a 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -19,6 +19,7 @@ type ShellRunTool struct { func NewShellRunTool(workingDir string) *ShellRunTool { return &ShellRunTool{workingDir: workingDir} } +func (*ShellRunTool) Mutates() bool { return true } func (t *ShellRunTool) Definition() llm.Tool { return llm.Tool{ diff --git a/internal/tools/tools.go b/internal/tools/tools.go index c235187..7209fb6 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -19,6 +19,18 @@ type Tool interface { Execute(ctx context.Context, args json.RawMessage) (string, error) } +// MutatingTool is optionally implemented by tools that modify external state. +// It is used by the agent to determine whether tool calls can run in parallel. +type MutatingTool interface { + Mutates() bool +} + +// IsMutating reports whether tool implements MutatingTool and returns true. +func IsMutating(tool Tool) bool { + mutating, ok := tool.(MutatingTool) + return ok && mutating.Mutates() +} + type Registry struct { tools map[string]Tool primary []string @@ -66,30 +78,39 @@ func ReadOnlyBuiltins(workingDir string) *Registry { fileList := NewFileListTool(workingDir) fileSearch := NewFileSearchTool(workingDir) codeSymbols := NewCodeSymbolsTool(workingDir) + gitStatus := NewGitStatusTool(workingDir) + gitDiff := NewGitDiffTool(workingDir) + gitLog := NewGitLogTool(workingDir) - registry := NewRegistry(fileRead, fileList, fileSearch, codeSymbols) + registry := NewRegistry(fileRead, fileList, fileSearch, codeSymbols, gitStatus, gitDiff, gitLog) registry.RegisterAlias("file.read", fileRead) registry.RegisterAlias("file.list", fileList) registry.RegisterAlias("file.search", fileSearch) registry.RegisterAlias("code.symbols", codeSymbols) + registry.RegisterAlias("git.status", gitStatus) + registry.RegisterAlias("git.diff", gitDiff) + registry.RegisterAlias("git.log", gitLog) return registry } func Builtins(workingDir string) *Registry { registry := ReadOnlyBuiltins(workingDir) fileWrite := NewFileWriteTool(workingDir) + fileEdit := NewFileEditTool(workingDir) shellRun := NewShellRunTool(workingDir) registry.Register(fileWrite) + registry.Register(fileEdit) registry.Register(shellRun) registry.RegisterAlias("file.write", fileWrite) + registry.RegisterAlias("file.edit", fileEdit) registry.RegisterAlias("shell.run", shellRun) return registry } func AgentInstructions(workingDir string, yolo bool) string { searchInstructions := webSearchInstructions() - 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" + readOnlyTools := "file_list, file_read, file_search, code_symbols, git_status, git_diff, git_log, web_search, web_fetch" + yoloTools := "file_list, file_read, file_search, code_symbols, file_write, file_edit, shell_run, git_status, git_diff, git_log, web_search, web_fetch" if yolo { return fmt.Sprintf(`Local project context: - The project working directory is %q. @@ -99,7 +120,9 @@ func AgentInstructions(workingDir string, yolo bool) string { - 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. +- When editing existing files, prefer file_edit for small or localized changes; use file_write only for creating files, replacing whole files intentionally, or appending. - Do not use file tools as a substitute for a command execution request. +- When the user asks about changed files, diffs, or git history, use git_status, git_diff, or git_log instead of shell_run. - Do not start interactive or long-running sessions. Prefer bounded, non-interactive local commands with clear completion conditions. - %s - Tool paths should normally be relative to the project working directory. @@ -113,6 +136,7 @@ func AgentInstructions(workingDir string, yolo bool) string { - 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. +- When the user asks about changed files, diffs, or git history, use git_status, git_diff, or git_log instead of shell_run. - %s - Tool paths should normally be relative to the project working directory. - Available tools: %s.`, workingDir, searchInstructions, readOnlyTools) diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index b11ca74..9677af1 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -104,12 +104,12 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) { } } - for _, want := range []string{"file_list", "file_read", "file_search", "code_symbols"} { + for _, want := range []string{"file_list", "file_read", "file_search", "code_symbols", "git_status", "git_diff", "git_log"} { if !slices.Contains(names, want) { t.Fatalf("definitions missing %s: %#v", want, names) } } - for _, alias := range []string{"file.list", "file.read", "file.search", "code.symbols"} { + for _, alias := range []string{"file.list", "file.read", "file.search", "code.symbols", "git.status", "git.diff", "git.log"} { if _, ok := registry.Get(alias); !ok { t.Fatalf("alias missing: %s", alias) } @@ -117,6 +117,9 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) { if _, ok := registry.Get("file_write"); ok { t.Fatal("read-only registry should not expose file_write") } + if _, ok := registry.Get("file_edit"); ok { + t.Fatal("read-only registry should not expose file_edit") + } } func TestCodeSymbolsToolListsGoSymbols(t *testing.T) { @@ -170,7 +173,7 @@ func TestRegistryCanExposeWebSearch(t *testing.T) { func TestBuiltinsExposeYoloTools(t *testing.T) { registry := Builtins(t.TempDir()) - for _, name := range []string{"file_write", "shell_run", "file.write", "shell.run"} { + for _, name := range []string{"file_write", "file_edit", "shell_run", "file.write", "file.edit", "shell.run"} { if _, ok := registry.Get(name); !ok { t.Fatalf("tool missing: %s", name) } @@ -179,14 +182,14 @@ func TestBuiltinsExposeYoloTools(t *testing.T) { func TestAgentInstructionsExplainCommandModes(t *testing.T) { readOnly := AgentInstructions("/tmp/project", false) - for _, want := range []string{"Shell command execution is disabled", "--yolo", "Do not call tools for greetings", "Do not explore the project preemptively", "Do not call file_list just to discover context"} { + for _, want := range []string{"Shell command execution is disabled", "--yolo", "Do not call tools for greetings", "Do not explore the project preemptively", "Do not call file_list just to discover context", "git_status, git_diff, git_log"} { if !strings.Contains(readOnly, want) { t.Fatalf("read-only instructions missing %q:\n%s", want, readOnly) } } yolo := AgentInstructions("/tmp/project", true) - for _, want := range []string{"use shell_run", "commands written in backticks", "Do not start interactive", "non-interactive local commands", "Do not call tools for greetings"} { + for _, want := range []string{"use shell_run", "commands written in backticks", "Do not start interactive", "non-interactive local commands", "Do not call tools for greetings", "prefer file_edit"} { if !strings.Contains(yolo, want) { t.Fatalf("yolo instructions missing %q:\n%s", want, yolo) } @@ -195,7 +198,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, code_symbols, 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, git_status, git_diff, git_log, web_search, web_fetch"} { if !strings.Contains(withSearch, want) { t.Fatalf("web search instructions missing %q:\n%s", want, withSearch) } @@ -257,3 +260,16 @@ func TestResolvePathRejectsDotDotThatEscapes(t *testing.T) { t.Fatal("expected error for path escaping via ..") } } + +func TestIsMutating(t *testing.T) { + dir := t.TempDir() + if IsMutating(NewFileReadTool(dir)) { + t.Fatal("FileReadTool should not be mutating") + } + if !IsMutating(NewFileWriteTool(dir)) { + t.Fatal("FileWriteTool should be mutating") + } + if !IsMutating(NewShellRunTool(dir)) { + t.Fatal("ShellRunTool should be mutating") + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index ae5f8fd..f122595 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -290,8 +290,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case toolLogMsg: text := cleanToolLog(string(msg)) if text != "" { - m.messages = append(m.messages, message{role: roleTool, content: text}) - m.status = text + if !m.mergeToolLog(text) { + m.messages = append(m.messages, message{role: roleTool, content: text}) + } + m.status = toolStatusText(text) m.refreshViewport(true) } return m, m.waitForEvent() @@ -572,8 +574,9 @@ func roleLabel(r role) string { } type toolLogParts struct { - name string - args string + name string + args string + output string } func parseToolLog(content string) toolLogParts { @@ -581,14 +584,21 @@ func parseToolLog(content string) toolLogParts { if content == "" { return toolLogParts{} } - idx := strings.IndexFunc(content, unicode.IsSpace) + beforeOutput, output, hasOutput := strings.Cut(content, "\n[output]\n") + parts := toolLogParts{} + if hasOutput { + parts.output = strings.TrimSpace(output) + } + firstLine := strings.SplitN(beforeOutput, "\n", 2)[0] + firstLine = strings.TrimSpace(firstLine) + idx := strings.IndexFunc(firstLine, unicode.IsSpace) if idx < 0 { - return toolLogParts{name: content} - } - return toolLogParts{ - name: strings.TrimSpace(content[:idx]), - args: strings.TrimSpace(content[idx+1:]), + parts.name = firstLine + return parts } + parts.name = strings.TrimSpace(firstLine[:idx]) + parts.args = strings.TrimSpace(firstLine[idx+1:]) + return parts } func (m model) toolMessageView(content string, width int) string { @@ -599,11 +609,60 @@ func (m model) toolMessageView(content string, width int) string { args := fitLine(parts.args, max(1, width-4)) lines = append(lines, m.styles.ToolValue.Render(args)) } + if parts.output != "" { + lines = append(lines, m.styles.ToolKey.Render("output")) + previewLines := toolOutputPreviewLines(parts.output, max(1, width-4)) + for _, line := range previewLines { + lines = append(lines, m.styles.ToolValue.Render(line)) + } + } bodyContent := strings.Join(lines, "\n") bodyWidth := messageBodyWidth(bodyContent, width, m.styles.ToolMessage) return m.styles.ToolMessage.Width(bodyWidth).Render(bodyContent) } +const maxToolOutputPreviewLines = 8 + +func toolStatusText(content string) string { + content = strings.TrimSpace(content) + if content == "" { + return "" + } + lines := strings.SplitN(content, "\n", 2) + return lines[0] +} + +func (m *model) mergeToolLog(text string) bool { + incoming := parseToolLog(text) + if incoming.output == "" { + return false + } + for i := len(m.messages) - 1; i >= 0; i-- { + if m.messages[i].role != roleTool { + continue + } + existing := parseToolLog(m.messages[i].content) + if existing.name == incoming.name && existing.args == incoming.args && existing.output == "" { + m.messages[i].content = text + return true + } + } + return false +} + +func toolOutputPreviewLines(output string, width int) []string { + lines := strings.Split(output, "\n") + if len(lines) > maxToolOutputPreviewLines { + lines = lines[:maxToolOutputPreviewLines] + lines = append(lines, fmt.Sprintf("[truncated: showing first %d lines]", maxToolOutputPreviewLines)) + } + result := make([]string, 0, len(lines)) + for _, line := range lines { + result = append(result, fitLine(line, width)) + } + return result +} + type metaPill struct { text string accent bool diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 5f7e97e..666f6e3 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -348,6 +348,43 @@ func TestToolMessageRendersStructuredCard(t *testing.T) { } } +func TestToolMessageRendersOutputPreview(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + m.messages = append(m.messages, message{role: roleTool, content: "file_read {\"path\":\"README.md\"}\n[output]\nfirst line\nsecond line"}) + + plain := stripANSI(m.renderMessages()) + for _, want := range []string{iconTool + " file_read", "README.md", "output", "first line", "second line"} { + if !strings.Contains(plain, want) { + t.Fatalf("tool card missing %q:\n%s", want, plain) + } + } +} + +func TestToolLogOutputUpdatesExistingToolCard(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`}) + + updated := m.mergeToolLog("file_read {\"path\":\"README.md\"}\n[output]\nhello") + if !updated { + t.Fatal("expected mergeToolLog to return true") + } + if len(m.messages) != 1 { + t.Fatalf("messages count = %d, want 1", len(m.messages)) + } + plain := stripANSI(m.renderMessages()) + if !strings.Contains(plain, "hello") { + t.Fatalf("rendered output missing 'hello':\n%s", plain) + } + + status := toolStatusText("file_read {\"path\":\"README.md\"}\n[output]\nhello world") + if strings.Contains(status, "hello") { + t.Fatalf("status should not contain output: %q", status) + } + if !strings.Contains(status, "file_read") { + t.Fatalf("status missing tool name: %q", status) + } +} + type tuiCompactProvider struct{} func (tuiCompactProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {