Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d403bd685 | |||
| 7b8bad5e62 |
@@ -125,6 +125,9 @@ Read-only tools are available by default:
|
|||||||
- `file_list`
|
- `file_list`
|
||||||
- `file_search` with optional `context_lines` and `max_results`
|
- `file_search` with optional `context_lines` and `max_results`
|
||||||
- `code_symbols` for Go packages, imports, types, functions, and methods
|
- `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:
|
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:
|
`--yolo` additionally enables:
|
||||||
|
|
||||||
- `file_write`
|
- `file_write` for creating or replacing whole files
|
||||||
- `shell_run`
|
- `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
|
Use `--yolo` for prompts that ask agentu to run or inspect local commands, for
|
||||||
example:
|
example:
|
||||||
@@ -143,6 +147,8 @@ example:
|
|||||||
请帮我运行 `go test ./...` 并总结结果
|
请帮我运行 `go test ./...` 并总结结果
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Run the verification suite:
|
Run the verification suite:
|
||||||
|
|||||||
+91
-12
@@ -7,10 +7,11 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"agentu/pkg/llm"
|
|
||||||
"agentu/internal/tools"
|
"agentu/internal/tools"
|
||||||
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -19,6 +20,9 @@ const (
|
|||||||
compactRecentMessages = 6
|
compactRecentMessages = 6
|
||||||
summaryMessageLimit = 8000
|
summaryMessageLimit = 8000
|
||||||
contextSummaryPrefix = "[context summary]"
|
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" +
|
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" +
|
"- Key facts, decisions, and conclusions\n" +
|
||||||
@@ -29,6 +33,11 @@ const (
|
|||||||
"Output only the summary, no preamble."
|
"Output only the summary, no preamble."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type toolExecutionResult struct {
|
||||||
|
call llm.ToolCall
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
provider llm.Provider
|
provider llm.Provider
|
||||||
providerName string
|
providerName string
|
||||||
@@ -364,14 +373,15 @@ func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, call := range calls {
|
results := a.executeTools(ctx, calls, logs)
|
||||||
result := a.executeTool(ctx, call, logs)
|
for _, result := range results {
|
||||||
a.messages = append(a.messages, llm.Message{
|
a.messages = append(a.messages, llm.Message{
|
||||||
Role: llm.RoleTool,
|
Role: llm.RoleTool,
|
||||||
ToolCallID: call.ID,
|
ToolCallID: result.call.ID,
|
||||||
Content: result,
|
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)
|
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()
|
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
|
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 {
|
if a.toolRegistry == nil {
|
||||||
return tools.FormatError(fmt.Errorf("tool registry is disabled"))
|
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)
|
return tools.FormatError(err)
|
||||||
}
|
}
|
||||||
if logs != nil {
|
|
||||||
fmt.Fprintf(logs, "[tool] %s done\n", name)
|
|
||||||
}
|
|
||||||
return output
|
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 {
|
type toolCallBuilder struct {
|
||||||
index int
|
index int
|
||||||
id string
|
id string
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"agentu/pkg/llm"
|
|
||||||
"agentu/internal/tools"
|
"agentu/internal/tools"
|
||||||
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeProvider struct {
|
type fakeProvider struct {
|
||||||
@@ -340,3 +340,174 @@ func TestAgentHardLimitStopsVaryingCalls(t *testing.T) {
|
|||||||
t.Fatalf("calls = %d, want %d", provider.calls, limit)
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ type FileWriteTool struct {
|
|||||||
func NewFileWriteTool(workingDir string) *FileWriteTool {
|
func NewFileWriteTool(workingDir string) *FileWriteTool {
|
||||||
return &FileWriteTool{workingDir: workingDir}
|
return &FileWriteTool{workingDir: workingDir}
|
||||||
}
|
}
|
||||||
|
func (*FileWriteTool) Mutates() bool { return true }
|
||||||
|
|
||||||
func (t *FileWriteTool) Definition() llm.Tool {
|
func (t *FileWriteTool) Definition() llm.Tool {
|
||||||
return llm.Tool{
|
return llm.Tool{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ type ShellRunTool struct {
|
|||||||
func NewShellRunTool(workingDir string) *ShellRunTool {
|
func NewShellRunTool(workingDir string) *ShellRunTool {
|
||||||
return &ShellRunTool{workingDir: workingDir}
|
return &ShellRunTool{workingDir: workingDir}
|
||||||
}
|
}
|
||||||
|
func (*ShellRunTool) Mutates() bool { return true }
|
||||||
|
|
||||||
func (t *ShellRunTool) Definition() llm.Tool {
|
func (t *ShellRunTool) Definition() llm.Tool {
|
||||||
return llm.Tool{
|
return llm.Tool{
|
||||||
|
|||||||
+27
-3
@@ -19,6 +19,18 @@ type Tool interface {
|
|||||||
Execute(ctx context.Context, args json.RawMessage) (string, error)
|
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 {
|
type Registry struct {
|
||||||
tools map[string]Tool
|
tools map[string]Tool
|
||||||
primary []string
|
primary []string
|
||||||
@@ -66,30 +78,39 @@ func ReadOnlyBuiltins(workingDir string) *Registry {
|
|||||||
fileList := NewFileListTool(workingDir)
|
fileList := NewFileListTool(workingDir)
|
||||||
fileSearch := NewFileSearchTool(workingDir)
|
fileSearch := NewFileSearchTool(workingDir)
|
||||||
codeSymbols := NewCodeSymbolsTool(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.read", fileRead)
|
||||||
registry.RegisterAlias("file.list", fileList)
|
registry.RegisterAlias("file.list", fileList)
|
||||||
registry.RegisterAlias("file.search", fileSearch)
|
registry.RegisterAlias("file.search", fileSearch)
|
||||||
registry.RegisterAlias("code.symbols", codeSymbols)
|
registry.RegisterAlias("code.symbols", codeSymbols)
|
||||||
|
registry.RegisterAlias("git.status", gitStatus)
|
||||||
|
registry.RegisterAlias("git.diff", gitDiff)
|
||||||
|
registry.RegisterAlias("git.log", gitLog)
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
func Builtins(workingDir string) *Registry {
|
func Builtins(workingDir string) *Registry {
|
||||||
registry := ReadOnlyBuiltins(workingDir)
|
registry := ReadOnlyBuiltins(workingDir)
|
||||||
fileWrite := NewFileWriteTool(workingDir)
|
fileWrite := NewFileWriteTool(workingDir)
|
||||||
|
fileEdit := NewFileEditTool(workingDir)
|
||||||
shellRun := NewShellRunTool(workingDir)
|
shellRun := NewShellRunTool(workingDir)
|
||||||
registry.Register(fileWrite)
|
registry.Register(fileWrite)
|
||||||
|
registry.Register(fileEdit)
|
||||||
registry.Register(shellRun)
|
registry.Register(shellRun)
|
||||||
registry.RegisterAlias("file.write", fileWrite)
|
registry.RegisterAlias("file.write", fileWrite)
|
||||||
|
registry.RegisterAlias("file.edit", fileEdit)
|
||||||
registry.RegisterAlias("shell.run", shellRun)
|
registry.RegisterAlias("shell.run", shellRun)
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
func AgentInstructions(workingDir string, yolo bool) string {
|
func AgentInstructions(workingDir string, yolo bool) string {
|
||||||
searchInstructions := webSearchInstructions()
|
searchInstructions := webSearchInstructions()
|
||||||
readOnlyTools := "file_list, file_read, file_search, code_symbols, 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, shell_run, 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 {
|
if yolo {
|
||||||
return fmt.Sprintf(`Local project context:
|
return fmt.Sprintf(`Local project context:
|
||||||
- The project working directory is %q.
|
- 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 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 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 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.
|
- 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.
|
- Do not start interactive or long-running sessions. Prefer bounded, non-interactive local commands with clear completion conditions.
|
||||||
- %s
|
- %s
|
||||||
- Tool paths should normally be relative to the project working directory.
|
- 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 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 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.
|
- 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
|
- %s
|
||||||
- Tool paths should normally be relative to the project working directory.
|
- Tool paths should normally be relative to the project working directory.
|
||||||
- Available tools: %s.`, workingDir, searchInstructions, readOnlyTools)
|
- Available tools: %s.`, workingDir, searchInstructions, readOnlyTools)
|
||||||
|
|||||||
@@ -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) {
|
if !slices.Contains(names, want) {
|
||||||
t.Fatalf("definitions missing %s: %#v", want, names)
|
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 {
|
if _, ok := registry.Get(alias); !ok {
|
||||||
t.Fatalf("alias missing: %s", alias)
|
t.Fatalf("alias missing: %s", alias)
|
||||||
}
|
}
|
||||||
@@ -117,6 +117,9 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) {
|
|||||||
if _, ok := registry.Get("file_write"); ok {
|
if _, ok := registry.Get("file_write"); ok {
|
||||||
t.Fatal("read-only registry should not expose file_write")
|
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) {
|
func TestCodeSymbolsToolListsGoSymbols(t *testing.T) {
|
||||||
@@ -170,7 +173,7 @@ func TestRegistryCanExposeWebSearch(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuiltinsExposeYoloTools(t *testing.T) {
|
func TestBuiltinsExposeYoloTools(t *testing.T) {
|
||||||
registry := Builtins(t.TempDir())
|
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 {
|
if _, ok := registry.Get(name); !ok {
|
||||||
t.Fatalf("tool missing: %s", name)
|
t.Fatalf("tool missing: %s", name)
|
||||||
}
|
}
|
||||||
@@ -179,14 +182,14 @@ func TestBuiltinsExposeYoloTools(t *testing.T) {
|
|||||||
|
|
||||||
func TestAgentInstructionsExplainCommandModes(t *testing.T) {
|
func TestAgentInstructionsExplainCommandModes(t *testing.T) {
|
||||||
readOnly := AgentInstructions("/tmp/project", false)
|
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) {
|
if !strings.Contains(readOnly, want) {
|
||||||
t.Fatalf("read-only instructions missing %q:\n%s", want, readOnly)
|
t.Fatalf("read-only instructions missing %q:\n%s", want, readOnly)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
yolo := AgentInstructions("/tmp/project", true)
|
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) {
|
if !strings.Contains(yolo, want) {
|
||||||
t.Fatalf("yolo instructions missing %q:\n%s", want, yolo)
|
t.Fatalf("yolo instructions missing %q:\n%s", want, yolo)
|
||||||
}
|
}
|
||||||
@@ -195,7 +198,7 @@ func TestAgentInstructionsExplainCommandModes(t *testing.T) {
|
|||||||
|
|
||||||
func TestAgentInstructionsExplainWebSearch(t *testing.T) {
|
func TestAgentInstructionsExplainWebSearch(t *testing.T) {
|
||||||
withSearch := AgentInstructions("/tmp/project", false)
|
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) {
|
if !strings.Contains(withSearch, want) {
|
||||||
t.Fatalf("web search instructions missing %q:\n%s", want, withSearch)
|
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 ..")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+338
-39
@@ -6,7 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"agentu/internal/agent"
|
"agentu/internal/agent"
|
||||||
"agentu/pkg/llm"
|
"agentu/pkg/llm"
|
||||||
@@ -21,6 +23,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
minInputLines = 1
|
minInputLines = 1
|
||||||
maxInputLines = 6
|
maxInputLines = 6
|
||||||
|
maxCommandPaletteItems = 8
|
||||||
|
maxInputHistory = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -89,6 +93,10 @@ type model struct {
|
|||||||
messages []message
|
messages []message
|
||||||
status string
|
status string
|
||||||
contextUsage string
|
contextUsage string
|
||||||
|
inputHistory []string
|
||||||
|
historyIndex int
|
||||||
|
draftInput string
|
||||||
|
|
||||||
running bool
|
running bool
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
events <-chan tea.Msg
|
events <-chan tea.Msg
|
||||||
@@ -120,6 +128,11 @@ type slashCommand struct {
|
|||||||
Description string
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type commandMatch struct {
|
||||||
|
command slashCommand
|
||||||
|
score int
|
||||||
|
}
|
||||||
|
|
||||||
var slashCommands = []slashCommand{
|
var slashCommands = []slashCommand{
|
||||||
{Name: "/clear", Description: "reset context"},
|
{Name: "/clear", Description: "reset context"},
|
||||||
{Name: "/compact", Description: "compress context"},
|
{Name: "/compact", Description: "compress context"},
|
||||||
@@ -174,6 +187,7 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
|
|||||||
input: input,
|
input: input,
|
||||||
spinner: spin,
|
spinner: spin,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
|
historyIndex: -1,
|
||||||
}
|
}
|
||||||
m.refreshContextUsage()
|
m.refreshContextUsage()
|
||||||
return m
|
return m
|
||||||
@@ -249,6 +263,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m.submit()
|
return m.submit()
|
||||||
|
case "ctrl+p":
|
||||||
|
if m.running {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.previousInputHistory()
|
||||||
|
m.afterInputChanged()
|
||||||
|
return m, nil
|
||||||
|
case "ctrl+n":
|
||||||
|
if m.running {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.nextInputHistory()
|
||||||
|
m.afterInputChanged()
|
||||||
|
return m, nil
|
||||||
case "alt+enter", "ctrl+j":
|
case "alt+enter", "ctrl+j":
|
||||||
return m.insertInputNewline()
|
return m.insertInputNewline()
|
||||||
}
|
}
|
||||||
@@ -262,8 +290,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case toolLogMsg:
|
case toolLogMsg:
|
||||||
text := cleanToolLog(string(msg))
|
text := cleanToolLog(string(msg))
|
||||||
if text != "" {
|
if text != "" {
|
||||||
|
if !m.mergeToolLog(text) {
|
||||||
m.messages = append(m.messages, message{role: roleTool, content: text})
|
m.messages = append(m.messages, message{role: roleTool, content: text})
|
||||||
m.status = text
|
}
|
||||||
|
m.status = toolStatusText(text)
|
||||||
m.refreshViewport(true)
|
m.refreshViewport(true)
|
||||||
}
|
}
|
||||||
return m, m.waitForEvent()
|
return m, m.waitForEvent()
|
||||||
@@ -297,6 +327,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if !m.running {
|
if !m.running {
|
||||||
nextInput, cmd := m.input.Update(msg)
|
nextInput, cmd := m.input.Update(msg)
|
||||||
m.input = nextInput
|
m.input = nextInput
|
||||||
|
m.historyIndex = -1
|
||||||
|
m.draftInput = ""
|
||||||
m.afterInputChanged()
|
m.afterInputChanged()
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
@@ -343,6 +375,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
|
|||||||
if input == "" {
|
if input == "" {
|
||||||
return *m, nil
|
return *m, nil
|
||||||
}
|
}
|
||||||
|
m.rememberInput(input)
|
||||||
|
|
||||||
switch input {
|
switch input {
|
||||||
case "/exit", "/quit":
|
case "/exit", "/quit":
|
||||||
@@ -402,6 +435,8 @@ func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
|
|||||||
enter := tea.KeyMsg{Type: tea.KeyEnter}
|
enter := tea.KeyMsg{Type: tea.KeyEnter}
|
||||||
nextInput, cmd := m.input.Update(enter)
|
nextInput, cmd := m.input.Update(enter)
|
||||||
m.input = nextInput
|
m.input = nextInput
|
||||||
|
m.historyIndex = -1
|
||||||
|
m.draftInput = ""
|
||||||
m.afterInputChanged()
|
m.afterInputChanged()
|
||||||
return *m, cmd
|
return *m, cmd
|
||||||
}
|
}
|
||||||
@@ -472,6 +507,10 @@ func (m model) renderMessages() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) messageView(r role, content string, width int) string {
|
func (m model) messageView(r role, content string, width int) string {
|
||||||
|
if r == roleTool {
|
||||||
|
return m.toolMessageView(content, width)
|
||||||
|
}
|
||||||
|
|
||||||
label := roleLabel(r)
|
label := roleLabel(r)
|
||||||
style := m.styles.AssistantMsg
|
style := m.styles.AssistantMsg
|
||||||
labelStyle := m.styles.SystemLabel
|
labelStyle := m.styles.SystemLabel
|
||||||
@@ -534,39 +573,169 @@ func roleLabel(r role) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type toolLogParts struct {
|
||||||
|
name string
|
||||||
|
args string
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseToolLog(content string) toolLogParts {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if content == "" {
|
||||||
|
return toolLogParts{}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
parts := parseToolLog(content)
|
||||||
|
header := m.styles.ToolName.Render(strings.TrimSpace(iconTool + " " + parts.name))
|
||||||
|
lines := []string{header}
|
||||||
|
if parts.args != "" {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) renderMetaPills(parts []metaPill) string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := make([]string, len(parts))
|
||||||
|
for i, part := range parts {
|
||||||
|
plain[i] = part.text
|
||||||
|
}
|
||||||
|
|
||||||
|
width := max(1, m.width-2)
|
||||||
|
visible := len(plain)
|
||||||
|
for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
|
||||||
|
visible--
|
||||||
|
}
|
||||||
|
if lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
|
||||||
|
return m.styles.PillAccent.Render(fitLine(plain[0], width))
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered := make([]string, 0, visible)
|
||||||
|
for i := 0; i < visible; i++ {
|
||||||
|
style := m.styles.Pill
|
||||||
|
if parts[i].accent {
|
||||||
|
style = m.styles.PillAccent
|
||||||
|
}
|
||||||
|
rendered = append(rendered, style.Render(parts[i].text))
|
||||||
|
}
|
||||||
|
return strings.Join(rendered, " ")
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) modelMetaView() string {
|
func (m model) modelMetaView() string {
|
||||||
mode := "read-only tools"
|
mode := "read-only tools"
|
||||||
if m.yolo {
|
if m.yolo {
|
||||||
mode = "yolo tools"
|
mode = "yolo tools"
|
||||||
}
|
}
|
||||||
|
|
||||||
modelName := m.modelName
|
modelName := m.modelName
|
||||||
parts := []string{}
|
|
||||||
if m.models != nil {
|
if m.models != nil {
|
||||||
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
|
if currentModel := m.models.CurrentModel(); currentModel != "" {
|
||||||
parts = append(parts, "session "+sessionName)
|
modelName = currentModel
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := []metaPill{{text: iconModel + " " + modelName, accent: true}}
|
||||||
|
if m.models != nil {
|
||||||
if provider := m.models.CurrentProvider(); provider != "" {
|
if provider := m.models.CurrentProvider(); provider != "" {
|
||||||
parts = append(parts, "provider "+provider)
|
parts = append(parts, metaPill{text: "provider " + provider})
|
||||||
}
|
}
|
||||||
modelName = m.models.CurrentModel()
|
|
||||||
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
|
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
|
||||||
parts = append(parts, "thinking "+currentThinking)
|
parts = append(parts, metaPill{text: "thinking " + currentThinking})
|
||||||
|
}
|
||||||
|
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
|
||||||
|
parts = append(parts, metaPill{text: iconSession + " " + sessionName})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.contextUsage != "" {
|
if m.contextUsage != "" {
|
||||||
parts = append(parts, m.contextUsage)
|
parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage})
|
||||||
}
|
}
|
||||||
parts = append([]string{modelName, mode}, parts...)
|
parts = append(parts, metaPill{text: mode}, metaPill{text: "theme " + string(m.theme.Mode)})
|
||||||
parts = append(parts, "theme "+string(m.theme.Mode))
|
|
||||||
line := fitLine(strings.Join(parts, " · "), max(1, m.width-2))
|
return m.styles.ModelMeta.Width(max(1, m.width)).Render(m.renderMetaPills(parts))
|
||||||
return m.styles.ModelMeta.Width(max(1, m.width)).Render(line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) inputView() string {
|
func (m model) inputView() string {
|
||||||
suggestions := m.slashSuggestionsView()
|
palette := m.commandPaletteView()
|
||||||
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
|
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
|
||||||
if suggestions != "" {
|
if palette != "" {
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, suggestions, box)
|
return lipgloss.JoinVertical(lipgloss.Left, palette, box)
|
||||||
}
|
}
|
||||||
return box
|
return box
|
||||||
}
|
}
|
||||||
@@ -579,12 +748,12 @@ func (m model) activityView() string {
|
|||||||
if status == "" || status == "ready" {
|
if status == "" || status == "ready" {
|
||||||
status = "working..."
|
status = "working..."
|
||||||
}
|
}
|
||||||
line := fitLine(fmt.Sprintf("%s Working · %s · esc cancels", m.spinner.View(), status), max(1, m.width-2))
|
line := m.styles.StatusInfo.Render(iconWorking) + " Working " + m.spinner.View() + " " + status + " · esc cancels"
|
||||||
return m.styles.Activity.Width(max(1, m.width)).Render(line)
|
return m.styles.Activity.Render(fitLine(line, max(1, m.width-2)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) footerView() string {
|
func (m model) footerView() string {
|
||||||
hint := "enter send · ctrl+j newline · alt+enter newline · / commands"
|
hint := "enter send · ctrl+j newline · alt+enter newline · ctrl+p/ctrl+n history · / commands"
|
||||||
if m.running {
|
if m.running {
|
||||||
hint = "esc cancel · ctrl+c quit"
|
hint = "esc cancel · ctrl+c quit"
|
||||||
}
|
}
|
||||||
@@ -639,6 +808,51 @@ func (m *model) afterInputChanged() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *model) rememberInput(input string) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(m.inputHistory) > 0 && m.inputHistory[len(m.inputHistory)-1] == input {
|
||||||
|
m.historyIndex = -1
|
||||||
|
m.draftInput = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.inputHistory = append(m.inputHistory, input)
|
||||||
|
if len(m.inputHistory) > maxInputHistory {
|
||||||
|
m.inputHistory = m.inputHistory[len(m.inputHistory)-maxInputHistory:]
|
||||||
|
}
|
||||||
|
m.historyIndex = -1
|
||||||
|
m.draftInput = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) previousInputHistory() {
|
||||||
|
if len(m.inputHistory) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.historyIndex == -1 {
|
||||||
|
m.draftInput = m.input.Value()
|
||||||
|
m.historyIndex = len(m.inputHistory) - 1
|
||||||
|
} else if m.historyIndex > 0 {
|
||||||
|
m.historyIndex--
|
||||||
|
}
|
||||||
|
m.input.SetValue(m.inputHistory[m.historyIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) nextInputHistory() {
|
||||||
|
if len(m.inputHistory) == 0 || m.historyIndex == -1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.historyIndex < len(m.inputHistory)-1 {
|
||||||
|
m.historyIndex++
|
||||||
|
m.input.SetValue(m.inputHistory[m.historyIndex])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.input.SetValue(m.draftInput)
|
||||||
|
m.historyIndex = -1
|
||||||
|
m.draftInput = ""
|
||||||
|
}
|
||||||
|
|
||||||
func (m *model) syncInputHeight() {
|
func (m *model) syncInputHeight() {
|
||||||
m.input.SetHeight(m.desiredInputHeight())
|
m.input.SetHeight(m.desiredInputHeight())
|
||||||
}
|
}
|
||||||
@@ -661,47 +875,132 @@ func (m model) desiredInputHeight() int {
|
|||||||
|
|
||||||
func (m model) inputBlockHeight() int {
|
func (m model) inputBlockHeight() int {
|
||||||
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
|
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
|
||||||
if m.showSlashSuggestions() {
|
if m.showCommandPalette() {
|
||||||
height += m.slashSuggestionCount()
|
height += m.commandPaletteHeight()
|
||||||
}
|
}
|
||||||
return height
|
return height
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) slashSuggestionCount() int {
|
func (m model) commandPaletteHeight() int {
|
||||||
prefix := strings.TrimSpace(m.input.Value())
|
matches := commandMatches(commandPaletteQuery(m.input.Value()))
|
||||||
count := 0
|
count := len(matches)
|
||||||
for _, command := range slashCommands {
|
if count > maxCommandPaletteItems {
|
||||||
if strings.HasPrefix(command.Name, prefix) {
|
count = maxCommandPaletteItems
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
count = 1 // "No matching commands"
|
count = 1
|
||||||
}
|
}
|
||||||
return count
|
return 1 + count + m.styles.Palette.GetVerticalFrameSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) showSlashSuggestions() bool {
|
func (m model) showCommandPalette() bool {
|
||||||
value := strings.TrimSpace(m.input.Value())
|
value := strings.TrimSpace(m.input.Value())
|
||||||
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
|
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) slashSuggestionsView() string {
|
func (m model) commandPaletteView() string {
|
||||||
if !m.showSlashSuggestions() {
|
if !m.showCommandPalette() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
prefix := strings.TrimSpace(m.input.Value())
|
matches := commandMatches(commandPaletteQuery(m.input.Value()))
|
||||||
var parts []string
|
lines := []string{m.styles.Header.Render(iconCommand + " Commands")}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
lines = append(lines, m.styles.Muted.Render("No matching commands"))
|
||||||
|
} else {
|
||||||
|
for i, match := range matches {
|
||||||
|
if i >= maxCommandPaletteItems {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%s %s", m.styles.PaletteMatch.Render(match.command.Name), m.styles.Muted.Render(match.command.Description)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandPaletteQuery(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if !strings.HasPrefix(value, "/") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(value, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandMatches(query string) []commandMatch {
|
||||||
|
matches := make([]commandMatch, 0, len(slashCommands))
|
||||||
for _, command := range slashCommands {
|
for _, command := range slashCommands {
|
||||||
if !strings.HasPrefix(command.Name, prefix) {
|
score, ok := scoreCommand(command, query)
|
||||||
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts = append(parts, fmt.Sprintf("%s %s", command.Name, command.Description))
|
matches = append(matches, commandMatch{command: command, score: score})
|
||||||
}
|
}
|
||||||
if len(parts) == 0 {
|
sort.Slice(matches, func(i, j int) bool {
|
||||||
parts = append(parts, "No matching commands")
|
if matches[i].score != matches[j].score {
|
||||||
|
return matches[i].score < matches[j].score
|
||||||
}
|
}
|
||||||
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, "\n"))
|
return matches[i].command.Name < matches[j].command.Name
|
||||||
|
})
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreCommand(command slashCommand, query string) (int, bool) {
|
||||||
|
query = strings.ToLower(strings.TrimSpace(query))
|
||||||
|
name := strings.TrimPrefix(strings.ToLower(command.Name), "/")
|
||||||
|
if query == "" || query == name {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, query) {
|
||||||
|
return 100 + len(name) - len(query), true
|
||||||
|
}
|
||||||
|
if score, ok := subsequenceScore(name, query, 200); ok {
|
||||||
|
return score, true
|
||||||
|
}
|
||||||
|
candidate := name + " " + strings.ToLower(command.Description)
|
||||||
|
if score, ok := subsequenceScore(candidate, query, 300); ok {
|
||||||
|
return score, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func subsequenceScore(candidate string, query string, base int) (int, bool) {
|
||||||
|
candidate = strings.ToLower(candidate)
|
||||||
|
query = strings.ToLower(query)
|
||||||
|
if query == "" {
|
||||||
|
return base, true
|
||||||
|
}
|
||||||
|
start := 0
|
||||||
|
last := -1
|
||||||
|
gaps := 0
|
||||||
|
for _, r := range query {
|
||||||
|
found := -1
|
||||||
|
for i, c := range candidate[start:] {
|
||||||
|
if c == r {
|
||||||
|
found = start + i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if last >= 0 {
|
||||||
|
gaps += found - last - 1
|
||||||
|
}
|
||||||
|
last = found
|
||||||
|
start = found + len(string(r))
|
||||||
|
}
|
||||||
|
return base + gaps, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) slashSuggestionCount() int {
|
||||||
|
return m.commandPaletteHeight()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) showSlashSuggestions() bool {
|
||||||
|
return m.showCommandPalette()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) slashSuggestionsView() string {
|
||||||
|
return m.commandPaletteView()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) modelInfo() string {
|
func (m model) modelInfo() string {
|
||||||
|
|||||||
+102
-8
@@ -21,7 +21,7 @@ func TestModelRendersChatShell(t *testing.T) {
|
|||||||
m = next.(model)
|
m = next.(model)
|
||||||
rendered := m.View()
|
rendered := m.View()
|
||||||
|
|
||||||
for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} {
|
for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter", "ctrl+p/ctrl+n history"} {
|
||||||
if !strings.Contains(rendered, want) {
|
if !strings.Contains(rendered, want) {
|
||||||
t.Fatalf("rendered view missing %q:\n%s", want, rendered)
|
t.Fatalf("rendered view missing %q:\n%s", want, rendered)
|
||||||
}
|
}
|
||||||
@@ -123,11 +123,14 @@ func TestRunningStateRendersActivityIndicator(t *testing.T) {
|
|||||||
m.resize()
|
m.resize()
|
||||||
|
|
||||||
rendered := m.View()
|
rendered := m.View()
|
||||||
for _, want := range []string{"Working", "thinking...", "esc cancels"} {
|
for _, want := range []string{iconWorking, "Working", "thinking...", "esc cancels"} {
|
||||||
if !strings.Contains(rendered, want) {
|
if !strings.Contains(rendered, want) {
|
||||||
t.Fatalf("running view missing %q:\n%s", want, rendered)
|
t.Fatalf("running view missing %q:\n%s", want, rendered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if backgroundPattern.MatchString(m.activityView()) {
|
||||||
|
t.Fatalf("activity view should not paint a background:\n%q", m.activityView())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserMessageRendersFullWidthBubble(t *testing.T) {
|
func TestUserMessageRendersFullWidthBubble(t *testing.T) {
|
||||||
@@ -276,19 +279,110 @@ func TestInputStartsAtOneLineAndGrows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSlashCommandSuggestions(t *testing.T) {
|
func TestCommandPaletteFuzzyMatches(t *testing.T) {
|
||||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
|
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
|
||||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||||
m = next.(model)
|
m = next.(model)
|
||||||
m.input.SetValue("/")
|
m.input.SetValue("/mdl")
|
||||||
m.afterInputChanged()
|
m.afterInputChanged()
|
||||||
|
|
||||||
rendered := m.View()
|
plain := stripANSI(m.View())
|
||||||
for _, want := range []string{"/clear reset context", "/compact compress context", "/exit quit", "/model model/provider/thinking"} {
|
for _, want := range []string{iconCommand + " Commands", "/model model/provider/thinking"} {
|
||||||
if !strings.Contains(rendered, want) {
|
if !strings.Contains(plain, want) {
|
||||||
t.Fatalf("rendered suggestions missing %q:\n%s", want, rendered)
|
t.Fatalf("rendered palette missing %q:\n%s", want, plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
matches := commandMatches("m")
|
||||||
|
modelIndex := -1
|
||||||
|
renameIndex := -1
|
||||||
|
for i, match := range matches {
|
||||||
|
switch match.command.Name {
|
||||||
|
case "/model":
|
||||||
|
modelIndex = i
|
||||||
|
case "/rename":
|
||||||
|
renameIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if modelIndex < 0 || renameIndex < 0 || modelIndex > renameIndex {
|
||||||
|
t.Fatalf("/model should sort before /rename for /m query: %#v", matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputHistoryNavigation(t *testing.T) {
|
||||||
|
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||||
|
m.rememberInput("first")
|
||||||
|
m.rememberInput("second")
|
||||||
|
|
||||||
|
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
|
||||||
|
m = next.(model)
|
||||||
|
if got := m.input.Value(); got != "second" {
|
||||||
|
t.Fatalf("after ctrl+p input = %q", got)
|
||||||
|
}
|
||||||
|
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
|
||||||
|
m = next.(model)
|
||||||
|
if got := m.input.Value(); got != "first" {
|
||||||
|
t.Fatalf("after second ctrl+p input = %q", got)
|
||||||
|
}
|
||||||
|
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
|
||||||
|
m = next.(model)
|
||||||
|
if got := m.input.Value(); got != "second" {
|
||||||
|
t.Fatalf("after ctrl+n input = %q", got)
|
||||||
|
}
|
||||||
|
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
|
||||||
|
m = next.(model)
|
||||||
|
if got := m.input.Value(); got != "" {
|
||||||
|
t.Fatalf("after second ctrl+n input = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolMessageRendersStructuredCard(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"}`})
|
||||||
|
|
||||||
|
plain := stripANSI(m.renderMessages())
|
||||||
|
for _, want := range []string{iconTool + " file_read", "README.md"} {
|
||||||
|
if !strings.Contains(plain, want) {
|
||||||
|
t.Fatalf("tool card missing %q:\n%s", want, plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{}
|
type tuiCompactProvider struct{}
|
||||||
|
|||||||
+71
-5
@@ -15,6 +15,17 @@ const (
|
|||||||
ThemeDark ThemeMode = "dark"
|
ThemeDark ThemeMode = "dark"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
iconReady = "✓"
|
||||||
|
iconWorking = "◆"
|
||||||
|
iconTool = "▣"
|
||||||
|
iconError = "✕"
|
||||||
|
iconCommand = "⌘"
|
||||||
|
iconSession = "◌"
|
||||||
|
iconContext = "◔"
|
||||||
|
iconModel = "◉"
|
||||||
|
)
|
||||||
|
|
||||||
func ParseThemeMode(value string) (ThemeMode, error) {
|
func ParseThemeMode(value string) (ThemeMode, error) {
|
||||||
switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) {
|
switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) {
|
||||||
case "", ThemeLight:
|
case "", ThemeLight:
|
||||||
@@ -48,11 +59,23 @@ type styles struct {
|
|||||||
App lipgloss.Style
|
App lipgloss.Style
|
||||||
Muted lipgloss.Style
|
Muted lipgloss.Style
|
||||||
Activity lipgloss.Style
|
Activity lipgloss.Style
|
||||||
|
Header lipgloss.Style
|
||||||
ModelMeta lipgloss.Style
|
ModelMeta lipgloss.Style
|
||||||
Footer lipgloss.Style
|
Footer lipgloss.Style
|
||||||
Viewport lipgloss.Style
|
Viewport lipgloss.Style
|
||||||
InputBox lipgloss.Style
|
InputBox lipgloss.Style
|
||||||
CommandHint lipgloss.Style
|
CommandHint lipgloss.Style
|
||||||
|
StatusReady lipgloss.Style
|
||||||
|
StatusWarn lipgloss.Style
|
||||||
|
StatusError lipgloss.Style
|
||||||
|
StatusInfo lipgloss.Style
|
||||||
|
Pill lipgloss.Style
|
||||||
|
PillAccent lipgloss.Style
|
||||||
|
Palette lipgloss.Style
|
||||||
|
PaletteMatch lipgloss.Style
|
||||||
|
ToolName lipgloss.Style
|
||||||
|
ToolKey lipgloss.Style
|
||||||
|
ToolValue lipgloss.Style
|
||||||
UserLabel lipgloss.Style
|
UserLabel lipgloss.Style
|
||||||
ToolLabel lipgloss.Style
|
ToolLabel lipgloss.Style
|
||||||
ErrorLabel lipgloss.Style
|
ErrorLabel lipgloss.Style
|
||||||
@@ -116,14 +139,15 @@ func (t theme) styles() styles {
|
|||||||
Foreground(lipgloss.Color(t.Muted)),
|
Foreground(lipgloss.Color(t.Muted)),
|
||||||
|
|
||||||
Activity: lipgloss.NewStyle().
|
Activity: lipgloss.NewStyle().
|
||||||
Bold(true).
|
Foreground(lipgloss.Color(t.Muted)).
|
||||||
Foreground(lipgloss.Color(t.Background)).
|
|
||||||
Background(lipgloss.Color(t.Title)).
|
|
||||||
Padding(0, 1),
|
Padding(0, 1),
|
||||||
|
|
||||||
|
Header: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(t.Title)),
|
||||||
|
|
||||||
ModelMeta: lipgloss.NewStyle().
|
ModelMeta: lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(t.Text)).
|
Foreground(lipgloss.Color(t.Muted)).
|
||||||
Background(lipgloss.Color(t.SurfaceAlt)).
|
|
||||||
Padding(0, 1),
|
Padding(0, 1),
|
||||||
|
|
||||||
Footer: lipgloss.NewStyle().
|
Footer: lipgloss.NewStyle().
|
||||||
@@ -141,9 +165,51 @@ func (t theme) styles() styles {
|
|||||||
|
|
||||||
CommandHint: lipgloss.NewStyle().
|
CommandHint: lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(t.Muted)).
|
Foreground(lipgloss.Color(t.Muted)).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
StatusReady: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Assistant)),
|
||||||
|
|
||||||
|
StatusWarn: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Tool)),
|
||||||
|
|
||||||
|
StatusError: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Error)),
|
||||||
|
|
||||||
|
StatusInfo: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.User)),
|
||||||
|
|
||||||
|
Pill: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Text)).
|
||||||
Background(lipgloss.Color(t.SurfaceAlt)).
|
Background(lipgloss.Color(t.SurfaceAlt)).
|
||||||
Padding(0, 1),
|
Padding(0, 1),
|
||||||
|
|
||||||
|
PillAccent: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(t.Title)).
|
||||||
|
Background(lipgloss.Color(t.SurfaceAlt)).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
Palette: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Text)).
|
||||||
|
Border(lipgloss.ThickBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(t.Border)).
|
||||||
|
Padding(0, 2),
|
||||||
|
|
||||||
|
PaletteMatch: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(t.User)),
|
||||||
|
|
||||||
|
ToolName: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(t.Tool)),
|
||||||
|
|
||||||
|
ToolKey: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Muted)),
|
||||||
|
|
||||||
|
ToolValue: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Text)),
|
||||||
|
|
||||||
UserLabel: labelStyle(t.User),
|
UserLabel: labelStyle(t.User),
|
||||||
ToolLabel: labelStyle(t.Tool),
|
ToolLabel: labelStyle(t.Tool),
|
||||||
ErrorLabel: labelStyle(t.Error),
|
ErrorLabel: labelStyle(t.Error),
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"agentu/internal/agent"
|
"agentu/internal/agent"
|
||||||
"agentu/pkg/config"
|
|
||||||
"agentu/pkg/llm"
|
|
||||||
"agentu/internal/session"
|
"agentu/internal/session"
|
||||||
"agentu/internal/tools"
|
"agentu/internal/tools"
|
||||||
"agentu/internal/tui"
|
"agentu/internal/tui"
|
||||||
|
"agentu/pkg/config"
|
||||||
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultConfigContent = `# agentu configuration
|
const defaultConfigContent = `# agentu configuration
|
||||||
@@ -112,15 +112,8 @@ func run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resume session
|
if err := initializeSession(modelManager, *resumeID); err != nil {
|
||||||
if *resumeID != "" {
|
return err
|
||||||
if err := modelManager.Resume(*resumeID); err != nil {
|
|
||||||
return fmt.Errorf("resume session: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := modelManager.ResumeLatest(); err != nil {
|
|
||||||
return fmt.Errorf("resume latest session: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
@@ -141,6 +134,19 @@ func run() error {
|
|||||||
return repl(ctx, assistant, modelManager)
|
return repl(ctx, assistant, modelManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initializeSession(modelManager *session.Manager, resumeID string) error {
|
||||||
|
if resumeID != "" {
|
||||||
|
if err := modelManager.Resume(resumeID); err != nil {
|
||||||
|
return fmt.Errorf("resume session: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := modelManager.NewSession(); err != nil {
|
||||||
|
return fmt.Errorf("start new session: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func bootstrapConfig(configPath string) error {
|
func bootstrapConfig(configPath string) error {
|
||||||
resolved, err := config.ResolvePath(configPath)
|
resolved, err := config.ResolvePath(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agentu/internal/agent"
|
||||||
|
"agentu/internal/session"
|
||||||
|
"agentu/pkg/config"
|
||||||
|
"agentu/pkg/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type startupProvider struct{}
|
||||||
|
|
||||||
|
func (p *startupProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||||
|
return emit(llm.StreamEvent{Content: "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func startupTestManager(t *testing.T) (*session.Manager, *agent.Agent) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Providers: map[string]config.ProviderConfig{
|
||||||
|
"one": {
|
||||||
|
Name: "one",
|
||||||
|
BaseURL: "https://one.example.com",
|
||||||
|
APIKey: "sk-test",
|
||||||
|
Model: "model-a",
|
||||||
|
Models: []string{"model-a"},
|
||||||
|
Thinking: "none",
|
||||||
|
ThinkingParam: "thinking",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assistant := agent.New(agent.Options{
|
||||||
|
Provider: &startupProvider{},
|
||||||
|
ProviderName: "one",
|
||||||
|
Model: "model-a",
|
||||||
|
})
|
||||||
|
store, err := session.NewStore(filepath.Join(t.TempDir(), "sessions"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager, err := session.NewManager(cfg, assistant, nil, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return manager, assistant
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitializeSessionStartsNewByDefault(t *testing.T) {
|
||||||
|
manager, assistant := startupTestManager(t)
|
||||||
|
if _, err := manager.NewSession(); err != nil {
|
||||||
|
t.Fatalf("new session: %v", err)
|
||||||
|
}
|
||||||
|
savedID := manager.CurrentSessionID()
|
||||||
|
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}})
|
||||||
|
if err := manager.Save(); err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := initializeSession(manager, ""); err != nil {
|
||||||
|
t.Fatalf("initialize: %v", err)
|
||||||
|
}
|
||||||
|
if manager.CurrentSessionID() == savedID {
|
||||||
|
t.Fatalf("default startup resumed latest session %s", savedID)
|
||||||
|
}
|
||||||
|
for _, msg := range assistant.Messages() {
|
||||||
|
if msg.Content == "old context" {
|
||||||
|
t.Fatal("default startup carried old context into new session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitializeSessionResumesExplicitID(t *testing.T) {
|
||||||
|
manager, assistant := startupTestManager(t)
|
||||||
|
if _, err := manager.NewSession(); err != nil {
|
||||||
|
t.Fatalf("new session: %v", err)
|
||||||
|
}
|
||||||
|
savedID := manager.CurrentSessionID()
|
||||||
|
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}})
|
||||||
|
if err := manager.Save(); err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := initializeSession(manager, savedID); err != nil {
|
||||||
|
t.Fatalf("initialize: %v", err)
|
||||||
|
}
|
||||||
|
if manager.CurrentSessionID() != savedID {
|
||||||
|
t.Fatalf("expected resumed session %s, got %s", savedID, manager.CurrentSessionID())
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, msg := range assistant.Messages() {
|
||||||
|
if msg.Content == "old context" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("explicit resume did not restore saved context")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user