feat: complete v0.3.1 agent workflow
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user