45bd258be1
Implementa la Fase 2 del issue 0036: mensajes de progreso en tiempo real que muestran al usuario que herramientas esta usando el agente claude-code. - SendMarkdownGetID en shell/matrix/client.go: envia mensaje y retorna el event ID para editarlo despues - EditMessage en shell/matrix/client.go: edita un mensaje existente usando m.replace (m.relates_to con rel_type=m.replace) - ProgressReporter en shell/effects/progress.go (NEW): recibe streaming events y actualiza un mensaje unico en Matrix mostrando el progreso (e.g. "🔧 Bash: ls -la" → "🔧 Read: file.go" → "✅ Completado") - Rate limiter integrado: max 1 edit/segundo para no saturar el homeserver - Conectado en devagents/handler.go: cuando provider=claude-code y streaming+show_tool_progress habilitados, crea ProgressReporter y pasa StreamFunc al CompletionRequest - MatrixSender interface actualizada con los nuevos metodos - 10 tests nuevos para ProgressReporter, todos los existentes pasan Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
227 lines
7.1 KiB
Go
227 lines
7.1 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))
|
|
}
|
|
if !strings.Contains(sender.edits[0], "Bash") {
|
|
t.Errorf("edit = %q, should contain tool name", 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 ...")
|
|
}
|
|
}
|