Files
goclaw/internal/tools/team_tasks_blocker_test.go
viettranx e9342533e0 refactor(tools): extract TeamToolBackend interface for team task testability
Extract TeamToolBackend interface from concrete *TeamToolManager to enable
unit testing of the 17 team task action handlers without DB or message bus.

- Add TeamToolBackend interface (19 methods) in team_tool_backend.go
- Add exported wrappers on TeamToolManager (cache, helpers, dispatch, manager)
- Migrate 133 call sites across 5 action handler files (pure casing rename)
- Change TeamTasksTool.manager field from *TeamToolManager to TeamToolBackend
- Add mock backend infrastructure (baseNoopTeamStore + mockTaskStore + mockBackend)
- Add 94 unit subtests across 6 test files covering all actions:
  lifecycle (claim/complete/cancel/review/approve/reject),
  create validation, blocker escalation, mutations (comment/progress/attach/update),
  followup (ask_user/clear_ask_user/retry), read actions (notification/policy/list/search/get)

WorkspaceInterceptor and PostTurnProcessor remain on concrete *TeamToolManager.
No behavioral changes — all existing logic preserved.
2026-03-27 22:26:36 +07:00

219 lines
6.2 KiB
Go

package tools
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func TestBlockerComment(t *testing.T) {
t.Run("InProgressAutoFail", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusInProgress)
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
result := tool.Execute(memberCtx, map[string]any{
"action": "comment",
"task_id": taskID.String(),
"text": "database is down, cannot proceed",
"type": "blocker",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
// Task should be failed
mb.taskStore.mu.Lock()
task := mb.taskStore.tasks[taskID]
mb.taskStore.mu.Unlock()
if task.Status != store.TeamTaskStatusFailed {
t.Errorf("expected task status=failed, got %s", task.Status)
}
// EventTeamTaskFailed must be broadcast
mb.mu.Lock()
found := false
for _, e := range mb.events {
if e.Name == protocol.EventTeamTaskFailed {
found = true
}
}
mb.mu.Unlock()
if !found {
t.Error("expected EventTeamTaskFailed broadcast")
}
})
t.Run("NotInProgress_NoEscalation", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusCompleted)
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
result := tool.Execute(memberCtx, map[string]any{
"action": "comment",
"task_id": taskID.String(),
"text": "still a concern",
"type": "blocker",
})
if result.IsError {
t.Fatalf("expected success, got: %s", result.ForLLM)
}
// Task should NOT be failed (it was already completed)
mb.taskStore.mu.Lock()
task := mb.taskStore.tasks[taskID]
mb.taskStore.mu.Unlock()
if task.Status != store.TeamTaskStatusCompleted {
t.Errorf("expected task status unchanged (completed), got %s", task.Status)
}
// No EventTeamTaskFailed
mb.mu.Lock()
for _, e := range mb.events {
if e.Name == protocol.EventTeamTaskFailed {
mb.mu.Unlock()
t.Error("EventTeamTaskFailed should NOT be broadcast for non-in-progress task")
return
}
}
mb.mu.Unlock()
})
t.Run("ReasonTruncated500", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusInProgress)
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
longText := strings.Repeat("x", 600)
result := tool.Execute(memberCtx, map[string]any{
"action": "comment",
"task_id": taskID.String(),
"text": longText,
"type": "blocker",
})
if result.IsError {
t.Fatalf("expected success, got: %s", result.ForLLM)
}
mb.taskStore.mu.Lock()
task := mb.taskStore.tasks[taskID]
mb.taskStore.mu.Unlock()
// Result stored as "FAILED: Blocked: <reason>"
// The reason field in FailTask is "Blocked: " + text, truncated to 500 runes total
if task.Result == nil {
t.Fatal("expected task result to be set")
}
if len([]rune(*task.Result)) > 510 { // allow a bit of slack for "FAILED: " prefix
t.Errorf("expected reason to be truncated, got %d runes", len([]rune(*task.Result)))
}
})
t.Run("EscalationEnabled", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
// Enable blocker escalation in team settings
mb.team.Settings = json.RawMessage(`{"blocker_escalation":{"enabled":true}}`)
mb.taskStore.mu.Lock()
mb.taskStore.team = mb.team
mb.taskStore.mu.Unlock()
taskID := uuid.New()
mb.taskStore.mu.Lock()
mb.taskStore.tasks[taskID] = &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID, CreatedAt: time.Now(), UpdatedAt: time.Now()},
TeamID: testTeamID,
Subject: "Escalation task",
Status: store.TeamTaskStatusInProgress,
OwnerAgentID: &memberID,
Channel: ChannelDashboard,
ChatID: testTeamID.String(),
}
mb.taskStore.mu.Unlock()
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
result := tool.Execute(memberCtx, map[string]any{
"action": "comment",
"task_id": taskID.String(),
"text": "completely stuck",
"type": "blocker",
})
if result.IsError {
t.Fatalf("expected success, got: %s", result.ForLLM)
}
mb.mu.Lock()
inboundCount := len(mb.inbound)
mb.mu.Unlock()
if inboundCount == 0 {
t.Error("expected inbound escalation message to be published")
}
})
t.Run("EscalationDisabled", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
// Explicitly disable escalation in settings
mb.team.Settings = json.RawMessage(`{"blocker_escalation":{"enabled":false}}`)
mb.taskStore.mu.Lock()
mb.taskStore.team = mb.team
mb.taskStore.mu.Unlock()
taskID := uuid.New()
mb.taskStore.mu.Lock()
mb.taskStore.tasks[taskID] = &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID, CreatedAt: time.Now(), UpdatedAt: time.Now()},
TeamID: testTeamID,
Subject: "No escalation task",
Status: store.TeamTaskStatusInProgress,
OwnerAgentID: &memberID,
Channel: ChannelDashboard,
ChatID: testTeamID.String(),
}
mb.taskStore.mu.Unlock()
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
result := tool.Execute(memberCtx, map[string]any{
"action": "comment",
"task_id": taskID.String(),
"text": "stuck here",
"type": "blocker",
})
if result.IsError {
t.Fatalf("expected success, got: %s", result.ForLLM)
}
mb.mu.Lock()
inboundCount := len(mb.inbound)
mb.mu.Unlock()
if inboundCount != 0 {
t.Errorf("expected no inbound escalation, got %d", inboundCount)
}
})
}