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.
This commit is contained in:
viettranx committed 2026-03-27 22:26:36 +07:00
1 parent c0dfea35ea
commit e9342533e0
18 files changed
+3357 -138

No files matched your search

+890
View File
@@ -0,0 +1,890 @@
package tools
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ============================================================
// baseNoopTeamStore — noop implementations for all TeamStore methods
// ============================================================
type baseNoopTeamStore struct{}
// TeamCRUDStore
func (b *baseNoopTeamStore) CreateTeam(_ context.Context, _ *store.TeamData) error {
return fmt.Errorf("not implemented: CreateTeam")
}
func (b *baseNoopTeamStore) GetTeam(_ context.Context, _ uuid.UUID) (*store.TeamData, error) {
return nil, fmt.Errorf("not implemented: GetTeam")
}
func (b *baseNoopTeamStore) GetTeamUnscoped(_ context.Context, _ uuid.UUID) (*store.TeamData, error) {
return nil, fmt.Errorf("not implemented: GetTeamUnscoped")
}
func (b *baseNoopTeamStore) UpdateTeam(_ context.Context, _ uuid.UUID, _ map[string]any) error {
return fmt.Errorf("not implemented: UpdateTeam")
}
func (b *baseNoopTeamStore) DeleteTeam(_ context.Context, _ uuid.UUID) error {
return fmt.Errorf("not implemented: DeleteTeam")
}
func (b *baseNoopTeamStore) ListTeams(_ context.Context) ([]store.TeamData, error) {
return nil, fmt.Errorf("not implemented: ListTeams")
}
func (b *baseNoopTeamStore) AddMember(_ context.Context, _, _ uuid.UUID, _ string) error {
return fmt.Errorf("not implemented: AddMember")
}
func (b *baseNoopTeamStore) RemoveMember(_ context.Context, _, _ uuid.UUID) error {
return fmt.Errorf("not implemented: RemoveMember")
}
func (b *baseNoopTeamStore) ListMembers(_ context.Context, _ uuid.UUID) ([]store.TeamMemberData, error) {
return nil, fmt.Errorf("not implemented: ListMembers")
}
func (b *baseNoopTeamStore) ListIdleMembers(_ context.Context, _ uuid.UUID) ([]store.TeamMemberData, error) {
return nil, fmt.Errorf("not implemented: ListIdleMembers")
}
func (b *baseNoopTeamStore) GetTeamForAgent(_ context.Context, _ uuid.UUID) (*store.TeamData, error) {
return nil, fmt.Errorf("not implemented: GetTeamForAgent")
}
func (b *baseNoopTeamStore) KnownUserIDs(_ context.Context, _ uuid.UUID, _ int) ([]string, error) {
return nil, fmt.Errorf("not implemented: KnownUserIDs")
}
func (b *baseNoopTeamStore) ListTaskScopes(_ context.Context, _ uuid.UUID) ([]store.ScopeEntry, error) {
return nil, fmt.Errorf("not implemented: ListTaskScopes")
}
// TaskStore
func (b *baseNoopTeamStore) CreateTask(_ context.Context, _ *store.TeamTaskData) error {
return fmt.Errorf("not implemented: CreateTask")
}
func (b *baseNoopTeamStore) UpdateTask(_ context.Context, _ uuid.UUID, _ map[string]any) error {
return fmt.Errorf("not implemented: UpdateTask")
}
func (b *baseNoopTeamStore) ListTasks(_ context.Context, _ uuid.UUID, _, _, _, _, _ string, _, _ int) ([]store.TeamTaskData, error) {
return nil, fmt.Errorf("not implemented: ListTasks")
}
func (b *baseNoopTeamStore) GetTask(_ context.Context, _ uuid.UUID) (*store.TeamTaskData, error) {
return nil, fmt.Errorf("not implemented: GetTask")
}
func (b *baseNoopTeamStore) GetTasksByIDs(_ context.Context, _ []uuid.UUID) ([]store.TeamTaskData, error) {
return nil, fmt.Errorf("not implemented: GetTasksByIDs")
}
func (b *baseNoopTeamStore) SearchTasks(_ context.Context, _ uuid.UUID, _ string, _ int, _ string) ([]store.TeamTaskData, error) {
return nil, fmt.Errorf("not implemented: SearchTasks")
}
func (b *baseNoopTeamStore) DeleteTask(_ context.Context, _, _ uuid.UUID) error {
return fmt.Errorf("not implemented: DeleteTask")
}
func (b *baseNoopTeamStore) DeleteTasks(_ context.Context, _ []uuid.UUID, _ uuid.UUID) ([]uuid.UUID, error) {
return nil, fmt.Errorf("not implemented: DeleteTasks")
}
func (b *baseNoopTeamStore) ClaimTask(_ context.Context, _, _, _ uuid.UUID) error {
return fmt.Errorf("not implemented: ClaimTask")
}
func (b *baseNoopTeamStore) AssignTask(_ context.Context, _, _, _ uuid.UUID) error {
return fmt.Errorf("not implemented: AssignTask")
}
func (b *baseNoopTeamStore) CompleteTask(_ context.Context, _, _ uuid.UUID, _ string) error {
return fmt.Errorf("not implemented: CompleteTask")
}
func (b *baseNoopTeamStore) CancelTask(_ context.Context, _, _ uuid.UUID, _ string) error {
return fmt.Errorf("not implemented: CancelTask")
}
func (b *baseNoopTeamStore) FailTask(_ context.Context, _, _ uuid.UUID, _ string) error {
return fmt.Errorf("not implemented: FailTask")
}
func (b *baseNoopTeamStore) FailPendingTask(_ context.Context, _, _ uuid.UUID, _ string) error {
return fmt.Errorf("not implemented: FailPendingTask")
}
func (b *baseNoopTeamStore) ReviewTask(_ context.Context, _, _ uuid.UUID) error {
return fmt.Errorf("not implemented: ReviewTask")
}
func (b *baseNoopTeamStore) ApproveTask(_ context.Context, _, _ uuid.UUID, _ string) error {
return fmt.Errorf("not implemented: ApproveTask")
}
func (b *baseNoopTeamStore) RejectTask(_ context.Context, _, _ uuid.UUID, _ string) error {
return fmt.Errorf("not implemented: RejectTask")
}
func (b *baseNoopTeamStore) UpdateTaskProgress(_ context.Context, _, _ uuid.UUID, _ int, _ string) error {
return fmt.Errorf("not implemented: UpdateTaskProgress")
}
func (b *baseNoopTeamStore) RenewTaskLock(_ context.Context, _, _ uuid.UUID) error {
return fmt.Errorf("not implemented: RenewTaskLock")
}
func (b *baseNoopTeamStore) ResetTaskStatus(_ context.Context, _, _ uuid.UUID) error {
return fmt.Errorf("not implemented: ResetTaskStatus")
}
func (b *baseNoopTeamStore) ListActiveTasksByChatID(_ context.Context, _ string) ([]store.TeamTaskData, error) {
return nil, fmt.Errorf("not implemented: ListActiveTasksByChatID")
}
// TaskCommentStore
func (b *baseNoopTeamStore) AddTaskComment(_ context.Context, _ *store.TeamTaskCommentData) error {
return fmt.Errorf("not implemented: AddTaskComment")
}
func (b *baseNoopTeamStore) ListTaskComments(_ context.Context, _ uuid.UUID) ([]store.TeamTaskCommentData, error) {
return nil, fmt.Errorf("not implemented: ListTaskComments")
}
func (b *baseNoopTeamStore) ListRecentTaskComments(_ context.Context, _ uuid.UUID, _ int) ([]store.TeamTaskCommentData, error) {
return nil, fmt.Errorf("not implemented: ListRecentTaskComments")
}
func (b *baseNoopTeamStore) RecordTaskEvent(_ context.Context, _ *store.TeamTaskEventData) error {
return fmt.Errorf("not implemented: RecordTaskEvent")
}
func (b *baseNoopTeamStore) ListTaskEvents(_ context.Context, _ uuid.UUID) ([]store.TeamTaskEventData, error) {
return nil, fmt.Errorf("not implemented: ListTaskEvents")
}
func (b *baseNoopTeamStore) ListTeamEvents(_ context.Context, _ uuid.UUID, _, _ int) ([]store.TeamTaskEventData, error) {
return nil, fmt.Errorf("not implemented: ListTeamEvents")
}
func (b *baseNoopTeamStore) AttachFileToTask(_ context.Context, _ *store.TeamTaskAttachmentData) error {
return fmt.Errorf("not implemented: AttachFileToTask")
}
func (b *baseNoopTeamStore) GetAttachment(_ context.Context, _ uuid.UUID) (*store.TeamTaskAttachmentData, error) {
return nil, fmt.Errorf("not implemented: GetAttachment")
}
func (b *baseNoopTeamStore) ListTaskAttachments(_ context.Context, _ uuid.UUID) ([]store.TeamTaskAttachmentData, error) {
return nil, fmt.Errorf("not implemented: ListTaskAttachments")
}
func (b *baseNoopTeamStore) DetachFileFromTask(_ context.Context, _ uuid.UUID, _ string) error {
return fmt.Errorf("not implemented: DetachFileFromTask")
}
// TaskRecoveryStore
func (b *baseNoopTeamStore) RecoverAllStaleTasks(_ context.Context) ([]store.RecoveredTaskInfo, error) {
return nil, fmt.Errorf("not implemented: RecoverAllStaleTasks")
}
func (b *baseNoopTeamStore) ForceRecoverAllTasks(_ context.Context) ([]store.RecoveredTaskInfo, error) {
return nil, fmt.Errorf("not implemented: ForceRecoverAllTasks")
}
func (b *baseNoopTeamStore) ListRecoverableTasks(_ context.Context, _ uuid.UUID) ([]store.TeamTaskData, error) {
return nil, fmt.Errorf("not implemented: ListRecoverableTasks")
}
func (b *baseNoopTeamStore) MarkAllStaleTasks(_ context.Context, _ time.Time) ([]store.RecoveredTaskInfo, error) {
return nil, fmt.Errorf("not implemented: MarkAllStaleTasks")
}
func (b *baseNoopTeamStore) MarkInReviewStaleTasks(_ context.Context, _ time.Time) ([]store.RecoveredTaskInfo, error) {
return nil, fmt.Errorf("not implemented: MarkInReviewStaleTasks")
}
func (b *baseNoopTeamStore) FixOrphanedBlockedTasks(_ context.Context) ([]store.RecoveredTaskInfo, error) {
return nil, fmt.Errorf("not implemented: FixOrphanedBlockedTasks")
}
// TaskFollowupStore
func (b *baseNoopTeamStore) SetTaskFollowup(_ context.Context, _, _ uuid.UUID, _ time.Time, _ int, _, _, _ string) error {
return fmt.Errorf("not implemented: SetTaskFollowup")
}
func (b *baseNoopTeamStore) ClearTaskFollowup(_ context.Context, _ uuid.UUID) error {
return fmt.Errorf("not implemented: ClearTaskFollowup")
}
func (b *baseNoopTeamStore) ListAllFollowupDueTasks(_ context.Context) ([]store.TeamTaskData, error) {
return nil, fmt.Errorf("not implemented: ListAllFollowupDueTasks")
}
func (b *baseNoopTeamStore) IncrementFollowupCount(_ context.Context, _ uuid.UUID, _ *time.Time) error {
return fmt.Errorf("not implemented: IncrementFollowupCount")
}
func (b *baseNoopTeamStore) ClearFollowupByScope(_ context.Context, _, _ string) (int, error) {
return 0, fmt.Errorf("not implemented: ClearFollowupByScope")
}
func (b *baseNoopTeamStore) SetFollowupForActiveTasks(_ context.Context, _ uuid.UUID, _, _ string, _ time.Time, _ int, _ string) (int, error) {
return 0, fmt.Errorf("not implemented: SetFollowupForActiveTasks")
}
func (b *baseNoopTeamStore) HasActiveMemberTasks(_ context.Context, _ uuid.UUID, _ uuid.UUID) (bool, error) {
return false, fmt.Errorf("not implemented: HasActiveMemberTasks")
}
// TeamAccessStore
func (b *baseNoopTeamStore) GrantTeamAccess(_ context.Context, _ uuid.UUID, _, _, _ string) error {
return fmt.Errorf("not implemented: GrantTeamAccess")
}
func (b *baseNoopTeamStore) RevokeTeamAccess(_ context.Context, _ uuid.UUID, _ string) error {
return fmt.Errorf("not implemented: RevokeTeamAccess")
}
func (b *baseNoopTeamStore) ListTeamGrants(_ context.Context, _ uuid.UUID) ([]store.TeamUserGrant, error) {
return nil, fmt.Errorf("not implemented: ListTeamGrants")
}
func (b *baseNoopTeamStore) ListUserTeams(_ context.Context, _ string) ([]store.TeamData, error) {
return nil, fmt.Errorf("not implemented: ListUserTeams")
}
func (b *baseNoopTeamStore) HasTeamAccess(_ context.Context, _ uuid.UUID, _ string) (bool, error) {
return false, fmt.Errorf("not implemented: HasTeamAccess")
}
// ============================================================
// mockTaskStore — in-memory TeamStore for unit tests
// ============================================================
type mockTaskStore struct {
baseNoopTeamStore
tasks map[uuid.UUID]*store.TeamTaskData
comments map[uuid.UUID][]store.TeamTaskCommentData
events map[uuid.UUID][]store.TeamTaskEventData
attachments map[uuid.UUID][]store.TeamTaskAttachmentData
team *store.TeamData
members []store.TeamMemberData
taskSeq int
mu sync.Mutex
}
func newMockTaskStore(team *store.TeamData, members []store.TeamMemberData) *mockTaskStore {
return &mockTaskStore{
tasks: make(map[uuid.UUID]*store.TeamTaskData),
comments: make(map[uuid.UUID][]store.TeamTaskCommentData),
events: make(map[uuid.UUID][]store.TeamTaskEventData),
attachments: make(map[uuid.UUID][]store.TeamTaskAttachmentData),
team: team,
members: members,
}
}
func (s *mockTaskStore) GetTeam(_ context.Context, teamID uuid.UUID) (*store.TeamData, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.team != nil && s.team.ID == teamID {
return s.team, nil
}
return nil, fmt.Errorf("team not found: %s", teamID)
}
func (s *mockTaskStore) GetTeamForAgent(_ context.Context, _ uuid.UUID) (*store.TeamData, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.team, nil
}
func (s *mockTaskStore) ListMembers(_ context.Context, _ uuid.UUID) ([]store.TeamMemberData, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.members, nil
}
func (s *mockTaskStore) GetTask(_ context.Context, taskID uuid.UUID) (*store.TeamTaskData, error) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return nil, store.ErrTaskNotFound
}
cp := *t
return &cp, nil
}
func (s *mockTaskStore) GetTasksByIDs(_ context.Context, ids []uuid.UUID) ([]store.TeamTaskData, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]store.TeamTaskData, 0, len(ids))
for _, id := range ids {
if t, ok := s.tasks[id]; ok {
out = append(out, *t)
}
}
return out, nil
}
func (s *mockTaskStore) CreateTask(_ context.Context, task *store.TeamTaskData) error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
task.ID = uuid.New()
s.taskSeq++
task.TaskNumber = s.taskSeq
task.CreatedAt = now
task.UpdatedAt = now
if task.Identifier == "" {
task.Identifier = fmt.Sprintf("T-%d", task.TaskNumber)
}
cp := *task
s.tasks[task.ID] = &cp
return nil
}
func (s *mockTaskStore) UpdateTask(_ context.Context, taskID uuid.UUID, updates map[string]any) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
for k, v := range updates {
switch k {
case "status":
if sv, ok := v.(string); ok {
t.Status = sv
}
case "result":
if sv, ok := v.(string); ok {
t.Result = &sv
}
case "metadata":
if mv, ok := v.(map[string]any); ok {
t.Metadata = mv
}
case "description":
if sv, ok := v.(string); ok {
t.Description = sv
}
case "subject":
if sv, ok := v.(string); ok {
t.Subject = sv
}
case "blocked_by":
if ids, ok := v.([]uuid.UUID); ok {
t.BlockedBy = ids
}
case "owner_agent_id":
if id, ok := v.(uuid.UUID); ok {
t.OwnerAgentID = &id
}
case "locked_at":
if tv, ok := v.(time.Time); ok {
t.LockedAt = &tv
}
case "lock_expires_at":
if tv, ok := v.(time.Time); ok {
t.LockExpiresAt = &tv
}
case "progress_percent":
if iv, ok := v.(int); ok {
t.ProgressPercent = iv
}
case "progress_step":
if sv, ok := v.(string); ok {
t.ProgressStep = sv
}
case "followup_at":
if tv, ok := v.(time.Time); ok {
t.FollowupAt = &tv
}
case "followup_count":
if iv, ok := v.(int); ok {
t.FollowupCount = iv
}
case "followup_max":
if iv, ok := v.(int); ok {
t.FollowupMax = iv
}
case "followup_message":
if sv, ok := v.(string); ok {
t.FollowupMessage = sv
}
case "followup_channel":
if sv, ok := v.(string); ok {
t.FollowupChannel = sv
}
case "followup_chat_id":
if sv, ok := v.(string); ok {
t.FollowupChatID = sv
}
}
}
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) ClaimTask(_ context.Context, taskID, agentID, _ uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
if t.Status != store.TeamTaskStatusPending {
return fmt.Errorf("cannot claim task with status %q", t.Status)
}
if t.OwnerAgentID != nil && *t.OwnerAgentID != agentID {
return fmt.Errorf("task already owned by another agent")
}
now := time.Now()
t.Status = store.TeamTaskStatusInProgress
t.OwnerAgentID = &agentID
t.LockedAt = &now
t.UpdatedAt = now
return nil
}
func (s *mockTaskStore) AssignTask(_ context.Context, taskID, agentID, _ uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
if t.Status != store.TeamTaskStatusPending {
return fmt.Errorf("cannot assign task with status %q", t.Status)
}
now := time.Now()
t.Status = store.TeamTaskStatusInProgress
t.OwnerAgentID = &agentID
t.LockedAt = &now
t.UpdatedAt = now
return nil
}
func (s *mockTaskStore) CompleteTask(_ context.Context, taskID, _ uuid.UUID, result string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
if t.Status != store.TeamTaskStatusInProgress {
return fmt.Errorf("cannot complete task with status %q", t.Status)
}
t.Status = store.TeamTaskStatusCompleted
t.Result = &result
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) CancelTask(_ context.Context, taskID, _ uuid.UUID, reason string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
if t.Status == store.TeamTaskStatusCompleted || t.Status == store.TeamTaskStatusCancelled {
return fmt.Errorf("cannot cancel task with status %q", t.Status)
}
t.Status = store.TeamTaskStatusCancelled
t.Result = &reason
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) FailTask(_ context.Context, taskID, _ uuid.UUID, errMsg string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
if t.Status != store.TeamTaskStatusInProgress {
return fmt.Errorf("cannot fail task with status %q", t.Status)
}
result := "FAILED: " + errMsg
t.Status = store.TeamTaskStatusFailed
t.Result = &result
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) FailPendingTask(_ context.Context, taskID, _ uuid.UUID, errMsg string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
if t.Status != store.TeamTaskStatusPending && t.Status != store.TeamTaskStatusBlocked {
return fmt.Errorf("cannot fail-pending task with status %q", t.Status)
}
t.Status = store.TeamTaskStatusFailed
t.Result = &errMsg
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) ReviewTask(_ context.Context, taskID, _ uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
if t.Status != store.TeamTaskStatusInProgress {
return fmt.Errorf("cannot review task with status %q", t.Status)
}
t.Status = store.TeamTaskStatusInReview
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) ApproveTask(_ context.Context, taskID, _ uuid.UUID, _ string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
if t.Status != store.TeamTaskStatusInReview {
return fmt.Errorf("cannot approve task with status %q", t.Status)
}
t.Status = store.TeamTaskStatusCompleted
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) RejectTask(_ context.Context, taskID, _ uuid.UUID, reason string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
if t.Status != store.TeamTaskStatusInReview {
return fmt.Errorf("cannot reject task with status %q", t.Status)
}
t.Status = store.TeamTaskStatusCancelled
t.Result = &reason
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) ResetTaskStatus(_ context.Context, taskID, _ uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
t.Status = store.TeamTaskStatusPending
t.LockedAt = nil
t.LockExpiresAt = nil
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) UpdateTaskProgress(_ context.Context, taskID, _ uuid.UUID, percent int, step string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
if t.Status != store.TeamTaskStatusInProgress {
return fmt.Errorf("cannot update progress for task with status %q", t.Status)
}
t.ProgressPercent = percent
t.ProgressStep = step
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) RenewTaskLock(_ context.Context, taskID, _ uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
expires := time.Now().Add(10 * time.Minute)
t.LockExpiresAt = &expires
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) ListTasks(_ context.Context, teamID uuid.UUID, _, _, _, _, _ string, _, _ int) ([]store.TeamTaskData, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []store.TeamTaskData
for _, t := range s.tasks {
if t.TeamID == teamID {
out = append(out, *t)
}
}
return out, nil
}
func (s *mockTaskStore) SearchTasks(_ context.Context, teamID uuid.UUID, query string, limit int, _ string) ([]store.TeamTaskData, error) {
s.mu.Lock()
defer s.mu.Unlock()
q := strings.ToLower(query)
var out []store.TeamTaskData
for _, t := range s.tasks {
if t.TeamID == teamID && strings.Contains(strings.ToLower(t.Subject), q) {
out = append(out, *t)
if limit > 0 && len(out) >= limit {
break
}
}
}
return out, nil
}
func (s *mockTaskStore) AddTaskComment(_ context.Context, comment *store.TeamTaskCommentData) error {
s.mu.Lock()
defer s.mu.Unlock()
comment.ID = uuid.New()
comment.CreatedAt = time.Now()
cp := *comment
s.comments[comment.TaskID] = append(s.comments[comment.TaskID], cp)
return nil
}
func (s *mockTaskStore) ListTaskComments(_ context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.comments[taskID], nil
}
func (s *mockTaskStore) ListRecentTaskComments(_ context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) {
s.mu.Lock()
defer s.mu.Unlock()
all := s.comments[taskID]
if limit <= 0 || len(all) <= limit {
return all, nil
}
return all[len(all)-limit:], nil
}
func (s *mockTaskStore) ListTaskEvents(_ context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.events[taskID], nil
}
func (s *mockTaskStore) AttachFileToTask(_ context.Context, att *store.TeamTaskAttachmentData) error {
s.mu.Lock()
defer s.mu.Unlock()
att.ID = uuid.New()
att.CreatedAt = time.Now()
cp := *att
s.attachments[att.TaskID] = append(s.attachments[att.TaskID], cp)
return nil
}
func (s *mockTaskStore) ListTaskAttachments(_ context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.attachments[taskID], nil
}
func (s *mockTaskStore) SetTaskFollowup(_ context.Context, taskID, _ uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
t.FollowupAt = &followupAt
t.FollowupMax = max
t.FollowupMessage = message
t.FollowupChannel = channel
t.FollowupChatID = chatID
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) ClearTaskFollowup(_ context.Context, taskID uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[taskID]
if !ok {
return store.ErrTaskNotFound
}
t.FollowupAt = nil
t.FollowupCount = 0
t.FollowupMax = 0
t.FollowupMessage = ""
t.FollowupChannel = ""
t.FollowupChatID = ""
t.UpdatedAt = time.Now()
return nil
}
func (s *mockTaskStore) ListRecoverableTasks(_ context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []store.TeamTaskData
for _, t := range s.tasks {
if t.TeamID != teamID {
continue
}
if t.Status == store.TeamTaskStatusPending || t.Status == store.TeamTaskStatusStale {
out = append(out, *t)
}
}
return out, nil
}
// ============================================================
// capturedEvent / capturedDispatch
// ============================================================
type capturedEvent struct {
Name string
Payload any
}
type capturedDispatch struct {
TaskID uuid.UUID
AgentID uuid.UUID
}
// ============================================================
// mockBackend — implements TeamToolBackend for unit tests
// ============================================================
type mockBackend struct {
team *store.TeamData
members []store.TeamMemberData
agents map[uuid.UUID]*store.AgentData
agentKeys map[string]*store.AgentData
taskStore *mockTaskStore
events []capturedEvent
dispatches []capturedDispatch
inbound []bus.InboundMessage
mu sync.Mutex
}
func newMockBackend(team *store.TeamData, members []store.TeamMemberData, agents []*store.AgentData) *mockBackend {
mb := &mockBackend{
team: team,
members: members,
agents: make(map[uuid.UUID]*store.AgentData),
agentKeys: make(map[string]*store.AgentData),
taskStore: newMockTaskStore(team, members),
}
for _, ag := range agents {
mb.agents[ag.ID] = ag
mb.agentKeys[ag.AgentKey] = ag
}
return mb
}
func (mb *mockBackend) ResolveTeam(ctx context.Context) (*store.TeamData, uuid.UUID, error) {
if mb.team == nil {
return nil, uuid.Nil, fmt.Errorf("no team configured in mock")
}
agentID := store.AgentIDFromContext(ctx)
return mb.team, agentID, nil
}
func (mb *mockBackend) RequireLead(ctx context.Context, team *store.TeamData, agentID uuid.UUID) error {
channel := ToolChannelFromCtx(ctx)
if channel == ChannelTeammate || channel == ChannelSystem {
return nil
}
if agentID != team.LeadAgentID {
return fmt.Errorf("only the team lead can perform this action")
}
return nil
}
func (mb *mockBackend) Store() store.TeamStore { return mb.taskStore }
func (mb *mockBackend) ResolveAgentByKey(_ context.Context, key string) (uuid.UUID, error) {
if ag, ok := mb.agentKeys[key]; ok {
return ag.ID, nil
}
return uuid.Nil, fmt.Errorf("agent not found: %s", key)
}
func (mb *mockBackend) AgentKeyFromID(_ context.Context, id uuid.UUID) string {
if ag, ok := mb.agents[id]; ok {
return ag.AgentKey
}
return id.String()
}
func (mb *mockBackend) AgentDisplayName(_ context.Context, key string) string {
if ag, ok := mb.agentKeys[key]; ok {
return ag.DisplayName
}
return ""
}
func (mb *mockBackend) CachedListMembers(_ context.Context, _ uuid.UUID, _ uuid.UUID) ([]store.TeamMemberData, error) {
return mb.members, nil
}
func (mb *mockBackend) CachedGetAgentByID(_ context.Context, id uuid.UUID) (*store.AgentData, error) {
if ag, ok := mb.agents[id]; ok {
return ag, nil
}
return nil, fmt.Errorf("agent not found: %s", id)
}
func (mb *mockBackend) PreWarmAgentKeyCache(_ context.Context, _ []string) {}
func (mb *mockBackend) PreWarmAgentIDCache(_ context.Context, _ []uuid.UUID) {}
func (mb *mockBackend) BroadcastTeamEvent(_ context.Context, name string, payload any) {
mb.mu.Lock()
mb.events = append(mb.events, capturedEvent{Name: name, Payload: payload})
mb.mu.Unlock()
}
func (mb *mockBackend) DispatchTaskToAgent(_ context.Context, task *store.TeamTaskData, _ *store.TeamData, agentID uuid.UUID) {
mb.mu.Lock()
mb.dispatches = append(mb.dispatches, capturedDispatch{TaskID: task.ID, AgentID: agentID})
mb.mu.Unlock()
}
func (mb *mockBackend) TryPublishInbound(msg bus.InboundMessage) bool {
mb.mu.Lock()
mb.inbound = append(mb.inbound, msg)
mb.mu.Unlock()
return true
}
func (mb *mockBackend) BuildBlockerResultsSummary(_ context.Context, _ *store.TeamTaskData) string { return "" }
func (mb *mockBackend) BuildRecentCommentsSummary(_ context.Context, _ uuid.UUID) string { return "" }
func (mb *mockBackend) RestoreTraceContext(ctx context.Context, _ *store.TeamTaskData) context.Context {
return ctx
}
func (mb *mockBackend) FollowupDelayMinutes(_ *store.TeamData) int { return 30 }
func (mb *mockBackend) FollowupMaxReminders(_ *store.TeamData) int { return 0 }
func (mb *mockBackend) DataDir() string { return "/tmp/test" }
// ============================================================
// newTestTeamSetup — standard test fixture
// ============================================================
var (
testTeamID = uuid.MustParse("00000000-0000-0000-0000-000000000001")
testLeadID = uuid.MustParse("00000000-0000-0000-0000-000000000002")
testMemberID = uuid.MustParse("00000000-0000-0000-0000-000000000003")
testMember2ID = uuid.MustParse("00000000-0000-0000-0000-000000000004")
testTenantID = uuid.MustParse("00000000-0000-0000-0000-000000000099")
)
func newTestTeamSetup() (*mockBackend, *TeamTasksTool, uuid.UUID, uuid.UUID, context.Context) {
team := &store.TeamData{
BaseModel: store.BaseModel{ID: testTeamID},
Name: "test-team",
LeadAgentID: testLeadID,
Status: store.TeamStatusActive,
}
leadAgent := &store.AgentData{
BaseModel: store.BaseModel{ID: testLeadID},
AgentKey: "lead-agent",
DisplayName: "Lead Agent",
}
memberAgent := &store.AgentData{
BaseModel: store.BaseModel{ID: testMemberID},
AgentKey: "member-agent",
DisplayName: "Member Agent",
}
member2Agent := &store.AgentData{
BaseModel: store.BaseModel{ID: testMember2ID},
AgentKey: "member2-agent",
DisplayName: "Member 2 Agent",
}
members := []store.TeamMemberData{
{TeamID: testTeamID, AgentID: testLeadID, Role: store.TeamRoleLead, AgentKey: "lead-agent"},
{TeamID: testTeamID, AgentID: testMemberID, Role: store.TeamRoleMember, AgentKey: "member-agent"},
{TeamID: testTeamID, AgentID: testMember2ID, Role: store.TeamRoleMember, AgentKey: "member2-agent"},
}
mb := newMockBackend(team, members, []*store.AgentData{leadAgent, memberAgent, member2Agent})
tool := NewTeamTasksTool(mb, FullTeamPolicy{})
ctx := context.Background()
ctx = store.WithTenantID(ctx, testTenantID)
ctx = store.WithAgentID(ctx, testLeadID)
ctx = WithToolChannel(ctx, ChannelDashboard)
ctx = WithToolChatID(ctx, testTeamID.String())
ctx = WithTaskActionFlags(ctx, &TaskActionFlags{})
return mb, tool, testLeadID, testMemberID, ctx
}
+7 -7
View File
@@ -35,7 +35,7 @@ func (t *TeamTasksTool) handleBlockerComment(
if len([]rune(reason)) > 500 {
reason = string([]rune(reason)[:500])
}
if err := t.manager.teamStore.FailTask(ctx, taskID, team.ID, reason); err != nil {
if err := t.manager.Store().FailTask(ctx, taskID, team.ID, reason); err != nil {
slog.Warn("blocker: FailTask error", "task_id", taskID, "error", err)
// Task may have completed concurrently — not a hard error.
return NewResult("Blocker comment saved. Task may have already completed — check task status.")
@@ -45,8 +45,8 @@ func (t *TeamTasksTool) handleBlockerComment(
// 1. Cancel subscriber → sched.CancelSession() → member stops
// 2. Notify subscriber → "❌ Task failed" → chat channel (direct outbound)
// 3. WS broadcast → web UI dashboard real-time update
memberKey := t.manager.agentKeyFromID(ctx, agentID)
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
memberKey := t.manager.AgentKeyFromID(ctx, agentID)
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
@@ -64,8 +64,8 @@ func (t *TeamTasksTool) handleBlockerComment(
// Escalate to leader if enabled in team settings.
escalationCfg := ParseBlockerEscalationConfig(team.Settings)
if escalationCfg.Enabled && t.manager.msgBus != nil {
leadAg, err := t.manager.cachedGetAgentByID(ctx, team.LeadAgentID)
if escalationCfg.Enabled {
leadAg, err := t.manager.CachedGetAgentByID(ctx, team.LeadAgentID)
if err == nil {
escalationMsg := fmt.Sprintf(
"[Escalation] Member %q is blocked on task #%d \"%s\"\n\n"+
@@ -73,7 +73,7 @@ func (t *TeamTasksTool) handleBlockerComment(
"Use team_tasks(action=\"retry\", task_id=\"%s\") to reopen with updated instructions.",
memberKey, task.TaskNumber, task.Subject, text, taskID)
if !t.manager.msgBus.TryPublishInbound(bus.InboundMessage{
if !t.manager.TryPublishInbound(bus.InboundMessage{
Channel: task.Channel,
SenderID: "system:escalation",
ChatID: task.ChatID,
@@ -82,7 +82,7 @@ func (t *TeamTasksTool) handleBlockerComment(
TenantID: store.TenantIDFromContext(ctx),
AgentID: leadAg.AgentKey,
}) {
slog.Warn("blocker: inbound buffer full, escalation dropped",
slog.Warn("blocker: escalation dropped (bus unavailable or buffer full)",
"task_id", taskID, "leader", leadAg.AgentKey)
}
}
+218
View File
@@ -0,0 +1,218 @@
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)
}
})
}
+427
View File
@@ -0,0 +1,427 @@
package tools
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestCreate(t *testing.T) {
t.Run("Success", func(t *testing.T) {
mb, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Test task",
"description": "Do something",
"assignee": "member-agent",
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "Task created") {
t.Errorf("expected 'Task created' in response, got: %s", result.ForLLM)
}
mb.taskStore.mu.Lock()
created := len(mb.taskStore.tasks)
mb.taskStore.mu.Unlock()
if created != 1 {
t.Errorf("expected 1 task, got %d", created)
}
// Verify event was broadcast
mb.mu.Lock()
evCount := len(mb.events)
mb.mu.Unlock()
if evCount == 0 {
t.Error("expected at least one event broadcast")
}
// Check task status is pending
mb.taskStore.mu.Lock()
var task *store.TeamTaskData
for _, v := range mb.taskStore.tasks {
task = v
}
mb.taskStore.mu.Unlock()
if task.Status != store.TeamTaskStatusPending {
t.Errorf("expected status pending, got %s", task.Status)
}
})
t.Run("MemberGeneralBlocked", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
result := tool.Execute(memberCtx, map[string]any{
"action": "create",
"subject": "General task",
"assignee": "member2-agent",
})
if !result.IsError {
t.Fatal("expected error for member creating general task")
}
if !strings.Contains(result.ForLLM, "Members cannot create tasks") {
t.Errorf("expected 'Members cannot create tasks', got: %s", result.ForLLM)
}
_ = mb
})
t.Run("MemberRequestEnabled", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
mb.team.Settings = json.RawMessage(`{"member_requests":{"enabled":true}}`)
mb.taskStore.team = mb.team
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
result := tool.Execute(memberCtx, map[string]any{
"action": "create",
"subject": "Need help with this",
"task_type": "request",
"assignee": "lead-agent",
})
// Note: lead-agent is the lead, but the self-assign check only applies when lead assigns to itself.
// Here the member is assigning to the lead which is allowed.
// Actually lead-agent IS team.LeadAgentID, so the check "assigneeID == team.LeadAgentID" fires.
// Let's assign to member2 instead.
_ = result
result = tool.Execute(memberCtx, map[string]any{
"action": "create",
"subject": "Need help with this",
"task_type": "request",
"assignee": "member2-agent",
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
})
t.Run("MemberRequestDisabled", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
// Settings empty = member_request disabled
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
result := tool.Execute(memberCtx, map[string]any{
"action": "create",
"subject": "Request something",
"task_type": "request",
"assignee": "lead-agent",
})
if !result.IsError {
t.Fatal("expected error for member request when disabled")
}
if !strings.Contains(result.ForLLM, "Members cannot create tasks") {
t.Errorf("expected 'Members cannot create tasks', got: %s", result.ForLLM)
}
_ = mb
})
t.Run("RequiresSearchGate", func(t *testing.T) {
_, tool, _, _, ctx := newTestTeamSetup()
// ptd exists but HasListed=false
ptd := NewPendingTeamDispatch()
ctx = WithPendingTeamDispatch(ctx, ptd)
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Test task",
"assignee": "member-agent",
})
if !result.IsError {
t.Fatal("expected error when list gate not passed")
}
if !strings.Contains(result.ForLLM, "check existing tasks") {
t.Errorf("expected gate message, got: %s", result.ForLLM)
}
})
t.Run("EmptySubject", func(t *testing.T) {
_, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "",
"assignee": "member-agent",
})
if !result.IsError {
t.Fatal("expected error for empty subject")
}
if !strings.Contains(result.ForLLM, "subject is required") {
t.Errorf("expected 'subject is required', got: %s", result.ForLLM)
}
})
t.Run("RequiresAssignee", func(t *testing.T) {
_, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Task without assignee",
})
if !result.IsError {
t.Fatal("expected error for missing assignee")
}
if !strings.Contains(result.ForLLM, "assignee is required") {
t.Errorf("expected 'assignee is required', got: %s", result.ForLLM)
}
})
t.Run("SelfAssignBlocked", func(t *testing.T) {
_, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Self assigned task",
"assignee": "lead-agent",
})
if !result.IsError {
t.Fatal("expected error for self-assign")
}
if !strings.Contains(result.ForLLM, "cannot assign tasks to itself") {
t.Errorf("expected self-assign error, got: %s", result.ForLLM)
}
})
t.Run("NonMemberAssignee", func(t *testing.T) {
_, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Task for unknown",
"assignee": "unknown-agent",
})
if !result.IsError {
t.Fatal("expected error for non-member assignee")
}
if !strings.Contains(result.ForLLM, "not found") {
t.Errorf("expected 'not found', got: %s", result.ForLLM)
}
})
t.Run("BlockedByTerminal", func(t *testing.T) {
mb, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
blockerID := uuid.New()
mb.taskStore.mu.Lock()
mb.taskStore.tasks[blockerID] = &store.TeamTaskData{
BaseModel: store.BaseModel{ID: blockerID, CreatedAt: time.Now(), UpdatedAt: time.Now()},
TeamID: testTeamID,
Subject: "Blocker task",
Status: store.TeamTaskStatusCompleted,
}
mb.taskStore.mu.Unlock()
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Blocked task",
"assignee": "member-agent",
"blocked_by": []any{blockerID.String()},
})
if !result.IsError {
t.Fatal("expected error for blocking on terminal task")
}
if !strings.Contains(result.ForLLM, "already") {
t.Errorf("expected finished task error, got: %s", result.ForLLM)
}
})
t.Run("BlockedByCrossTeam", func(t *testing.T) {
mb, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
otherTeamID := uuid.New()
crossTeamTaskID := uuid.New()
mb.taskStore.mu.Lock()
mb.taskStore.tasks[crossTeamTaskID] = &store.TeamTaskData{
BaseModel: store.BaseModel{ID: crossTeamTaskID, CreatedAt: time.Now(), UpdatedAt: time.Now()},
TeamID: otherTeamID,
Subject: "Cross team task",
Status: store.TeamTaskStatusPending,
}
mb.taskStore.mu.Unlock()
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Cross-team blocked task",
"assignee": "member-agent",
"blocked_by": []any{crossTeamTaskID.String()},
})
if !result.IsError {
t.Fatal("expected error for cross-team blocked_by")
}
if !strings.Contains(result.ForLLM, "different team") {
t.Errorf("expected 'different team' error, got: %s", result.ForLLM)
}
})
t.Run("BlockedByInvalidUUID", func(t *testing.T) {
_, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Task with bad blocker",
"assignee": "member-agent",
"blocked_by": []any{"not-a-uuid"},
})
if !result.IsError {
t.Fatal("expected error for invalid UUID in blocked_by")
}
if !strings.Contains(result.ForLLM, "invalid") {
t.Errorf("expected 'invalid' UUID error, got: %s", result.ForLLM)
}
})
t.Run("RequireApproval", func(t *testing.T) {
mb, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Approval task",
"assignee": "member-agent",
"require_approval": true,
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
mb.taskStore.mu.Lock()
var task *store.TeamTaskData
for _, v := range mb.taskStore.tasks {
task = v
}
mb.taskStore.mu.Unlock()
if task.Status != store.TeamTaskStatusInReview {
t.Errorf("expected status in_review, got %s", task.Status)
}
})
t.Run("WithBlockers", func(t *testing.T) {
mb, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
blockerID := uuid.New()
mb.taskStore.mu.Lock()
mb.taskStore.tasks[blockerID] = &store.TeamTaskData{
BaseModel: store.BaseModel{ID: blockerID, CreatedAt: time.Now(), UpdatedAt: time.Now()},
TeamID: testTeamID,
Subject: "Pending blocker",
Status: store.TeamTaskStatusPending,
}
mb.taskStore.mu.Unlock()
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Blocked task",
"assignee": "member-agent",
"blocked_by": []any{blockerID.String()},
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
mb.taskStore.mu.Lock()
var created *store.TeamTaskData
for _, v := range mb.taskStore.tasks {
if v.Subject == "Blocked task" {
created = v
}
}
mb.taskStore.mu.Unlock()
if created == nil {
t.Fatal("task not found in store")
}
if created.Status != store.TeamTaskStatusBlocked {
t.Errorf("expected status blocked, got %s", created.Status)
}
})
t.Run("PendingDispatchTracked", func(t *testing.T) {
mb, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Tracked task",
"assignee": "member-agent",
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
drained := ptd.Drain()
ids := drained[testTeamID]
if len(ids) != 1 {
t.Errorf("expected 1 pending dispatch, got %d", len(ids))
}
mb.taskStore.mu.Lock()
var task *store.TeamTaskData
for _, v := range mb.taskStore.tasks {
task = v
}
mb.taskStore.mu.Unlock()
if task != nil && len(ids) == 1 && ids[0] != task.ID {
t.Errorf("tracked task ID %s does not match created task ID %s", ids[0], task.ID)
}
})
t.Run("CompoundSubjectWarning", func(t *testing.T) {
_, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ptd.MarkListed()
ctx = WithPendingTeamDispatch(ctx, ptd)
result := tool.Execute(ctx, map[string]any{
"action": "create",
"subject": "Implement and design the new feature",
"assignee": "member-agent",
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "Warning") {
t.Errorf("expected Warning in response for compound subject, got: %s", result.ForLLM)
}
})
}
+18 -18
View File
@@ -10,7 +10,7 @@ import (
)
func (t *TeamTasksTool) executeAskUser(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -26,7 +26,7 @@ func (t *TeamTasksTool) executeAskUser(ctx context.Context, args map[string]any)
}
// Verify ownership.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
@@ -38,8 +38,8 @@ func (t *TeamTasksTool) executeAskUser(ctx context.Context, args map[string]any)
}
// Resolve delay and max from team settings.
delayMinutes := t.manager.followupDelayMinutes(team)
maxReminders := t.manager.followupMaxReminders(team)
delayMinutes := t.manager.FollowupDelayMinutes(team)
maxReminders := t.manager.FollowupMaxReminders(team)
// Resolve channel: prefer task's channel, fallback to context channel.
channel := task.Channel
@@ -54,7 +54,7 @@ func (t *TeamTasksTool) executeAskUser(ctx context.Context, args map[string]any)
}
followupAt := time.Now().Add(time.Duration(delayMinutes) * time.Minute)
if err := t.manager.teamStore.SetTaskFollowup(ctx, taskID, team.ID, followupAt, maxReminders, followupMessage, channel, chatID); err != nil {
if err := t.manager.Store().SetTaskFollowup(ctx, taskID, team.ID, followupAt, maxReminders, followupMessage, channel, chatID); err != nil {
return ErrorResult("failed to set follow-up: " + err.Error())
}
@@ -66,7 +66,7 @@ func (t *TeamTasksTool) executeAskUser(ctx context.Context, args map[string]any)
}
func (t *TeamTasksTool) executeClearAskUser(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -77,7 +77,7 @@ func (t *TeamTasksTool) executeClearAskUser(ctx context.Context, args map[string
}
// Verify task belongs to team.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
@@ -89,7 +89,7 @@ func (t *TeamTasksTool) executeClearAskUser(ctx context.Context, args map[string
return ErrorResult("only the task owner or team lead can clear follow-up reminders")
}
if err := t.manager.teamStore.ClearTaskFollowup(ctx, taskID); err != nil {
if err := t.manager.Store().ClearTaskFollowup(ctx, taskID); err != nil {
return ErrorResult("failed to clear follow-up: " + err.Error())
}
@@ -97,11 +97,11 @@ func (t *TeamTasksTool) executeClearAskUser(ctx context.Context, args map[string
}
func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
@@ -110,7 +110,7 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *
return ErrorResult(err.Error())
}
task, err := t.manager.teamStore.GetTask(ctx, taskID)
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
@@ -132,32 +132,32 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *
}
// Reset status to pending first (AssignTask only transitions from pending).
if err := t.manager.teamStore.ResetTaskStatus(ctx, taskID, team.ID); err != nil {
if err := t.manager.Store().ResetTaskStatus(ctx, taskID, team.ID); err != nil {
return ErrorResult("failed to reset task: " + err.Error())
}
// Assign (pending → in_progress + lock).
if err := t.manager.teamStore.AssignTask(ctx, taskID, *task.OwnerAgentID, team.ID); err != nil {
if err := t.manager.Store().AssignTask(ctx, taskID, *task.OwnerAgentID, team.ID); err != nil {
return ErrorResult("failed to retry task: " + err.Error())
}
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: t.manager.agentKeyFromID(ctx, *task.OwnerAgentID),
OwnerAgentKey: t.manager.AgentKeyFromID(ctx, *task.OwnerAgentID),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
})
// Dispatch immediately (retry is an explicit action, not during a turn).
t.manager.dispatchTaskToAgent(ctx, task, team, *task.OwnerAgentID)
t.manager.DispatchTaskToAgent(ctx, task, team, *task.OwnerAgentID)
assignee := t.manager.agentKeyFromID(ctx, *task.OwnerAgentID)
assignee := t.manager.AgentKeyFromID(ctx, *task.OwnerAgentID)
return NewResult(fmt.Sprintf("Task #%d \"%s\" (id: %s) retried and dispatched to %s. The assignee will receive the task with your recent comments.", task.TaskNumber, task.Subject, taskID, assignee))
}
+341
View File
@@ -0,0 +1,341 @@
package tools
import (
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestAskUser(t *testing.T) {
t.Run("OwnerSuccess", 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())
// Set an external channel so followup can resolve
memberCtx = WithToolChannel(memberCtx, "telegram")
result := tool.Execute(memberCtx, map[string]any{
"action": "ask_user",
"task_id": taskID.String(),
"text": "Do you want me to proceed with option A or B?",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
mb.taskStore.mu.Lock()
task := mb.taskStore.tasks[taskID]
mb.taskStore.mu.Unlock()
if task.FollowupAt == nil {
t.Error("expected FollowupAt to be set")
}
if task.FollowupMessage == "" {
t.Error("expected FollowupMessage to be set")
}
})
t.Run("NonOwnerBlocked", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusInProgress)
leadCtx := store.WithAgentID(ctx, leadID)
leadCtx = WithTaskActionFlags(leadCtx, &TaskActionFlags{})
result := tool.Execute(leadCtx, map[string]any{
"action": "ask_user",
"task_id": taskID.String(),
"text": "Can you clarify the deadline?",
})
if !result.IsError || !strings.Contains(result.ForLLM, "only the task owner") {
t.Errorf("expected owner-only error, got: %s (isError=%v)", result.ForLLM, result.IsError)
}
})
t.Run("EmptyText", 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{})
result := tool.Execute(memberCtx, map[string]any{
"action": "ask_user",
"task_id": taskID.String(),
"text": "",
})
if !result.IsError || !strings.Contains(result.ForLLM, "text is required") {
t.Errorf("expected text-required error, got: %s", result.ForLLM)
}
})
t.Run("InternalChannelBlocked", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
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: "Internal channel task",
Status: store.TeamTaskStatusInProgress,
OwnerAgentID: &memberID,
Channel: ChannelTeammate,
ChatID: "some-chat",
}
mb.taskStore.mu.Unlock()
// Context channel is also internal
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
memberCtx = WithToolChannel(memberCtx, ChannelTeammate)
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
result := tool.Execute(memberCtx, map[string]any{
"action": "ask_user",
"task_id": taskID.String(),
"text": "What should I do next?",
})
if !result.IsError || !strings.Contains(result.ForLLM, "no valid channel") {
t.Errorf("expected no-valid-channel error, got: %s (isError=%v)", result.ForLLM, result.IsError)
}
})
}
func TestClearAskUser(t *testing.T) {
// helper: set a followup on a task so there's something to clear
setFollowup := func(mb *mockBackend, taskID uuid.UUID) {
mb.taskStore.mu.Lock()
task := mb.taskStore.tasks[taskID]
now := time.Now().Add(30 * time.Minute)
task.FollowupAt = &now
task.FollowupMessage = "ping"
task.FollowupChannel = "telegram"
task.FollowupMax = 3
mb.taskStore.mu.Unlock()
}
t.Run("OwnerSuccess", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusInProgress)
setFollowup(mb, taskID)
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
result := tool.Execute(memberCtx, map[string]any{
"action": "clear_ask_user",
"task_id": taskID.String(),
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
mb.taskStore.mu.Lock()
task := mb.taskStore.tasks[taskID]
mb.taskStore.mu.Unlock()
if task.FollowupAt != nil || task.FollowupMessage != "" {
t.Error("expected followup to be cleared")
}
})
t.Run("LeadSuccess", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusInProgress)
setFollowup(mb, taskID)
leadCtx := store.WithAgentID(ctx, leadID)
leadCtx = WithTaskActionFlags(leadCtx, &TaskActionFlags{})
result := tool.Execute(leadCtx, map[string]any{
"action": "clear_ask_user",
"task_id": taskID.String(),
})
if result.IsError {
t.Fatalf("lead should be able to clear any task followup, got error: %s", result.ForLLM)
}
})
t.Run("NonOwnerNonLeadBlocked", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusInProgress)
setFollowup(mb, taskID)
// member2 tries to clear member's task
member2Ctx := store.WithAgentID(ctx, testMember2ID)
member2Ctx = WithTaskActionFlags(member2Ctx, &TaskActionFlags{})
result := tool.Execute(member2Ctx, map[string]any{
"action": "clear_ask_user",
"task_id": taskID.String(),
})
if !result.IsError {
t.Errorf("expected error for non-owner non-lead, got success: %s", result.ForLLM)
}
})
}
func TestRetry(t *testing.T) {
t.Run("LeadSuccess", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusFailed)
leadCtx := store.WithAgentID(ctx, leadID)
leadCtx = WithTaskActionFlags(leadCtx, &TaskActionFlags{})
result := tool.Execute(leadCtx, map[string]any{
"action": "retry",
"task_id": taskID.String(),
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
// Task should be in_progress after reset + assign
mb.taskStore.mu.Lock()
task := mb.taskStore.tasks[taskID]
mb.taskStore.mu.Unlock()
if task.Status != store.TeamTaskStatusInProgress {
t.Errorf("expected task status=in_progress after retry, got %s", task.Status)
}
// Dispatch should have been called
mb.mu.Lock()
dispatched := len(mb.dispatches) > 0
mb.mu.Unlock()
if !dispatched {
t.Error("expected DispatchTaskToAgent to be called on retry")
}
})
t.Run("MemberBlocked", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusFailed)
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
result := tool.Execute(memberCtx, map[string]any{
"action": "retry",
"task_id": taskID.String(),
})
if !result.IsError || !strings.Contains(result.ForLLM, "only the team lead") {
t.Errorf("expected lead-only error, got: %s", result.ForLLM)
}
})
t.Run("InvalidStatus", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusPending)
leadCtx := store.WithAgentID(ctx, leadID)
leadCtx = WithTaskActionFlags(leadCtx, &TaskActionFlags{})
result := tool.Execute(leadCtx, map[string]any{
"action": "retry",
"task_id": taskID.String(),
})
if !result.IsError || !strings.Contains(result.ForLLM, "retry only works on") {
t.Errorf("expected invalid-status error, got: %s", result.ForLLM)
}
})
t.Run("NoAssignee", func(t *testing.T) {
mb, tool, leadID, _, ctx := newTestTeamSetup()
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: "Unassigned failed task",
Status: store.TeamTaskStatusFailed,
OwnerAgentID: nil,
}
mb.taskStore.mu.Unlock()
leadCtx := store.WithAgentID(ctx, leadID)
leadCtx = WithTaskActionFlags(leadCtx, &TaskActionFlags{})
result := tool.Execute(leadCtx, map[string]any{
"action": "retry",
"task_id": taskID.String(),
})
if !result.IsError || !strings.Contains(result.ForLLM, "task has no assignee") {
t.Errorf("expected no-assignee error, got: %s", result.ForLLM)
}
})
t.Run("SelfDispatchBlocked", func(t *testing.T) {
mb, tool, leadID, _, ctx := newTestTeamSetup()
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: "Lead-assigned failed task",
Status: store.TeamTaskStatusFailed,
OwnerAgentID: &leadID,
}
mb.taskStore.mu.Unlock()
leadCtx := store.WithAgentID(ctx, leadID)
leadCtx = WithTaskActionFlags(leadCtx, &TaskActionFlags{})
result := tool.Execute(leadCtx, map[string]any{
"action": "retry",
"task_id": taskID.String(),
})
if !result.IsError || !strings.Contains(result.ForLLM, "cannot retry task assigned to the team lead") {
t.Errorf("expected self-dispatch error, got: %s", result.ForLLM)
}
})
t.Run("CrossTeamBlocked", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
otherTeamID := uuid.New()
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: otherTeamID,
Subject: "Foreign failed task",
Status: store.TeamTaskStatusFailed,
OwnerAgentID: &memberID,
}
mb.taskStore.mu.Unlock()
leadCtx := store.WithAgentID(ctx, leadID)
leadCtx = WithTaskActionFlags(leadCtx, &TaskActionFlags{})
result := tool.Execute(leadCtx, map[string]any{
"action": "retry",
"task_id": taskID.String(),
})
if !result.IsError || !strings.Contains(result.ForLLM, "does not belong") {
t.Errorf("expected cross-team error, got: %s", result.ForLLM)
}
})
}
+50 -50
View File
@@ -11,7 +11,7 @@ import (
)
func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -21,19 +21,19 @@ func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) *
return ErrorResult(err.Error())
}
if err := t.manager.teamStore.ClaimTask(ctx, taskID, agentID, team.ID); err != nil {
if err := t.manager.Store().ClaimTask(ctx, taskID, agentID, team.ID); err != nil {
return ErrorResult("failed to claim task: " + err.Error())
}
// Record action flag after successful store operation.
recordTaskAction(ctx, func(f *TaskActionFlags) { f.Claimed = true })
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskClaimed, protocol.TeamTaskEventPayload{
ownerKey := t.manager.AgentKeyFromID(ctx, agentID)
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskClaimed, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: ownerKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey),
OwnerDisplayName: t.manager.AgentDisplayName(ctx, ownerKey),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
@@ -50,7 +50,7 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
// All approval flows through leader. When reviewer role is enabled,
// restrict teammate agents from completing tasks directly.
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -68,15 +68,15 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
// Auto-claim if the task is still pending (saves an extra tool call).
// ClaimTask is atomic — only one agent can succeed, others get an error.
// Ignore claim error: task may already be in_progress (claimed by us or someone else).
_ = t.manager.teamStore.ClaimTask(ctx, taskID, agentID, team.ID)
_ = t.manager.Store().ClaimTask(ctx, taskID, agentID, team.ID)
if err := t.manager.teamStore.CompleteTask(ctx, taskID, team.ID, result); err != nil {
if err := t.manager.Store().CompleteTask(ctx, taskID, team.ID, result); err != nil {
// Fast path: check in-memory turn flags before hitting DB again.
if flags := TaskActionFlagsFromCtx(ctx); flags != nil && flags.Completed {
return NewResult(fmt.Sprintf("Task %s already completed.", taskID))
}
// Slow path: re-query to determine actual status.
if current, getErr := t.manager.teamStore.GetTask(ctx, taskID); getErr == nil && current != nil {
if current, getErr := t.manager.Store().GetTask(ctx, taskID); getErr == nil && current != nil {
switch current.Status {
case store.TeamTaskStatusCompleted:
recordTaskAction(ctx, func(f *TaskActionFlags) { f.Completed = true })
@@ -85,8 +85,8 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
return NewResult(fmt.Sprintf("Task %s is %s — cannot complete.", taskID, current.Status))
case store.TeamTaskStatusPending:
// Task was reset by stale recovery — re-assign and retry once.
if t.manager.teamStore.AssignTask(ctx, taskID, agentID, team.ID) == nil {
if t.manager.teamStore.CompleteTask(ctx, taskID, team.ID, result) == nil {
if t.manager.Store().AssignTask(ctx, taskID, agentID, team.ID) == nil {
if t.manager.Store().CompleteTask(ctx, taskID, team.ID, result) == nil {
slog.Info("executeComplete: re-assigned stale-recovered task", "task_id", taskID)
err = nil
}
@@ -100,23 +100,23 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
// Record action flag after successful store operation.
recordTaskAction(ctx, func(f *TaskActionFlags) { f.Completed = true })
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
ownerKey := t.manager.AgentKeyFromID(ctx, agentID)
// Fetch task for TaskNumber/Subject needed by notification subscriber.
completedTask, _ := t.manager.teamStore.GetTask(ctx, taskID)
completedTask, _ := t.manager.Store().GetTask(ctx, taskID)
var taskNumber int
var taskSubject string
if completedTask != nil {
taskNumber = completedTask.TaskNumber
taskSubject = completedTask.Subject
}
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: taskNumber,
Subject: taskSubject,
Status: store.TeamTaskStatusCompleted,
OwnerAgentKey: ownerKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey),
OwnerDisplayName: t.manager.AgentDisplayName(ctx, ownerKey),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
@@ -135,11 +135,11 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) *Result {
// Note: reviewer role not yet active. Cancellation goes through leader only.
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
@@ -154,11 +154,11 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any)
}
// CancelTask: guards against completed tasks, unblocks dependents, transitions blocked→pending.
if err := t.manager.teamStore.CancelTask(ctx, taskID, team.ID, reason); err != nil {
if err := t.manager.Store().CancelTask(ctx, taskID, team.ID, reason); err != nil {
return ErrorResult("failed to cancel task: " + err.Error())
}
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusCancelled,
@@ -168,7 +168,7 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any)
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
})
// Dependent tasks are dispatched by the consumer after this agent's turn ends (post-turn).
@@ -177,7 +177,7 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any)
}
func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -188,7 +188,7 @@ func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any)
}
// Verify the agent owns this task.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
@@ -199,19 +199,19 @@ func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any)
return ErrorResult("only the task owner can submit for review")
}
if err := t.manager.teamStore.ReviewTask(ctx, taskID, team.ID); err != nil {
if err := t.manager.Store().ReviewTask(ctx, taskID, team.ID); err != nil {
return ErrorResult("failed to submit for review: " + err.Error())
}
// Record action flag after successful store operation.
recordTaskAction(ctx, func(f *TaskActionFlags) { f.Reviewed = true })
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskReviewed, protocol.TeamTaskEventPayload{
ownerKey := t.manager.AgentKeyFromID(ctx, agentID)
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskReviewed, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusInReview,
OwnerAgentKey: ownerKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey),
OwnerDisplayName: t.manager.AgentDisplayName(ctx, ownerKey),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
@@ -226,7 +226,7 @@ func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any)
func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any) *Result {
// Note: reviewer role not yet active. All approvals flow through leader or dashboard.
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -235,7 +235,7 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
// System/dashboard channels bypass this check (human UI approval).
ch := ToolChannelFromCtx(ctx)
if ch != ChannelSystem && ch != ChannelDashboard {
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
}
@@ -246,7 +246,7 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
}
// Fetch task for subject (used in lead message) and team ownership check
task, err := t.manager.teamStore.GetTask(ctx, taskID)
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
@@ -255,18 +255,18 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
}
// Atomic transition: in_review -> completed
if err := t.manager.teamStore.ApproveTask(ctx, taskID, team.ID, ""); err != nil {
if err := t.manager.Store().ApproveTask(ctx, taskID, team.ID, ""); err != nil {
return ErrorResult("failed to approve task: " + err.Error())
}
// Re-fetch to get the actual post-approval status (pending or blocked)
approved, _ := t.manager.teamStore.GetTask(ctx, taskID)
approved, _ := t.manager.Store().GetTask(ctx, taskID)
newStatus := store.TeamTaskStatusPending
if approved != nil {
newStatus = approved.Status
}
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
@@ -276,12 +276,12 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
})
// Record approval as a task comment for audit trail.
approveMsg := fmt.Sprintf("Task approved (status: %s).", newStatus)
_ = t.manager.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
_ = t.manager.Store().AddTaskComment(ctx, &store.TeamTaskCommentData{
TaskID: taskID,
AgentID: &agentID,
Content: approveMsg,
@@ -293,7 +293,7 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any) *Result {
// Note: reviewer role not yet active. Rejections flow through leader or dashboard.
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -301,7 +301,7 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
// Only lead can reject tasks via tool.
ch := ToolChannelFromCtx(ctx)
if ch != ChannelSystem && ch != ChannelDashboard {
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
}
@@ -317,7 +317,7 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
}
// Fetch task to get subject for the lead message
task, err := t.manager.teamStore.GetTask(ctx, taskID)
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
@@ -327,7 +327,7 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
// Record rejection as a task comment for audit trail (before status change).
rejectMsg := fmt.Sprintf("Task rejected. Reason: %s", reason)
_ = t.manager.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
_ = t.manager.Store().AddTaskComment(ctx, &store.TeamTaskCommentData{
TaskID: taskID,
AgentID: &agentID,
Content: rejectMsg,
@@ -338,13 +338,13 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
// Dependents stay blocked until this task actually completes or circuit breaker fails it.
if task.OwnerAgentID != nil {
// Reset in_review → pending (ResetTaskStatus accepts in_review via store guard).
if err := t.manager.teamStore.ResetTaskStatus(ctx, taskID, team.ID); err != nil {
if err := t.manager.Store().ResetTaskStatus(ctx, taskID, team.ID); err != nil {
// Fallback: use RejectTask (cancels + unblocks) if reset fails.
slog.Warn("reject: reset failed, falling back to RejectTask", "task_id", taskID, "error", err)
if err := t.manager.teamStore.RejectTask(ctx, taskID, team.ID, reason); err != nil {
if err := t.manager.Store().RejectTask(ctx, taskID, team.ID, reason); err != nil {
return ErrorResult("failed to reject task: " + err.Error())
}
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
@@ -355,15 +355,15 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
})
return NewResult(fmt.Sprintf("Task %s rejected (cancelled). Use retry to re-dispatch manually.", taskID))
}
if err := t.manager.teamStore.AssignTask(ctx, taskID, *task.OwnerAgentID, team.ID); err != nil {
if err := t.manager.Store().AssignTask(ctx, taskID, *task.OwnerAgentID, team.ID); err != nil {
slog.Warn("reject: assign task failed", "task_id", taskID, "error", err)
return NewResult(fmt.Sprintf("Task %s rejected but could not assign. Use retry to re-dispatch manually.", taskID))
}
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
@@ -374,18 +374,18 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
})
t.manager.dispatchTaskToAgent(ctx, task, team, *task.OwnerAgentID)
t.manager.DispatchTaskToAgent(ctx, task, team, *task.OwnerAgentID)
return NewResult(fmt.Sprintf("Task %s rejected and re-dispatched to %s with feedback.",
taskID, t.manager.agentKeyFromID(ctx, *task.OwnerAgentID)))
taskID, t.manager.AgentKeyFromID(ctx, *task.OwnerAgentID)))
}
// No owner — use RejectTask to cancel + unblock dependents.
if err := t.manager.teamStore.RejectTask(ctx, taskID, team.ID, reason); err != nil {
if err := t.manager.Store().RejectTask(ctx, taskID, team.ID, reason); err != nil {
return ErrorResult("failed to reject task: " + err.Error())
}
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
@@ -396,7 +396,7 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
})
return NewResult(fmt.Sprintf("Task %s rejected.", taskID))
}
+663
View File
@@ -0,0 +1,663 @@
package tools
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// preloadTask stores a fully-specified task directly into the mock backend.
func preloadTask(mb *mockBackend, task *store.TeamTaskData) {
mb.taskStore.mu.Lock()
if task.ID == uuid.Nil {
task.ID = uuid.New()
}
if task.CreatedAt.IsZero() {
task.CreatedAt = time.Now()
task.UpdatedAt = time.Now()
}
mb.taskStore.tasks[task.ID] = task
mb.taskStore.mu.Unlock()
}
func readTask(mb *mockBackend, id uuid.UUID) *store.TeamTaskData {
mb.taskStore.mu.Lock()
defer mb.taskStore.mu.Unlock()
return mb.taskStore.tasks[id]
}
func TestClaim(t *testing.T) {
t.Run("Success", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "pending task",
Status: store.TeamTaskStatusPending,
})
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "claim",
"task_id": taskID.String(),
})
if result.IsError {
t.Fatalf("expected success, got error: %v", result.ForLLM)
}
task := readTask(mb, taskID)
if task.Status != store.TeamTaskStatusInProgress {
t.Errorf("expected in_progress, got %q", task.Status)
}
mb.mu.Lock()
evCount := len(mb.events)
mb.mu.Unlock()
if evCount == 0 {
t.Fatal("expected broadcast event")
}
if mb.events[0].Name != protocol.EventTeamTaskClaimed {
t.Errorf("expected %q, got %q", protocol.EventTeamTaskClaimed, mb.events[0].Name)
}
})
t.Run("AlreadyInProgress", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "in-progress task",
Status: store.TeamTaskStatusInProgress,
})
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "claim",
"task_id": taskID.String(),
})
if !result.IsError {
t.Error("expected error for already in_progress task")
}
})
t.Run("TeamResolutionFail", func(t *testing.T) {
// Backend with no team — ResolveTeam returns error.
noTeamMB := &mockBackend{}
noTeamTool := NewTeamTasksTool(noTeamMB, FullTeamPolicy{})
emptyCtx := WithTaskActionFlags(
WithToolChannel(
store.WithAgentID(store.WithTenantID(t.Context(), uuid.Nil), uuid.Nil),
ChannelDashboard,
),
&TaskActionFlags{},
)
result := noTeamTool.Execute(emptyCtx, map[string]any{
"action": "claim",
"task_id": uuid.New().String(),
})
if !result.IsError {
t.Error("expected error when team resolution fails")
}
})
}
func TestComplete(t *testing.T) {
t.Run("Success", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "work task",
Status: store.TeamTaskStatusInProgress,
OwnerAgentID: &memberID,
})
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "complete",
"task_id": taskID.String(),
"result": "done",
})
if result.IsError {
t.Fatalf("expected success, got error: %v", result.ForLLM)
}
task := readTask(mb, taskID)
if task.Status != store.TeamTaskStatusCompleted {
t.Errorf("expected completed, got %q", task.Status)
}
mb.mu.Lock()
evCount := len(mb.events)
mb.mu.Unlock()
if evCount == 0 {
t.Fatal("expected broadcast event")
}
})
t.Run("MissingResult", func(t *testing.T) {
_, tool, _, memberID, ctx := newTestTeamSetup()
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "complete",
"task_id": uuid.New().String(),
})
if !result.IsError {
t.Error("expected error for missing result")
}
})
t.Run("AutoClaim", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "unclaimed task",
Status: store.TeamTaskStatusPending,
})
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "complete",
"task_id": taskID.String(),
"result": "auto-completed",
})
if result.IsError {
t.Fatalf("expected success with auto-claim, got error: %v", result.ForLLM)
}
task := readTask(mb, taskID)
if task.Status != store.TeamTaskStatusCompleted {
t.Errorf("expected completed, got %q", task.Status)
}
})
t.Run("AlreadyCompleted", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "already done",
Status: store.TeamTaskStatusCompleted,
OwnerAgentID: &memberID,
})
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "complete",
"task_id": taskID.String(),
"result": "done again",
})
if result.IsError {
t.Errorf("expected idempotent success, got error: %v", result.ForLLM)
}
})
t.Run("TerminalStatus", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "failed task",
Status: store.TeamTaskStatusFailed,
OwnerAgentID: &memberID,
})
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "complete",
"task_id": taskID.String(),
"result": "trying to complete failed task",
})
// Should return info message (not error) about terminal status.
if result.IsError {
t.Errorf("expected info message for terminal status, got hard error: %v", result.ForLLM)
}
})
}
func TestCancel(t *testing.T) {
t.Run("LeadSuccess", func(t *testing.T) {
mb, tool, leadID, _, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "to cancel",
Status: store.TeamTaskStatusPending,
})
lCtx := store.WithAgentID(ctx, leadID)
lCtx = WithTaskActionFlags(lCtx, &TaskActionFlags{})
result := tool.Execute(lCtx, map[string]any{
"action": "cancel",
"task_id": taskID.String(),
"text": "no longer needed",
})
if result.IsError {
t.Fatalf("expected success, got error: %v", result.ForLLM)
}
task := readTask(mb, taskID)
if task.Status != store.TeamTaskStatusCancelled {
t.Errorf("expected cancelled, got %q", task.Status)
}
mb.mu.Lock()
evCount := len(mb.events)
mb.mu.Unlock()
if evCount == 0 {
t.Fatal("expected broadcast event")
}
})
t.Run("MemberBlocked", func(t *testing.T) {
_, tool, _, memberID, ctx := newTestTeamSetup()
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithToolChannel(mCtx, ChannelDashboard) // non-bypass channel
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "cancel",
"task_id": uuid.New().String(),
})
if !result.IsError {
t.Error("expected error: member cannot cancel via dashboard channel")
}
})
t.Run("ChannelBypass", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "bypass cancel",
Status: store.TeamTaskStatusPending,
})
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithToolChannel(mCtx, ChannelTeammate)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "cancel",
"task_id": taskID.String(),
})
if result.IsError {
t.Fatalf("expected success via teammate channel bypass, got error: %v", result.ForLLM)
}
})
t.Run("DefaultReason", func(t *testing.T) {
mb, tool, leadID, _, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "default reason test",
Status: store.TeamTaskStatusPending,
})
lCtx := store.WithAgentID(ctx, leadID)
lCtx = WithTaskActionFlags(lCtx, &TaskActionFlags{})
result := tool.Execute(lCtx, map[string]any{
"action": "cancel",
"task_id": taskID.String(),
// no "text" field — default reason applied
})
if result.IsError {
t.Fatalf("expected success, got error: %v", result.ForLLM)
}
task := readTask(mb, taskID)
if task.Result == nil || *task.Result != "Cancelled by agent" {
t.Errorf("expected default reason %q, got %v", "Cancelled by agent", task.Result)
}
})
}
func TestReview(t *testing.T) {
t.Run("OwnerSuccess", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "review task",
Status: store.TeamTaskStatusInProgress,
OwnerAgentID: &memberID,
})
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "review",
"task_id": taskID.String(),
})
if result.IsError {
t.Fatalf("expected success, got error: %v", result.ForLLM)
}
task := readTask(mb, taskID)
if task.Status != store.TeamTaskStatusInReview {
t.Errorf("expected in_review, got %q", task.Status)
}
mb.mu.Lock()
evCount := len(mb.events)
mb.mu.Unlock()
if evCount == 0 {
t.Fatal("expected broadcast event")
}
})
t.Run("NonOwnerBlocked", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "owned by member",
Status: store.TeamTaskStatusInProgress,
OwnerAgentID: &memberID,
})
// member2 tries to submit for review
m2Ctx := store.WithAgentID(ctx, testMember2ID)
m2Ctx = WithTaskActionFlags(m2Ctx, &TaskActionFlags{})
result := tool.Execute(m2Ctx, map[string]any{
"action": "review",
"task_id": taskID.String(),
})
if !result.IsError {
t.Error("expected error: only owner can submit for review")
}
})
t.Run("CrossTeamBlocked", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
otherTeamID := uuid.New()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: otherTeamID,
Subject: "cross-team task",
Status: store.TeamTaskStatusInProgress,
OwnerAgentID: &memberID,
})
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "review",
"task_id": taskID.String(),
})
if !result.IsError {
t.Error("expected error: task from different team")
}
})
}
func TestApprove(t *testing.T) {
t.Run("LeadSuccess", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "review me",
Status: store.TeamTaskStatusInReview,
OwnerAgentID: &memberID,
})
lCtx := store.WithAgentID(ctx, leadID)
lCtx = WithTaskActionFlags(lCtx, &TaskActionFlags{})
result := tool.Execute(lCtx, map[string]any{
"action": "approve",
"task_id": taskID.String(),
})
if result.IsError {
t.Fatalf("expected success, got error: %v", result.ForLLM)
}
task := readTask(mb, taskID)
if task.Status != store.TeamTaskStatusCompleted {
t.Errorf("expected completed, got %q", task.Status)
}
mb.mu.Lock()
evCount := len(mb.events)
mb.mu.Unlock()
if evCount == 0 {
t.Fatal("expected broadcast event")
}
if mb.events[0].Name != protocol.EventTeamTaskApproved {
t.Errorf("expected %q, got %q", protocol.EventTeamTaskApproved, mb.events[0].Name)
}
})
t.Run("MemberBlocked", func(t *testing.T) {
_, tool, _, memberID, ctx := newTestTeamSetup()
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithToolChannel(mCtx, ChannelTeammate) // not dashboard/system
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "approve",
"task_id": uuid.New().String(),
})
if !result.IsError {
t.Error("expected error: member cannot approve via teammate channel")
}
})
t.Run("DashboardBypass", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "dashboard approve",
Status: store.TeamTaskStatusInReview,
OwnerAgentID: &memberID,
})
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithToolChannel(mCtx, ChannelDashboard)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "approve",
"task_id": taskID.String(),
})
if result.IsError {
t.Fatalf("expected success via dashboard bypass, got error: %v", result.ForLLM)
}
})
t.Run("CrossTeamBlocked", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
otherTeamID := uuid.New()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: otherTeamID,
Subject: "other team task",
Status: store.TeamTaskStatusInReview,
OwnerAgentID: &memberID,
})
lCtx := store.WithAgentID(ctx, leadID)
lCtx = WithTaskActionFlags(lCtx, &TaskActionFlags{})
result := tool.Execute(lCtx, map[string]any{
"action": "approve",
"task_id": taskID.String(),
})
if !result.IsError {
t.Error("expected error: task from different team")
}
})
t.Run("AuditComment", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "audit trail test",
Status: store.TeamTaskStatusInReview,
OwnerAgentID: &memberID,
})
lCtx := store.WithAgentID(ctx, leadID)
lCtx = WithTaskActionFlags(lCtx, &TaskActionFlags{})
tool.Execute(lCtx, map[string]any{
"action": "approve",
"task_id": taskID.String(),
})
mb.taskStore.mu.Lock()
comments := mb.taskStore.comments[taskID]
mb.taskStore.mu.Unlock()
if len(comments) == 0 {
t.Error("expected audit comment added on approval")
}
})
}
func TestReject(t *testing.T) {
t.Run("WithOwner_ReDispatch", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "re-dispatch on reject",
Status: store.TeamTaskStatusInReview,
OwnerAgentID: &memberID,
})
lCtx := store.WithAgentID(ctx, leadID)
lCtx = WithTaskActionFlags(lCtx, &TaskActionFlags{})
result := tool.Execute(lCtx, map[string]any{
"action": "reject",
"task_id": taskID.String(),
"text": "needs rework",
})
if result.IsError {
t.Fatalf("expected success, got error: %v", result.ForLLM)
}
// Task must NOT be cancelled — it is re-dispatched to owner.
task := readTask(mb, taskID)
if task.Status == store.TeamTaskStatusCancelled {
t.Error("task should not be cancelled when owner exists")
}
mb.mu.Lock()
dispCount := len(mb.dispatches)
mb.mu.Unlock()
if dispCount == 0 {
t.Fatal("expected DispatchTaskToAgent call after reject with owner")
}
if mb.dispatches[0].AgentID != memberID {
t.Errorf("expected dispatch to memberID %v, got %v", memberID, mb.dispatches[0].AgentID)
}
})
t.Run("WithoutOwner_Cancel", func(t *testing.T) {
mb, tool, leadID, _, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "no owner reject",
Status: store.TeamTaskStatusInReview,
// no OwnerAgentID
})
lCtx := store.WithAgentID(ctx, leadID)
lCtx = WithTaskActionFlags(lCtx, &TaskActionFlags{})
result := tool.Execute(lCtx, map[string]any{
"action": "reject",
"task_id": taskID.String(),
"text": "rejected no owner",
})
if result.IsError {
t.Fatalf("expected success, got error: %v", result.ForLLM)
}
task := readTask(mb, taskID)
if task.Status != store.TeamTaskStatusCancelled {
t.Errorf("expected cancelled, got %q", task.Status)
}
})
t.Run("DashboardBypass", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "dashboard reject",
Status: store.TeamTaskStatusInReview,
})
mCtx := store.WithAgentID(ctx, memberID)
mCtx = WithToolChannel(mCtx, ChannelDashboard)
mCtx = WithTaskActionFlags(mCtx, &TaskActionFlags{})
result := tool.Execute(mCtx, map[string]any{
"action": "reject",
"task_id": taskID.String(),
"text": "dashboard user rejects",
})
if result.IsError {
t.Fatalf("expected success via dashboard bypass, got error: %v", result.ForLLM)
}
})
t.Run("AuditComment", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
taskID := uuid.New()
preloadTask(mb, &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: testTeamID,
Subject: "reject audit",
Status: store.TeamTaskStatusInReview,
OwnerAgentID: &memberID,
})
lCtx := store.WithAgentID(ctx, leadID)
lCtx = WithTaskActionFlags(lCtx, &TaskActionFlags{})
tool.Execute(lCtx, map[string]any{
"action": "reject",
"task_id": taskID.String(),
"text": "audit this rejection",
})
mb.taskStore.mu.Lock()
comments := mb.taskStore.comments[taskID]
mb.taskStore.mu.Unlock()
if len(comments) == 0 {
t.Error("expected audit comment added before status change on reject")
}
})
}
+41 -41
View File
@@ -17,7 +17,7 @@ import (
)
func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -43,7 +43,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
if taskType != "request" {
return ErrorResult("Members can only create task_type=\"request\". Use team_tasks(action=\"comment\") to communicate.")
}
} else if err := t.manager.requireLead(ctx, team, agentID); err != nil {
} else if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
@@ -78,7 +78,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
// Validate that all blocked_by tasks belong to the same team and are not terminal.
for _, depID := range blockedBy {
depTask, err := t.manager.teamStore.GetTask(ctx, depID)
depTask, err := t.manager.Store().GetTask(ctx, depID)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by task %s not found: %v", depID, err))
}
@@ -100,12 +100,12 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
if assigneeKey == "" {
return ErrorResult("assignee is required — specify which team member should handle this task")
}
assigneeID, err := t.manager.resolveAgentByKey(ctx, assigneeKey)
assigneeID, err := t.manager.ResolveAgentByKey(ctx, assigneeKey)
if err != nil {
return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err))
}
// Verify assignee is a member of this team.
members, err := t.manager.cachedListMembers(ctx, team.ID, agentID)
members, err := t.manager.CachedListMembers(ctx, team.ID, agentID)
if err != nil {
return ErrorResult("failed to verify team membership: " + err.Error())
}
@@ -145,7 +145,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
// Compute team workspace via layered pipeline: tenant → team → user/chat.
shared := IsSharedWorkspace(team.Settings)
taskMeta := make(map[string]any)
teamWsDir := ResolveWorkspace(t.manager.dataDir,
teamWsDir := ResolveWorkspace(t.manager.DataDir(),
TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
TeamLayer(team.ID),
UserChatLayer(chatID, shared),
@@ -231,7 +231,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
}
}
if err := t.manager.teamStore.CreateTask(ctx, task); err != nil {
if err := t.manager.Store().CreateTask(ctx, task); err != nil {
return ErrorResult("failed to create task: " + err.Error())
}
@@ -258,14 +258,14 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
MimeType: mimeFromExt(filepath.Ext(filePath)),
CreatedByAgentID: &agentID,
}
if err := t.manager.teamStore.AttachFileToTask(ctx, att); err != nil {
if err := t.manager.Store().AttachFileToTask(ctx, att); err != nil {
slog.Warn("executeCreate: auto-attach media failed", "task_id", task.ID, "path", filePath, "error", err)
}
}
}
agentKey := t.manager.agentKeyFromID(ctx, agentID)
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
agentKey := t.manager.AgentKeyFromID(ctx, agentID)
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
Subject: subject,
@@ -284,30 +284,30 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
ptd.Add(team.ID, task.ID)
} else {
// Fallback: assign (pending → in_progress + lock) then dispatch.
if err := t.manager.teamStore.AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil {
if err := t.manager.Store().AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil {
slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err)
} else {
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: t.manager.agentKeyFromID(ctx, assigneeID),
OwnerAgentKey: t.manager.AgentKeyFromID(ctx, assigneeID),
Channel: task.Channel,
ChatID: task.ChatID,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "fallback_dispatch",
})
t.manager.dispatchTaskToAgent(ctx, task, team, assigneeID)
t.manager.DispatchTaskToAgent(ctx, task, team, assigneeID)
}
}
}
assigneeName := t.manager.agentDisplayName(ctx, t.manager.agentKeyFromID(ctx, assigneeID))
assigneeName := t.manager.AgentDisplayName(ctx, t.manager.AgentKeyFromID(ctx, assigneeID))
if assigneeName == "" {
assigneeName = t.manager.agentKeyFromID(ctx, assigneeID)
assigneeName = t.manager.AgentKeyFromID(ctx, assigneeID)
}
msg := fmt.Sprintf("Task created: %s (id=%s, task_number=%d, status=%s, assignee=%s)", subject, task.ID, task.TaskNumber, status, assigneeName)
@@ -327,7 +327,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
}
func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -346,7 +346,7 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
}
// Verify task belongs to team.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
@@ -359,7 +359,7 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
commentType = "note"
}
if err := t.manager.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
if err := t.manager.Store().AddTaskComment(ctx, &store.TeamTaskCommentData{
TaskID: taskID,
AgentID: &agentID,
Content: text,
@@ -368,7 +368,7 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
return ErrorResult("failed to add comment: " + err.Error())
}
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
@@ -379,7 +379,7 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
})
// Record action flag after successful store operation.
@@ -412,7 +412,7 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -432,7 +432,7 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any
step, _ := args["text"].(string)
// Verify ownership.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
@@ -455,21 +455,21 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any
percent = task.ProgressPercent
}
if err := t.manager.teamStore.UpdateTaskProgress(ctx, taskID, team.ID, percent, step); err != nil {
if err := t.manager.Store().UpdateTaskProgress(ctx, taskID, team.ID, percent, step); err != nil {
// Status may have changed between GetTask and UpdateTaskProgress.
// Fast path: check in-memory turn flags before hitting DB again.
if flags := TaskActionFlagsFromCtx(ctx); flags != nil && flags.Completed {
return SilentResult("Task already completed — progress update skipped.")
}
// Slow path: re-query for stale recovery (pending) or concurrent status change.
if current, getErr := t.manager.teamStore.GetTask(ctx, taskID); getErr == nil && current != nil {
if current, getErr := t.manager.Store().GetTask(ctx, taskID); getErr == nil && current != nil {
switch current.Status {
case store.TeamTaskStatusCompleted, store.TeamTaskStatusFailed, store.TeamTaskStatusCancelled:
return SilentResult(fmt.Sprintf("Task already %s — progress update skipped.", current.Status))
case store.TeamTaskStatusPending:
// Task was reset by stale recovery — re-assign and retry once.
if t.manager.teamStore.AssignTask(ctx, taskID, agentID, team.ID) == nil {
if t.manager.teamStore.UpdateTaskProgress(ctx, taskID, team.ID, percent, step) == nil {
if t.manager.Store().AssignTask(ctx, taskID, agentID, team.ID) == nil {
if t.manager.Store().UpdateTaskProgress(ctx, taskID, team.ID, percent, step) == nil {
slog.Info("executeProgress: re-assigned stale-recovered task", "task_id", taskID)
recordTaskAction(ctx, func(f *TaskActionFlags) { f.Progressed = true })
return SilentResult(fmt.Sprintf("Progress updated: %d%% %s", percent, step))
@@ -484,9 +484,9 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any
ownerKey := ""
if task.OwnerAgentID != nil {
ownerKey = t.manager.agentKeyFromID(ctx, *task.OwnerAgentID)
ownerKey = t.manager.AgentKeyFromID(ctx, *task.OwnerAgentID)
}
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
@@ -505,7 +505,7 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any
}
func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -529,7 +529,7 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any)
filePath = filepath.Clean(filePath)
// Verify task belongs to team.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
@@ -538,7 +538,7 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any)
}
chatID := ToolChatIDFromCtx(ctx)
if err := t.manager.teamStore.AttachFileToTask(ctx, &store.TeamTaskAttachmentData{
if err := t.manager.Store().AttachFileToTask(ctx, &store.TeamTaskAttachmentData{
TaskID: taskID,
TeamID: team.ID,
ChatID: chatID,
@@ -548,7 +548,7 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any)
return ErrorResult("failed to attach file: " + err.Error())
}
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskAttachmentAdded, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskAttachmentAdded, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
@@ -558,18 +558,18 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any)
ChatID: chatID,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
})
return NewResult(fmt.Sprintf("File attached to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID))
}
func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
@@ -579,7 +579,7 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any)
}
// Verify task belongs to this team (prevent cross-team update).
task, err := t.manager.teamStore.GetTask(ctx, taskID)
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
@@ -607,7 +607,7 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any)
}
// Batch-validate all blocker tasks in one query.
if len(blockedBy) > 0 {
depTasks, err := t.manager.teamStore.GetTasksByIDs(ctx, blockedBy)
depTasks, err := t.manager.Store().GetTasksByIDs(ctx, blockedBy)
if err != nil {
return ErrorResult("failed to validate blocked_by: " + err.Error())
}
@@ -638,11 +638,11 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any)
return ErrorResult("no updates provided (set description, subject, or blocked_by)")
}
if err := t.manager.teamStore.UpdateTask(ctx, taskID, updates); err != nil {
if err := t.manager.Store().UpdateTask(ctx, taskID, updates); err != nil {
return ErrorResult("failed to update task: " + err.Error())
}
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
@@ -652,7 +652,7 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any)
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
})
return NewResult(fmt.Sprintf("Task #%d \"%s\" updated (id: %s).", task.TaskNumber, task.Subject, taskID))
+405
View File
@@ -0,0 +1,405 @@
package tools
import (
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// makeTask inserts a minimal task into the mock store and returns its ID.
func makeTask(mb *mockBackend, teamID, ownerID uuid.UUID, status string) uuid.UUID {
taskID := uuid.New()
ownerCopy := ownerID
now := time.Now()
mb.taskStore.mu.Lock()
mb.taskStore.tasks[taskID] = &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID, CreatedAt: now, UpdatedAt: now},
TeamID: teamID,
Subject: "Test task",
Status: status,
OwnerAgentID: &ownerCopy,
}
mb.taskStore.mu.Unlock()
return taskID
}
func TestComment(t *testing.T) {
t.Run("NoteSuccess", 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": "making progress",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
mb.mu.Lock()
found := false
for _, e := range mb.events {
if e.Name == protocol.EventTeamTaskCommented {
found = true
}
}
mb.mu.Unlock()
if !found {
t.Error("expected EventTeamTaskCommented broadcast")
}
flags := TaskActionFlagsFromCtx(memberCtx)
if flags == nil || !flags.Commented {
t.Error("expected Commented flag set")
}
})
t.Run("CrossTeamBlocked", func(t *testing.T) {
mb, tool, _, _, ctx := newTestTeamSetup()
otherTeamID := uuid.New()
taskID := uuid.New()
ownerID := testMemberID
mb.taskStore.mu.Lock()
mb.taskStore.tasks[taskID] = &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID, CreatedAt: time.Now(), UpdatedAt: time.Now()},
TeamID: otherTeamID,
Subject: "Other team task",
Status: store.TeamTaskStatusInProgress,
OwnerAgentID: &ownerID,
}
mb.taskStore.mu.Unlock()
result := tool.Execute(ctx, map[string]any{
"action": "comment",
"task_id": taskID.String(),
"text": "hello",
})
if !result.IsError || !strings.Contains(result.ForLLM, "task does not belong") {
t.Errorf("expected cross-team error, got: %s (isError=%v)", result.ForLLM, result.IsError)
}
})
t.Run("EmptyText", func(t *testing.T) {
mb, tool, _, _, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, testLeadID, store.TeamTaskStatusInProgress)
result := tool.Execute(ctx, map[string]any{
"action": "comment",
"task_id": taskID.String(),
"text": "",
})
if !result.IsError || !strings.Contains(result.ForLLM, "text is required") {
t.Errorf("expected text-required error, got: %s", result.ForLLM)
}
})
t.Run("MaxLengthExceeded", 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{})
result := tool.Execute(memberCtx, map[string]any{
"action": "comment",
"task_id": taskID.String(),
"text": strings.Repeat("x", 10001),
})
if !result.IsError || !strings.Contains(result.ForLLM, "comment text too long") {
t.Errorf("expected too-long error, got: %s", result.ForLLM)
}
})
}
func TestProgress(t *testing.T) {
t.Run("OwnerSuccess", 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": "progress",
"task_id": taskID.String(),
"percent": float64(60),
"text": "halfway",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
mb.mu.Lock()
found := false
for _, e := range mb.events {
if e.Name == protocol.EventTeamTaskProgress {
found = true
}
}
mb.mu.Unlock()
if !found {
t.Error("expected EventTeamTaskProgress broadcast")
}
})
t.Run("NonOwnerBlocked", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusInProgress)
leadCtx := store.WithAgentID(ctx, leadID)
leadCtx = WithTaskActionFlags(leadCtx, &TaskActionFlags{})
result := tool.Execute(leadCtx, map[string]any{
"action": "progress",
"task_id": taskID.String(),
"percent": float64(50),
})
if !result.IsError || !strings.Contains(result.ForLLM, "only the assigned task owner") {
t.Errorf("expected owner-only error, got: %s", result.ForLLM)
}
})
t.Run("RegressionPrevented", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusInProgress)
mb.taskStore.mu.Lock()
mb.taskStore.tasks[taskID].ProgressPercent = 50
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": "progress",
"task_id": taskID.String(),
"percent": float64(30),
})
if result.IsError {
t.Fatalf("expected success (regression clamped), got: %s", result.ForLLM)
}
mb.taskStore.mu.Lock()
task := mb.taskStore.tasks[taskID]
mb.taskStore.mu.Unlock()
if task.ProgressPercent < 50 {
t.Errorf("progress should not regress below 50, got %d", task.ProgressPercent)
}
})
t.Run("TerminalSkip", 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": "progress",
"task_id": taskID.String(),
"percent": float64(50),
})
if result.IsError {
t.Fatalf("expected silent result, got error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "already completed") {
t.Errorf("expected 'already completed', got: %s", result.ForLLM)
}
})
t.Run("InvalidPercent", 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{})
result := tool.Execute(memberCtx, map[string]any{
"action": "progress",
"task_id": taskID.String(),
"percent": float64(150),
})
if !result.IsError || !strings.Contains(result.ForLLM, "percent must be 0-100") {
t.Errorf("expected percent-range error, got: %s", result.ForLLM)
}
})
}
func TestUpdate(t *testing.T) {
t.Run("LeadSuccess", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusPending)
leadCtx := store.WithAgentID(ctx, leadID)
leadCtx = WithTaskActionFlags(leadCtx, &TaskActionFlags{})
result := tool.Execute(leadCtx, map[string]any{
"action": "update",
"task_id": taskID.String(),
"description": "updated description",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
})
t.Run("MemberBlocked", 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{})
result := tool.Execute(memberCtx, map[string]any{
"action": "update",
"task_id": taskID.String(),
"description": "sneaky update",
})
if !result.IsError || !strings.Contains(result.ForLLM, "only the team lead") {
t.Errorf("expected lead-only error, got: %s", result.ForLLM)
}
})
t.Run("CrossTeamBlocked", func(t *testing.T) {
mb, tool, leadID, _, ctx := newTestTeamSetup()
otherTeamID := uuid.New()
ownerID := testMemberID
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: otherTeamID,
Subject: "foreign task",
Status: store.TeamTaskStatusPending,
OwnerAgentID: &ownerID,
}
mb.taskStore.mu.Unlock()
leadCtx := store.WithAgentID(ctx, leadID)
leadCtx = WithTaskActionFlags(leadCtx, &TaskActionFlags{})
result := tool.Execute(leadCtx, map[string]any{
"action": "update",
"task_id": taskID.String(),
"description": "cross-team",
})
if !result.IsError || !strings.Contains(result.ForLLM, "does not belong") {
t.Errorf("expected cross-team error, got: %s", result.ForLLM)
}
})
t.Run("NoUpdates", func(t *testing.T) {
mb, tool, leadID, memberID, ctx := newTestTeamSetup()
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusPending)
leadCtx := store.WithAgentID(ctx, leadID)
leadCtx = WithTaskActionFlags(leadCtx, &TaskActionFlags{})
result := tool.Execute(leadCtx, map[string]any{
"action": "update",
"task_id": taskID.String(),
})
if !result.IsError || !strings.Contains(result.ForLLM, "no updates provided") {
t.Errorf("expected no-updates error, got: %s", result.ForLLM)
}
})
}
func TestAttach(t *testing.T) {
t.Run("Success", 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": "attach",
"task_id": taskID.String(),
"path": "/tmp/test/output.txt",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
mb.mu.Lock()
found := false
for _, e := range mb.events {
if e.Name == protocol.EventTeamTaskAttachmentAdded {
found = true
}
}
mb.mu.Unlock()
if !found {
t.Error("expected EventTeamTaskAttachmentAdded broadcast")
}
})
t.Run("CrossTeamBlocked", func(t *testing.T) {
mb, tool, _, memberID, ctx := newTestTeamSetup()
otherTeamID := uuid.New()
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: otherTeamID,
Subject: "foreign task",
Status: store.TeamTaskStatusInProgress,
OwnerAgentID: &memberID,
}
mb.taskStore.mu.Unlock()
memberCtx := store.WithAgentID(ctx, memberID)
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
result := tool.Execute(memberCtx, map[string]any{
"action": "attach",
"task_id": taskID.String(),
"path": "/tmp/test/file.txt",
})
if !result.IsError || !strings.Contains(result.ForLLM, "does not belong") {
t.Errorf("expected cross-team error, got: %s", result.ForLLM)
}
})
t.Run("EmptyPath", 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{})
result := tool.Execute(memberCtx, map[string]any{
"action": "attach",
"task_id": taskID.String(),
"path": "",
})
if !result.IsError || !strings.Contains(result.ForLLM, "path is required") {
t.Errorf("expected path-required error, got: %s", result.ForLLM)
}
})
}
+20 -20
View File
@@ -90,7 +90,7 @@ func (t *TeamTasksTool) resolveBlockers(ctx context.Context, blockedBy []uuid.UU
if len(blockedBy) == 0 {
return nil
}
tasks, err := t.manager.teamStore.GetTasksByIDs(ctx, blockedBy)
tasks, err := t.manager.Store().GetTasksByIDs(ctx, blockedBy)
if err != nil {
return nil
}
@@ -113,9 +113,9 @@ func (t *TeamTasksTool) toListItem(ctx context.Context, task store.TeamTaskData)
Subject: task.Subject,
Status: task.Status,
OwnerAgentKey: task.OwnerAgentKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, task.OwnerAgentKey),
OwnerDisplayName: t.manager.AgentDisplayName(ctx, task.OwnerAgentKey),
CreatedByAgentKey: task.CreatedByAgentKey,
CreatedByDisplayName: t.manager.agentDisplayName(ctx, task.CreatedByAgentKey),
CreatedByDisplayName: t.manager.AgentDisplayName(ctx, task.CreatedByAgentKey),
ProgressPercent: task.ProgressPercent,
ProgressStep: task.ProgressStep,
BlockedBy: t.resolveBlockers(ctx, task.BlockedBy),
@@ -132,9 +132,9 @@ func (t *TeamTasksTool) toDetailItem(ctx context.Context, task *store.TeamTaskDa
Status: task.Status,
Result: task.Result,
OwnerAgentKey: task.OwnerAgentKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, task.OwnerAgentKey),
OwnerDisplayName: t.manager.AgentDisplayName(ctx, task.OwnerAgentKey),
CreatedByAgentKey: task.CreatedByAgentKey,
CreatedByDisplayName: t.manager.agentDisplayName(ctx, task.CreatedByAgentKey),
CreatedByDisplayName: t.manager.AgentDisplayName(ctx, task.CreatedByAgentKey),
ProgressPercent: task.ProgressPercent,
ProgressStep: task.ProgressStep,
BlockedBy: t.resolveBlockers(ctx, task.BlockedBy),
@@ -148,7 +148,7 @@ func (t *TeamTasksTool) toDetailItem(ctx context.Context, task *store.TeamTaskDa
// Injected into search/list results so weaker models (MiniMax, Qwen) get
// actionable hints before calling create.
func (t *TeamTasksTool) buildCreateHint(ctx context.Context, teamID, leadAgentID, callerAgentID uuid.UUID) string {
members, err := t.manager.cachedListMembers(ctx, teamID, callerAgentID)
members, err := t.manager.CachedListMembers(ctx, teamID, callerAgentID)
if err != nil || len(members) == 0 {
return ""
}
@@ -160,7 +160,7 @@ func (t *TeamTasksTool) buildCreateHint(ctx context.Context, teamID, leadAgentID
continue // skip lead from member list
}
model := ""
if ag, err := t.manager.cachedGetAgentByID(ctx, m.AgentID); err == nil {
if ag, err := t.manager.CachedGetAgentByID(ctx, m.AgentID); err == nil {
model = ag.Model
}
entry := fmt.Sprintf("- %s (%s)", m.AgentKey, model)
@@ -182,7 +182,7 @@ func (t *TeamTasksTool) buildCreateHint(ctx context.Context, teamID, leadAgentID
}
func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -216,7 +216,7 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R
ptd.MarkListed()
}
tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", listChatID, 0, offset)
tasks, err := t.manager.Store().ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", listChatID, 0, offset)
if err != nil {
return ErrorResult("failed to list tasks: " + err.Error())
}
@@ -231,7 +231,7 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R
for _, task := range tasks {
agentKeys = append(agentKeys, task.OwnerAgentKey, task.CreatedByAgentKey)
}
t.manager.preWarmAgentKeyCache(ctx, agentKeys)
t.manager.PreWarmAgentKeyCache(ctx, agentKeys)
items := make([]taskListItem, 0, len(tasks))
for _, task := range tasks {
@@ -281,7 +281,7 @@ func resolveTaskID(ctx context.Context, args map[string]any) (uuid.UUID, error)
}
func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Result {
team, _, err := t.manager.resolveTeam(ctx)
team, _, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -291,7 +291,7 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re
return ErrorResult(err.Error())
}
task, err := t.manager.teamStore.GetTask(ctx, taskID)
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("failed to get task: " + err.Error())
}
@@ -310,14 +310,14 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re
}
// Pre-warm cache for task owner + creator display names.
t.manager.preWarmAgentKeyCache(ctx, []string{task.OwnerAgentKey, task.CreatedByAgentKey})
t.manager.PreWarmAgentKeyCache(ctx, []string{task.OwnerAgentKey, task.CreatedByAgentKey})
detail := t.toDetailItem(ctx, task)
// Load and slim comments/events/attachments
resp := map[string]any{"task": detail}
if comments, _ := t.manager.teamStore.ListTaskComments(ctx, taskID); len(comments) > 0 {
if comments, _ := t.manager.Store().ListTaskComments(ctx, taskID); len(comments) > 0 {
// Pre-warm agent cache to avoid N+1 queries for comment agent keys.
commentAgentIDs := make([]uuid.UUID, 0, len(comments))
for _, c := range comments {
@@ -325,13 +325,13 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re
commentAgentIDs = append(commentAgentIDs, *c.AgentID)
}
}
t.manager.preWarmAgentIDCache(ctx, commentAgentIDs)
t.manager.PreWarmAgentIDCache(ctx, commentAgentIDs)
slim := make([]slimComment, 0, len(comments))
for _, c := range comments {
key := ""
if c.AgentID != nil {
key = t.manager.agentKeyFromID(ctx, *c.AgentID)
key = t.manager.AgentKeyFromID(ctx, *c.AgentID)
}
slim = append(slim, slimComment{
AgentKey: key,
@@ -342,7 +342,7 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re
resp["comments"] = slim
}
if events, _ := t.manager.teamStore.ListTaskEvents(ctx, taskID); len(events) > 0 {
if events, _ := t.manager.Store().ListTaskEvents(ctx, taskID); len(events) > 0 {
slim := make([]slimEvent, 0, len(events))
for _, e := range events {
slim = append(slim, slimEvent{
@@ -354,7 +354,7 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re
resp["events"] = slim
}
if attachments, _ := t.manager.teamStore.ListTaskAttachments(ctx, taskID); len(attachments) > 0 {
if attachments, _ := t.manager.Store().ListTaskAttachments(ctx, taskID); len(attachments) > 0 {
resp["attachments"] = attachments
}
@@ -363,7 +363,7 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re
}
func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -389,7 +389,7 @@ func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]any)
ptd.MarkListed()
}
tasks, err := t.manager.teamStore.SearchTasks(ctx, team.ID, query, searchPageSize, filterUserID)
tasks, err := t.manager.Store().SearchTasks(ctx, team.ID, query, searchPageSize, filterUserID)
if err != nil {
return ErrorResult("failed to search tasks: " + err.Error())
}
+145
View File
@@ -0,0 +1,145 @@
package tools
import (
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestExecute_NotificationRunBlocked(t *testing.T) {
mb, tool, _, _, ctx := newTestTeamSetup()
_ = mb
notifCtx := WithRunKind(ctx, RunKindNotification)
tests := []struct {
action string
wantErr bool
}{
{"list", false},
{"get", false},
{"search", false},
{"create", true},
{"claim", true},
{"complete", true},
{"cancel", true},
{"approve", true},
{"reject", true},
{"comment", true},
{"progress", true},
{"update", true},
{"retry", true},
}
for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) {
result := tool.Execute(notifCtx, map[string]any{"action": tt.action})
if tt.wantErr && !result.IsError {
t.Errorf("expected error for action %q during notification run", tt.action)
}
if !tt.wantErr && result.IsError {
if strings.Contains(result.ForLLM, "notification run") {
t.Errorf("action %q should be allowed during notification run, got: %s", tt.action, result.ForLLM)
}
}
})
}
}
func TestExecute_PolicyBlocked(t *testing.T) {
mb, _, _, _, ctx := newTestTeamSetup()
liteTool := NewTeamTasksTool(mb, LiteTeamPolicy{})
blocked := []string{"comment", "review", "approve", "reject", "attach", "ask_user", "clear_ask_user"}
for _, action := range blocked {
t.Run(action, func(t *testing.T) {
result := liteTool.Execute(ctx, map[string]any{"action": action})
if !result.IsError {
t.Errorf("expected policy block for action %q in lite edition", action)
}
if !strings.Contains(result.ForLLM, "not available in this edition") {
t.Errorf("expected edition error, got: %s", result.ForLLM)
}
})
}
}
func TestExecute_UnknownAction(t *testing.T) {
_, tool, _, _, ctx := newTestTeamSetup()
result := tool.Execute(ctx, map[string]any{"action": "nonexistent"})
if !result.IsError {
t.Fatal("expected error for unknown action")
}
if !strings.Contains(result.ForLLM, "unknown action") {
t.Errorf("expected 'unknown action' error, got: %s", result.ForLLM)
}
}
func TestList_FiltersByUserForExternalChannels(t *testing.T) {
_, tool, _, _, ctx := newTestTeamSetup()
extCtx := WithToolChannel(ctx, "telegram")
extCtx = store.WithUserID(extCtx, "user123")
result := tool.Execute(extCtx, map[string]any{"action": "list"})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
}
func TestList_NoFilterForTeammate(t *testing.T) {
_, tool, _, _, ctx := newTestTeamSetup()
teamCtx := WithToolChannel(ctx, ChannelTeammate)
result := tool.Execute(teamCtx, map[string]any{"action": "list"})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
}
func TestSearch_SatisfiesCreateGate(t *testing.T) {
_, tool, _, _, ctx := newTestTeamSetup()
ptd := NewPendingTeamDispatch()
ctx = WithPendingTeamDispatch(ctx, ptd)
if ptd.HasListed() {
t.Fatal("expected HasListed=false before search")
}
tool.Execute(ctx, map[string]any{"action": "search", "query": "test"})
if !ptd.HasListed() {
t.Error("expected HasListed=true after search")
}
}
func TestGet_CrossTeamBlocked(t *testing.T) {
mb, tool, _, _, ctx := newTestTeamSetup()
otherTeamID := uuid.New()
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: otherTeamID,
Subject: "Other team task",
Status: store.TeamTaskStatusPending,
}
mb.taskStore.mu.Unlock()
result := tool.Execute(ctx, map[string]any{"action": "get", "task_id": taskID.String()})
if !result.IsError {
t.Fatal("expected error for cross-team get")
}
if !strings.Contains(result.ForLLM, "does not belong") {
t.Errorf("expected team ownership error, got: %s", result.ForLLM)
}
}
+2 -2
View File
@@ -9,11 +9,11 @@ import (
// TeamTasksTool exposes the shared team task list to agents.
// Actions are filtered by TeamActionPolicy (full in standard, limited in lite).
type TeamTasksTool struct {
manager *TeamToolManager
manager TeamToolBackend
policy TeamActionPolicy
}
func NewTeamTasksTool(manager *TeamToolManager, policy TeamActionPolicy) *TeamTasksTool {
func NewTeamTasksTool(manager TeamToolBackend, policy TeamActionPolicy) *TeamTasksTool {
return &TeamTasksTool{manager: manager, policy: policy}
}
+51
View File
@@ -0,0 +1,51 @@
package tools
import (
"context"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// TeamToolBackend abstracts the TeamToolManager for action handlers.
// This interface enables unit testing of action handlers with a mock backend.
// WorkspaceInterceptor and PostTurnProcessor continue to use *TeamToolManager directly.
type TeamToolBackend interface {
// Team resolution & auth
ResolveTeam(ctx context.Context) (*store.TeamData, uuid.UUID, error)
RequireLead(ctx context.Context, team *store.TeamData, agentID uuid.UUID) error
// Store access
Store() store.TeamStore
// Agent resolution
ResolveAgentByKey(ctx context.Context, key string) (uuid.UUID, error)
AgentKeyFromID(ctx context.Context, id uuid.UUID) string
AgentDisplayName(ctx context.Context, key string) string
CachedListMembers(ctx context.Context, teamID, agentID uuid.UUID) ([]store.TeamMemberData, error)
CachedGetAgentByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error)
PreWarmAgentKeyCache(ctx context.Context, keys []string)
PreWarmAgentIDCache(ctx context.Context, ids []uuid.UUID)
// Side effects
BroadcastTeamEvent(ctx context.Context, name string, payload any)
DispatchTaskToAgent(ctx context.Context, task *store.TeamTaskData, team *store.TeamData, agentID uuid.UUID)
TryPublishInbound(msg bus.InboundMessage) bool
// Dispatch helpers
BuildBlockerResultsSummary(ctx context.Context, task *store.TeamTaskData) string
BuildRecentCommentsSummary(ctx context.Context, taskID uuid.UUID) string
RestoreTraceContext(ctx context.Context, task *store.TeamTaskData) context.Context
// Settings helpers
FollowupDelayMinutes(team *store.TeamData) int
FollowupMaxReminders(team *store.TeamData) int
// Data directory for workspace resolution
DataDir() string
}
// Compile-time check: *TeamToolManager must satisfy TeamToolBackend.
var _ TeamToolBackend = (*TeamToolManager)(nil)
+29
View File
@@ -263,6 +263,35 @@ func (m *TeamToolManager) agentKeyFromID(ctx context.Context, id uuid.UUID) stri
return ag.AgentKey
}
// ============================================================
// TeamToolBackend exported wrappers (cache layer)
// ============================================================
func (m *TeamToolManager) ResolveTeam(ctx context.Context) (*store.TeamData, uuid.UUID, error) {
return m.resolveTeam(ctx)
}
func (m *TeamToolManager) RequireLead(ctx context.Context, team *store.TeamData, agentID uuid.UUID) error {
return m.requireLead(ctx, team, agentID)
}
func (m *TeamToolManager) ResolveAgentByKey(ctx context.Context, key string) (uuid.UUID, error) {
return m.resolveAgentByKey(ctx, key)
}
func (m *TeamToolManager) AgentKeyFromID(ctx context.Context, id uuid.UUID) string {
return m.agentKeyFromID(ctx, id)
}
func (m *TeamToolManager) CachedListMembers(ctx context.Context, teamID, agentID uuid.UUID) ([]store.TeamMemberData, error) {
return m.cachedListMembers(ctx, teamID, agentID)
}
func (m *TeamToolManager) CachedGetAgentByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) {
return m.cachedGetAgentByID(ctx, id)
}
func (m *TeamToolManager) PreWarmAgentKeyCache(ctx context.Context, keys []string) {
m.preWarmAgentKeyCache(ctx, keys)
}
func (m *TeamToolManager) PreWarmAgentIDCache(ctx context.Context, ids []uuid.UUID) {
m.preWarmAgentIDCache(ctx, ids)
}
// taskTeamWorkspace extracts the team_workspace path from task metadata.
func taskTeamWorkspace(task *store.TeamTaskData) string {
if task.Metadata == nil {
+17
View File
@@ -16,6 +16,23 @@ import (
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// ============================================================
// TeamToolBackend exported wrappers (dispatch layer)
// ============================================================
func (m *TeamToolManager) DispatchTaskToAgent(ctx context.Context, task *store.TeamTaskData, team *store.TeamData, agentID uuid.UUID) {
m.dispatchTaskToAgent(ctx, task, team, agentID)
}
func (m *TeamToolManager) BuildBlockerResultsSummary(ctx context.Context, task *store.TeamTaskData) string {
return m.buildBlockerResultsSummary(ctx, task)
}
func (m *TeamToolManager) BuildRecentCommentsSummary(ctx context.Context, taskID uuid.UUID) string {
return m.buildRecentCommentsSummary(ctx, taskID)
}
func (m *TeamToolManager) RestoreTraceContext(ctx context.Context, task *store.TeamTaskData) context.Context {
return m.restoreTraceContext(ctx, task)
}
// maxTaskDispatches is the max number of times a single task can be dispatched
// before it auto-fails. Prevents infinite loops when agents can't complete a task.
const maxTaskDispatches = 3
+17
View File
@@ -49,6 +49,23 @@ func (m *TeamToolManager) agentDisplayName(ctx context.Context, key string) stri
return ag.DisplayName
}
// ============================================================
// TeamToolBackend exported wrappers (helpers layer)
// ============================================================
func (m *TeamToolManager) BroadcastTeamEvent(ctx context.Context, name string, payload any) {
m.broadcastTeamEvent(ctx, name, payload)
}
func (m *TeamToolManager) AgentDisplayName(ctx context.Context, key string) string {
return m.agentDisplayName(ctx, key)
}
func (m *TeamToolManager) FollowupDelayMinutes(team *store.TeamData) int {
return m.followupDelayMinutes(team)
}
func (m *TeamToolManager) FollowupMaxReminders(team *store.TeamData) int {
return m.followupMaxReminders(team)
}
// ============================================================
// Version helpers
// ============================================================
+16
View File
@@ -40,3 +40,19 @@ type TeamToolManager struct {
func NewTeamToolManager(teamStore store.TeamStore, agentStore store.AgentStore, msgBus *bus.MessageBus, dataDir string) *TeamToolManager {
return &TeamToolManager{teamStore: teamStore, agentStore: agentStore, msgBus: msgBus, dataDir: dataDir}
}
// ============================================================
// TeamToolBackend exported wrappers
// These thin wrappers satisfy the TeamToolBackend interface
// while keeping the unexported originals for internal use
// (WorkspaceInterceptor, PostTurnProcessor, etc.).
// ============================================================
func (m *TeamToolManager) Store() store.TeamStore { return m.teamStore }
func (m *TeamToolManager) DataDir() string { return m.dataDir }
func (m *TeamToolManager) TryPublishInbound(msg bus.InboundMessage) bool {
if m.msgBus == nil {
return false
}
return m.msgBus.TryPublishInbound(msg)
}