feat: complete v0.3.1 agent workflow

This commit is contained in:
loveuer
2026-09-15 03:39:22 -07:00
parent 47671f3098
commit 065b30a84c
33 changed files with 4921 additions and 170 deletions
+296
View File
@@ -2,13 +2,17 @@ package tui
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"agentu/internal/agent"
"agentu/internal/tools"
"agentu/pkg/llm"
tea "github.com/charmbracelet/bubbletea"
@@ -126,6 +130,116 @@ func TestRunningStateRendersActivityIndicator(t *testing.T) {
}
}
type tuiBTWProvider struct{}
func (tuiBTWProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
return emit(llm.StreamEvent{Content: "side answer"})
}
func TestBTWCanBeSubmittedWhileMainTurnIsRunning(t *testing.T) {
assistant := agent.New(agent.Options{Provider: tuiBTWProvider{}, Model: "test-model"})
m := newModel(context.Background(), assistant, Options{ModelName: "test-model"})
m.running = true
m.events = make(chan tea.Msg, 8)
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/btw why?")})
m = next.(model)
if m.input.Value() != "/btw why?" {
t.Fatalf("running input = %q", m.input.Value())
}
next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = next.(model)
if !m.btwRunning || m.input.Value() != "" {
t.Fatalf("btw running/input = %v/%q", m.btwRunning, m.input.Value())
}
if got := m.messages[len(m.messages)-1]; got.role != roleBTWUser || got.content != "why?" {
t.Fatalf("btw question message = %+v", got)
}
if cmd == nil {
t.Fatal("btw did not return a start command")
}
_ = cmd()
chunk := <-m.events
done := <-m.events
if _, ok := chunk.(btwChunkMsg); !ok {
t.Fatalf("first btw event = %T", chunk)
}
if _, ok := done.(btwDoneMsg); !ok {
t.Fatalf("final btw event = %T", done)
}
if len(assistant.Messages()) != 0 {
t.Fatalf("btw polluted agent history: %+v", assistant.Messages())
}
}
func TestBTWRequiresQuestion(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.input.SetValue("/btw")
next, _ := m.submit()
m = next.(model)
if m.btwRunning {
t.Fatal("empty btw question started a request")
}
last := m.messages[len(m.messages)-1]
if last.role != roleBTW || !strings.Contains(last.content, "Usage: /btw") {
t.Fatalf("empty btw message = %+v", last)
}
}
func TestBTWMessagesHaveDistinctLabels(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
question := stripANSI(m.messageView(roleBTWUser, "question", 80))
answer := stripANSI(m.messageView(roleBTW, "answer", 80))
if !strings.Contains(question, "BTW · You") || !strings.Contains(answer, "BTW") {
t.Fatalf("btw labels missing:\n%s\n%s", question, answer)
}
}
func TestBTWAndMainStreamChunksStaySeparate(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.messages = append(m.messages, message{role: roleUser, content: "main"})
m.appendAssistantChunk("main one ")
m.messages = append(m.messages, message{role: roleBTWUser, content: "side"})
m.appendBTWChunk("side one ")
m.appendAssistantChunk("main two")
m.appendBTWChunk("side two")
var mainAnswers, sideAnswers []string
for _, msg := range m.messages {
switch msg.role {
case roleAssistant:
mainAnswers = append(mainAnswers, msg.content)
case roleBTW:
sideAnswers = append(sideAnswers, msg.content)
}
}
if !reflect.DeepEqual(mainAnswers, []string{"main one main two"}) {
t.Fatalf("main answers = %#v", mainAnswers)
}
if !reflect.DeepEqual(sideAnswers, []string{"side one side two"}) {
t.Fatalf("side answers = %#v", sideAnswers)
}
}
func TestMainCompletionKeepsBTWEventStreamAlive(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
events := make(chan tea.Msg, 1)
m.events = events
m.running = true
m.btwRunning = true
next, _ := m.Update(turnDoneMsg{})
m = next.(model)
if m.running || !m.btwRunning || m.events != events {
t.Fatalf("state after main completion: running=%v btw=%v events=%p", m.running, m.btwRunning, m.events)
}
next, _ = m.Update(btwDoneMsg{})
m = next.(model)
if m.active() || m.events != nil {
t.Fatalf("state after btw completion: active=%v events=%p", m.active(), m.events)
}
}
func TestUserMessageRendersFullWidthBubble(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
@@ -417,6 +531,76 @@ func TestToolLogStreamingChunksAppendToSameCard(t *testing.T) {
}
}
func TestShellRunCardShowsCommandWorkdirAndOutput(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.messages = append(m.messages, message{
role: roleTool,
content: "shell_run {\"command\":\"go test ./...\",\"workdir\":\"internal/tui\"}\n[output]\nok agentu/internal/tui",
})
plain := stripANSI(m.renderMessages())
for _, want := range []string{"$ go test ./...", "cwd internal/tui", "output", "ok agentu/internal/tui"} {
if !strings.Contains(plain, want) {
t.Fatalf("shell card missing %q:\n%s", want, plain)
}
}
if strings.Contains(plain, `{"command"`) || strings.Contains(plain, iconTool+" shell_run") {
t.Fatalf("shell card still exposes raw tool arguments:\n%s", plain)
}
if status := toolStatusText(`shell_run {"command":"go test ./...","workdir":"internal/tui"}`); status != "$ go test ./..." {
t.Fatalf("shell status = %q", status)
}
}
func TestShellRunStreamingEventsPreserveLineBreaks(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
args := `{"command":"printf lines"}`
m.messages = append(m.messages, message{role: roleTool, content: "shell_run " + args})
next, _ := m.Update(toolLogMsg("\n[tool] shell_run " + args + "\n[output]\none\n"))
m = next.(model)
next, _ = m.Update(toolLogMsg("\n[tool] shell_run " + args + "\n[output]\n\n"))
m = next.(model)
next, _ = m.Update(toolLogMsg("\n[tool] shell_run " + args + "\n[output]\ntwo\n"))
m = next.(model)
parts := parseToolLog(m.messages[0].content)
if parts.output != "one\n\ntwo" {
t.Fatalf("streamed output = %q", parts.output)
}
if len(m.messages) != 1 {
t.Fatalf("streaming created %d tool cards, want 1", len(m.messages))
}
plain := stripANSI(m.renderMessages())
if strings.Contains(plain, "onetwo") {
t.Fatalf("streamed lines were concatenated:\n%s", plain)
}
}
func TestShellOutputPreviewKeepsHeadAndLatestLines(t *testing.T) {
var lines []string
for i := 1; i <= 12; i++ {
lines = append(lines, fmt.Sprintf("line %d", i))
}
preview := strings.Join(shellOutputPreviewLines(strings.Join(lines, "\n"), 80), "\n")
for _, want := range []string{"line 1", "line 4", "5 lines omitted", "line 10", "line 12"} {
if !strings.Contains(preview, want) {
t.Fatalf("preview missing %q:\n%s", want, preview)
}
}
if strings.Contains(preview, "line 5\n") || strings.Contains(preview, "line 9\n") {
t.Fatalf("preview contains a line that should be omitted:\n%s", preview)
}
}
func TestSanitizeTerminalOutputRemovesEscapesAndFoldsCarriageReturns(t *testing.T) {
got := sanitizeTerminalOutput("\x1b[31mred\x1b[0m\nprogress 10%\rprogress 20%\r\nclean\x00text")
want := "red\nprogress 20%\ncleantext"
if got != want {
t.Fatalf("sanitized output = %q, want %q", got, want)
}
}
type tuiCompactProvider struct{}
func (tuiCompactProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
@@ -934,3 +1118,115 @@ func TestCleanToolLog(t *testing.T) {
t.Fatalf("got %q want %q", got, want)
}
}
func TestApprovalPromptAllowsAndDeniesToolCalls(t *testing.T) {
for _, tc := range []struct {
key tea.KeyMsg
want agent.ToolAuthorizationDecision
}{
{key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}, want: agent.ToolAuthorizationAllow},
{key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}, want: agent.ToolAuthorizationDeny},
} {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
responses := make(chan agent.ToolAuthorizationDecision, 1)
next, _ = m.Update(toolApprovalMsg{
request: agent.ToolAuthorizationRequest{ToolName: "file_write", Preview: "write 5 bytes", RollbackAvailable: true},
respond: responses,
})
m = next.(model)
view := stripANSI(m.View())
for _, want := range []string{"Approve file_write?", "write 5 bytes", "rollback snapshot", "y approve"} {
if !strings.Contains(view, want) {
t.Fatalf("approval view missing %q:\n%s", want, view)
}
}
next, _ = m.Update(tc.key)
m = next.(model)
if m.approval != nil {
t.Fatal("approval state was not cleared")
}
if got := <-responses; got != tc.want {
t.Fatalf("decision = %q, want %q", got, tc.want)
}
}
}
func TestApprovalBrokerBridgesAgentAndTUI(t *testing.T) {
broker := NewApprovalBroker()
events := make(chan tea.Msg, 1)
broker.SetEvents(events)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
result := make(chan agent.ToolAuthorizationDecision, 1)
go func() {
decision, _ := broker.AuthorizeToolCall(ctx, agent.ToolAuthorizationRequest{ToolName: "file_edit"})
result <- decision
}()
msg, ok := (<-events).(toolApprovalMsg)
if !ok {
t.Fatal("approval broker emitted unexpected message")
}
msg.respond <- agent.ToolAuthorizationAllow
if decision := <-result; decision != agent.ToolAuthorizationAllow {
t.Fatalf("decision = %q", decision)
}
}
func TestRollbackCommandRestoresLatestSnapshot(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "state.txt")
if err := os.WriteFile(path, []byte("before"), 0o644); err != nil {
t.Fatal(err)
}
rollback := tools.NewRollbackStore()
if _, err := rollback.Capture("file_edit", "state.txt", path); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("after"), 0o644); err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Rollback: rollback})
out, err := m.handleRollbackCommand(nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "Rolled back state.txt") {
t.Fatalf("output = %q", out)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != "before" {
t.Fatalf("content = %q", data)
}
}
func TestSkillsInstallCommandActivatesSkill(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
sourceDir := t.TempDir()
source := filepath.Join(sourceDir, "review skill.md")
content := "---\nname: review\ndescription: Review changes\n---\n\nCheck every diff.\n"
if err := os.WriteFile(source, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
assistant := agent.New(agent.Options{SystemPrompt: "base"})
m := newModel(context.Background(), assistant, Options{ModelName: "test-model"})
out, err := m.handleLocalCommand("/skills install " + source)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `Installed skill "review"`) {
t.Fatalf("output = %q", out)
}
if _, err := os.Stat(filepath.Join(home, ".agentu", "skills", "review", "SKILL.md")); err != nil {
t.Fatalf("installed skill missing: %v", err)
}
messages := assistant.Messages()
if len(messages) == 0 || !strings.Contains(messages[0].Content, "Check every diff.") {
t.Fatalf("skill not activated in system prompt: %+v", messages)
}
}