feat: Phase 1 code-agent capabilities — file_edit, git tools, parallel execution, tool output preview

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
This commit is contained in:
loveuer
2026-06-24 19:47:14 -07:00
parent 7b8bad5e62
commit 3d403bd685
13 changed files with 1135 additions and 39 deletions
+172 -1
View File
@@ -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 := &parallelProvider{
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 := &parallelProvider{
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})
}