diff --git a/internal/tools/team_mock_backend_test.go b/internal/tools/team_mock_backend_test.go new file mode 100644 index 00000000..36b02b97 --- /dev/null +++ b/internal/tools/team_mock_backend_test.go @@ -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 +} diff --git a/internal/tools/team_tasks_blocker.go b/internal/tools/team_tasks_blocker.go index 857e4c73..cc28b412 100644 --- a/internal/tools/team_tasks_blocker.go +++ b/internal/tools/team_tasks_blocker.go @@ -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) } } diff --git a/internal/tools/team_tasks_blocker_test.go b/internal/tools/team_tasks_blocker_test.go new file mode 100644 index 00000000..fd49041e --- /dev/null +++ b/internal/tools/team_tasks_blocker_test.go @@ -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: " + // 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) + } + }) +} diff --git a/internal/tools/team_tasks_create_test.go b/internal/tools/team_tasks_create_test.go new file mode 100644 index 00000000..3e64c5e7 --- /dev/null +++ b/internal/tools/team_tasks_create_test.go @@ -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) + } + }) +} diff --git a/internal/tools/team_tasks_followup.go b/internal/tools/team_tasks_followup.go index 9f41a920..f462344d 100644 --- a/internal/tools/team_tasks_followup.go +++ b/internal/tools/team_tasks_followup.go @@ -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)) } diff --git a/internal/tools/team_tasks_followup_test.go b/internal/tools/team_tasks_followup_test.go new file mode 100644 index 00000000..943e1990 --- /dev/null +++ b/internal/tools/team_tasks_followup_test.go @@ -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) + } + }) +} diff --git a/internal/tools/team_tasks_lifecycle.go b/internal/tools/team_tasks_lifecycle.go index 4c99c6c3..6c5fbc7c 100644 --- a/internal/tools/team_tasks_lifecycle.go +++ b/internal/tools/team_tasks_lifecycle.go @@ -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)) } diff --git a/internal/tools/team_tasks_lifecycle_test.go b/internal/tools/team_tasks_lifecycle_test.go new file mode 100644 index 00000000..31e62f94 --- /dev/null +++ b/internal/tools/team_tasks_lifecycle_test.go @@ -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") + } + }) +} diff --git a/internal/tools/team_tasks_mutations.go b/internal/tools/team_tasks_mutations.go index cd67d67d..2481746c 100644 --- a/internal/tools/team_tasks_mutations.go +++ b/internal/tools/team_tasks_mutations.go @@ -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)) diff --git a/internal/tools/team_tasks_mutations_test.go b/internal/tools/team_tasks_mutations_test.go new file mode 100644 index 00000000..b9b0c578 --- /dev/null +++ b/internal/tools/team_tasks_mutations_test.go @@ -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) + } + }) +} diff --git a/internal/tools/team_tasks_read.go b/internal/tools/team_tasks_read.go index 870f6b52..cad3f774 100644 --- a/internal/tools/team_tasks_read.go +++ b/internal/tools/team_tasks_read.go @@ -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()) } diff --git a/internal/tools/team_tasks_read_test.go b/internal/tools/team_tasks_read_test.go new file mode 100644 index 00000000..09e8d4aa --- /dev/null +++ b/internal/tools/team_tasks_read_test.go @@ -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) + } +} diff --git a/internal/tools/team_tasks_tool.go b/internal/tools/team_tasks_tool.go index 54e1cde8..0f205890 100644 --- a/internal/tools/team_tasks_tool.go +++ b/internal/tools/team_tasks_tool.go @@ -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} } diff --git a/internal/tools/team_tool_backend.go b/internal/tools/team_tool_backend.go new file mode 100644 index 00000000..0b59cc68 --- /dev/null +++ b/internal/tools/team_tool_backend.go @@ -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) diff --git a/internal/tools/team_tool_cache.go b/internal/tools/team_tool_cache.go index 73eb8d0f..00d8ef90 100644 --- a/internal/tools/team_tool_cache.go +++ b/internal/tools/team_tool_cache.go @@ -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 { diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index b8444339..d604eb14 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -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 diff --git a/internal/tools/team_tool_helpers.go b/internal/tools/team_tool_helpers.go index 3f800381..7322e7d8 100644 --- a/internal/tools/team_tool_helpers.go +++ b/internal/tools/team_tool_helpers.go @@ -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 // ============================================================ diff --git a/internal/tools/team_tool_manager.go b/internal/tools/team_tool_manager.go index bd3b4a7e..e667f371 100644 --- a/internal/tools/team_tool_manager.go +++ b/internal/tools/team_tool_manager.go @@ -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) +}