Files
agents_and_robots/shell/effects/progress_test.go
T
egutierrez 1d16362ca6 test: tests para deteccion de pasos del pipeline en ProgressReporter
Tests unitarios para formatToolEvent con todos los pipeline hints:
create-full.sh, health-check.sh, notify-developer.sh, restart.sh,
start.sh, go build, go test, Edit, Read, Glob, Grep, y fallback
generico. Incluye tests para el contador de pasos y truncateInput.

Actualiza test de integracion existente para el nuevo formato de
mensajes (step counter + nombres legibles vs raw tool names).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:16:52 +00:00

410 lines
12 KiB
Go

package effects
import (
"context"
"log/slog"
"strings"
"testing"
"time"
coretypes "github.com/enmanuel/agents/pkg/llm"
)
// mockProgressSender records sends and edits for testing ProgressReporter.
type mockProgressSender struct {
fakeMatrixSender // embed to satisfy the full interface
sends []string // markdowns from SendMarkdownGetID
edits []string // markdowns from EditMessage
editTargets []string // event IDs targeted by EditMessage
}
func (m *mockProgressSender) SendMarkdownGetID(_ context.Context, _, markdown string) (string, error) {
m.sends = append(m.sends, markdown)
return "$progress_msg_1", nil
}
func (m *mockProgressSender) EditMessage(_ context.Context, _, originalEventID, markdown string) error {
m.edits = append(m.edits, markdown)
m.editTargets = append(m.editTargets, originalEventID)
return nil
}
func TestProgressReporter_InitEvent(t *testing.T) {
sender := &mockProgressSender{}
pr := NewProgressReporter(sender, "!room:test", slog.Default())
fn := pr.StreamFunc()
fn(coretypes.StreamEvent{Kind: coretypes.StreamInit})
if len(sender.sends) != 1 {
t.Fatalf("expected 1 send, got %d", len(sender.sends))
}
if !strings.Contains(sender.sends[0], "Procesando") {
t.Errorf("init message = %q, should contain 'Procesando'", sender.sends[0])
}
if pr.EventID() != "$progress_msg_1" {
t.Errorf("EventID = %q, want %q", pr.EventID(), "$progress_msg_1")
}
}
func TestProgressReporter_ToolUseEditsMessage(t *testing.T) {
sender := &mockProgressSender{}
pr := NewProgressReporter(sender, "!room:test", slog.Default())
pr.minInterval = 0 // disable rate limiting for test
fn := pr.StreamFunc()
// First event creates the message
fn(coretypes.StreamEvent{Kind: coretypes.StreamInit})
// Second event should edit
fn(coretypes.StreamEvent{
Kind: coretypes.StreamToolUse,
ToolName: "Bash",
ToolInput: "ls -la",
})
if len(sender.edits) != 1 {
t.Fatalf("expected 1 edit, got %d", len(sender.edits))
}
// Should contain step number and the command
if !strings.Contains(sender.edits[0], "Paso 1") {
t.Errorf("edit = %q, should contain step number", sender.edits[0])
}
if !strings.Contains(sender.edits[0], "ls -la") {
t.Errorf("edit = %q, should contain tool input", sender.edits[0])
}
if sender.editTargets[0] != "$progress_msg_1" {
t.Errorf("edit target = %q, want %q", sender.editTargets[0], "$progress_msg_1")
}
}
func TestProgressReporter_MultipleToolUse(t *testing.T) {
sender := &mockProgressSender{}
pr := NewProgressReporter(sender, "!room:test", slog.Default())
pr.minInterval = 0 // disable rate limiting for test
fn := pr.StreamFunc()
fn(coretypes.StreamEvent{Kind: coretypes.StreamInit})
fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Bash", ToolInput: "echo 1"})
fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Read", ToolInput: "/tmp/file.go"})
fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Edit", ToolInput: "/tmp/file.go"})
// 1 send (init) + 3 edits (tool uses)
if len(sender.sends) != 1 {
t.Errorf("expected 1 send, got %d", len(sender.sends))
}
if len(sender.edits) != 3 {
t.Errorf("expected 3 edits, got %d", len(sender.edits))
}
}
func TestProgressReporter_RateLimiting(t *testing.T) {
sender := &mockProgressSender{}
pr := NewProgressReporter(sender, "!room:test", slog.Default())
pr.minInterval = 500 * time.Millisecond
fn := pr.StreamFunc()
// First event creates the message (no rate limit on first send)
fn(coretypes.StreamEvent{Kind: coretypes.StreamInit})
// Reset lastEdit to simulate time having passed after init
pr.mu.Lock()
pr.lastEdit = time.Now().Add(-time.Second)
pr.mu.Unlock()
// First tool event should go through (enough time has passed)
fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Bash", ToolInput: "echo 1"})
// These rapid-fire events should be rate-limited
fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Read", ToolInput: "file.go"})
fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Edit", ToolInput: "file.go"})
// Only 1 edit should have gone through (the rest rate limited)
if len(sender.edits) != 1 {
t.Errorf("expected 1 edit (rate limited), got %d", len(sender.edits))
}
}
func TestProgressReporter_ResultIgnored(t *testing.T) {
sender := &mockProgressSender{}
pr := NewProgressReporter(sender, "!room:test", slog.Default())
fn := pr.StreamFunc()
fn(coretypes.StreamEvent{Kind: coretypes.StreamInit})
fn(coretypes.StreamEvent{Kind: coretypes.StreamResult, Content: "Final answer"})
// Result should not trigger an edit
if len(sender.edits) != 0 {
t.Errorf("expected 0 edits for result event, got %d", len(sender.edits))
}
}
func TestProgressReporter_TextIgnored(t *testing.T) {
sender := &mockProgressSender{}
pr := NewProgressReporter(sender, "!room:test", slog.Default())
fn := pr.StreamFunc()
fn(coretypes.StreamEvent{Kind: coretypes.StreamInit})
fn(coretypes.StreamEvent{Kind: coretypes.StreamText, Content: "Some thinking..."})
// Text events should not trigger edits
if len(sender.edits) != 0 {
t.Errorf("expected 0 edits for text event, got %d", len(sender.edits))
}
}
func TestProgressReporter_Finalize(t *testing.T) {
sender := &mockProgressSender{}
pr := NewProgressReporter(sender, "!room:test", slog.Default())
pr.minInterval = 0
fn := pr.StreamFunc()
fn(coretypes.StreamEvent{Kind: coretypes.StreamInit})
pr.Finalize("Done! Here is the result.")
if len(sender.edits) != 1 {
t.Fatalf("expected 1 edit for finalize, got %d", len(sender.edits))
}
if sender.edits[0] != "Done! Here is the result." {
t.Errorf("finalize edit = %q", sender.edits[0])
}
}
func TestProgressReporter_FinalizeNoMessage(t *testing.T) {
sender := &mockProgressSender{}
pr := NewProgressReporter(sender, "!room:test", slog.Default())
// Finalize without ever sending a message should be a no-op
pr.Finalize("Final")
if len(sender.edits) != 0 {
t.Errorf("expected 0 edits when no message was sent, got %d", len(sender.edits))
}
}
func TestProgressReporter_FinalizeEmpty(t *testing.T) {
sender := &mockProgressSender{}
pr := NewProgressReporter(sender, "!room:test", slog.Default())
pr.minInterval = 0
fn := pr.StreamFunc()
fn(coretypes.StreamEvent{Kind: coretypes.StreamInit})
// Empty finalize should be a no-op
pr.Finalize("")
if len(sender.edits) != 0 {
t.Errorf("expected 0 edits for empty finalize, got %d", len(sender.edits))
}
}
func TestProgressReporter_ToolInputTruncation(t *testing.T) {
sender := &mockProgressSender{}
pr := NewProgressReporter(sender, "!room:test", slog.Default())
pr.minInterval = 0
fn := pr.StreamFunc()
fn(coretypes.StreamEvent{Kind: coretypes.StreamInit})
longInput := strings.Repeat("x", 100)
fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Bash", ToolInput: longInput})
if len(sender.edits) != 1 {
t.Fatalf("expected 1 edit, got %d", len(sender.edits))
}
// The input in the message should be truncated
if strings.Contains(sender.edits[0], longInput) {
t.Error("long input should be truncated in the message")
}
if !strings.Contains(sender.edits[0], "...") {
t.Error("truncated input should end with ...")
}
}
// ── formatToolEvent unit tests ──────────────────────────────────────────
func TestFormatToolEvent_PipelineHints(t *testing.T) {
tests := []struct {
name string
tool string
input string
wantSub string // substring that must be present
wantNot string // substring that must NOT be present (empty = skip)
}{
{
name: "create-full.sh detected",
tool: "Bash",
input: "./dev-scripts/agent/create-full.sh hora-bot \"Hora Bot\"",
wantSub: "Creando agente",
},
{
name: "health-check.sh detected",
tool: "Bash",
input: "./dev-scripts/agent/health-check.sh hora-bot",
wantSub: "health check",
},
{
name: "notify-developer.sh detected",
tool: "Bash",
input: "./dev-scripts/agent/notify-developer.sh hora-bot agent \"Hora Bot\"",
wantSub: "bienvenida",
},
{
name: "restart.sh detected",
tool: "Bash",
input: "./dev-scripts/server/restart.sh",
wantSub: "Reiniciando",
},
{
name: "start.sh detected",
tool: "Bash",
input: "./dev-scripts/server/start.sh",
wantSub: "Arrancando",
},
{
name: "go build detected",
tool: "Bash",
input: "go build -tags goolm ./...",
wantSub: "Compilando",
},
{
name: "go test detected",
tool: "Bash",
input: "go test -tags goolm ./pkg/...",
wantSub: "tests",
},
{
name: "generic Bash command",
tool: "Bash",
input: "cat /etc/hostname",
wantSub: "cat /etc/hostname",
},
{
name: "Edit tool",
tool: "Edit",
input: "agents/hora-bot/config.yaml",
wantSub: "Editando",
},
{
name: "Write tool",
tool: "Write",
input: "agents/hora-bot/prompts/system.md",
wantSub: "Editando",
},
{
name: "Read tool",
tool: "Read",
input: "agents/hora-bot/agent.go",
wantSub: "Leyendo",
},
{
name: "Glob tool",
tool: "Glob",
input: "agents/*/config.yaml",
wantSub: "Buscando",
},
{
name: "Grep tool",
tool: "Grep",
input: "func Rules",
wantSub: "Buscando",
},
{
name: "unknown tool with input",
tool: "CustomTool",
input: "some argument",
wantSub: "CustomTool",
},
{
name: "unknown tool without input",
tool: "CustomTool",
input: "",
wantSub: "CustomTool",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatToolEvent(1, tt.tool, tt.input)
if !strings.Contains(got, tt.wantSub) {
t.Errorf("formatToolEvent(1, %q, %q) = %q, want substring %q",
tt.tool, tt.input, got, tt.wantSub)
}
if tt.wantNot != "" && strings.Contains(got, tt.wantNot) {
t.Errorf("formatToolEvent(1, %q, %q) = %q, should NOT contain %q",
tt.tool, tt.input, got, tt.wantNot)
}
// All outputs should have step prefix
if !strings.Contains(got, "Paso 1") {
t.Errorf("formatToolEvent output %q should contain step number", got)
}
})
}
}
func TestFormatToolEvent_StepCounter(t *testing.T) {
r1 := formatToolEvent(1, "Bash", "echo hello")
r5 := formatToolEvent(5, "Read", "file.go")
r12 := formatToolEvent(12, "Edit", "config.yaml")
if !strings.Contains(r1, "Paso 1") {
t.Errorf("step 1: %q", r1)
}
if !strings.Contains(r5, "Paso 5") {
t.Errorf("step 5: %q", r5)
}
if !strings.Contains(r12, "Paso 12") {
t.Errorf("step 12: %q", r12)
}
}
func TestProgressReporter_StepCounterIncrements(t *testing.T) {
sender := &mockProgressSender{}
pr := NewProgressReporter(sender, "!room:test", slog.Default())
pr.minInterval = 0
fn := pr.StreamFunc()
fn(coretypes.StreamEvent{Kind: coretypes.StreamInit})
fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Bash", ToolInput: "echo 1"})
fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Read", ToolInput: "file.go"})
fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Edit", ToolInput: "file.go"})
if len(sender.edits) != 3 {
t.Fatalf("expected 3 edits, got %d", len(sender.edits))
}
if !strings.Contains(sender.edits[0], "Paso 1") {
t.Errorf("first edit should be Paso 1, got %q", sender.edits[0])
}
if !strings.Contains(sender.edits[1], "Paso 2") {
t.Errorf("second edit should be Paso 2, got %q", sender.edits[1])
}
if !strings.Contains(sender.edits[2], "Paso 3") {
t.Errorf("third edit should be Paso 3, got %q", sender.edits[2])
}
}
func TestTruncateInput(t *testing.T) {
tests := []struct {
input string
maxLen int
want string
}{
{"short", 10, "short"},
{"exactly10!", 10, "exactly10!"},
{"this is longer than ten", 10, "this is..."},
{"", 10, ""},
}
for _, tt := range tests {
got := truncateInput(tt.input, tt.maxLen)
if got != tt.want {
t.Errorf("truncateInput(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
}
}
}