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) } } }