mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +00:00
refactor(tools): extract TeamToolBackend interface for team task testability
Extract TeamToolBackend interface from concrete *TeamToolManager to enable unit testing of the 17 team task action handlers without DB or message bus. - Add TeamToolBackend interface (19 methods) in team_tool_backend.go - Add exported wrappers on TeamToolManager (cache, helpers, dispatch, manager) - Migrate 133 call sites across 5 action handler files (pure casing rename) - Change TeamTasksTool.manager field from *TeamToolManager to TeamToolBackend - Add mock backend infrastructure (baseNoopTeamStore + mockTaskStore + mockBackend) - Add 94 unit subtests across 6 test files covering all actions: lifecycle (claim/complete/cancel/review/approve/reject), create validation, blocker escalation, mutations (comment/progress/attach/update), followup (ask_user/clear_ask_user/retry), read actions (notification/policy/list/search/get) WorkspaceInterceptor and PostTurnProcessor remain on concrete *TeamToolManager. No behavioral changes — all existing logic preserved.
This commit is contained in:
1 parent
c0dfea35ea
commit
e9342533e0
18 files changed
+3357
-138
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
func TestBlockerComment(t *testing.T) {
|
||||
t.Run("InProgressAutoFail", func(t *testing.T) {
|
||||
mb, tool, _, memberID, ctx := newTestTeamSetup()
|
||||
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusInProgress)
|
||||
|
||||
memberCtx := store.WithAgentID(ctx, memberID)
|
||||
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
|
||||
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
|
||||
|
||||
result := tool.Execute(memberCtx, map[string]any{
|
||||
"action": "comment",
|
||||
"task_id": taskID.String(),
|
||||
"text": "database is down, cannot proceed",
|
||||
"type": "blocker",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Task should be failed
|
||||
mb.taskStore.mu.Lock()
|
||||
task := mb.taskStore.tasks[taskID]
|
||||
mb.taskStore.mu.Unlock()
|
||||
if task.Status != store.TeamTaskStatusFailed {
|
||||
t.Errorf("expected task status=failed, got %s", task.Status)
|
||||
}
|
||||
|
||||
// EventTeamTaskFailed must be broadcast
|
||||
mb.mu.Lock()
|
||||
found := false
|
||||
for _, e := range mb.events {
|
||||
if e.Name == protocol.EventTeamTaskFailed {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
mb.mu.Unlock()
|
||||
if !found {
|
||||
t.Error("expected EventTeamTaskFailed broadcast")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NotInProgress_NoEscalation", func(t *testing.T) {
|
||||
mb, tool, _, memberID, ctx := newTestTeamSetup()
|
||||
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusCompleted)
|
||||
|
||||
memberCtx := store.WithAgentID(ctx, memberID)
|
||||
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
|
||||
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
|
||||
|
||||
result := tool.Execute(memberCtx, map[string]any{
|
||||
"action": "comment",
|
||||
"task_id": taskID.String(),
|
||||
"text": "still a concern",
|
||||
"type": "blocker",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Task should NOT be failed (it was already completed)
|
||||
mb.taskStore.mu.Lock()
|
||||
task := mb.taskStore.tasks[taskID]
|
||||
mb.taskStore.mu.Unlock()
|
||||
if task.Status != store.TeamTaskStatusCompleted {
|
||||
t.Errorf("expected task status unchanged (completed), got %s", task.Status)
|
||||
}
|
||||
|
||||
// No EventTeamTaskFailed
|
||||
mb.mu.Lock()
|
||||
for _, e := range mb.events {
|
||||
if e.Name == protocol.EventTeamTaskFailed {
|
||||
mb.mu.Unlock()
|
||||
t.Error("EventTeamTaskFailed should NOT be broadcast for non-in-progress task")
|
||||
return
|
||||
}
|
||||
}
|
||||
mb.mu.Unlock()
|
||||
})
|
||||
|
||||
t.Run("ReasonTruncated500", func(t *testing.T) {
|
||||
mb, tool, _, memberID, ctx := newTestTeamSetup()
|
||||
taskID := makeTask(mb, testTeamID, memberID, store.TeamTaskStatusInProgress)
|
||||
|
||||
memberCtx := store.WithAgentID(ctx, memberID)
|
||||
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
|
||||
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
|
||||
|
||||
longText := strings.Repeat("x", 600)
|
||||
result := tool.Execute(memberCtx, map[string]any{
|
||||
"action": "comment",
|
||||
"task_id": taskID.String(),
|
||||
"text": longText,
|
||||
"type": "blocker",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
mb.taskStore.mu.Lock()
|
||||
task := mb.taskStore.tasks[taskID]
|
||||
mb.taskStore.mu.Unlock()
|
||||
|
||||
// Result stored as "FAILED: Blocked: <reason>"
|
||||
// The reason field in FailTask is "Blocked: " + text, truncated to 500 runes total
|
||||
if task.Result == nil {
|
||||
t.Fatal("expected task result to be set")
|
||||
}
|
||||
if len([]rune(*task.Result)) > 510 { // allow a bit of slack for "FAILED: " prefix
|
||||
t.Errorf("expected reason to be truncated, got %d runes", len([]rune(*task.Result)))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EscalationEnabled", func(t *testing.T) {
|
||||
mb, tool, _, memberID, ctx := newTestTeamSetup()
|
||||
|
||||
// Enable blocker escalation in team settings
|
||||
mb.team.Settings = json.RawMessage(`{"blocker_escalation":{"enabled":true}}`)
|
||||
mb.taskStore.mu.Lock()
|
||||
mb.taskStore.team = mb.team
|
||||
mb.taskStore.mu.Unlock()
|
||||
|
||||
taskID := uuid.New()
|
||||
mb.taskStore.mu.Lock()
|
||||
mb.taskStore.tasks[taskID] = &store.TeamTaskData{
|
||||
BaseModel: store.BaseModel{ID: taskID, CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
TeamID: testTeamID,
|
||||
Subject: "Escalation task",
|
||||
Status: store.TeamTaskStatusInProgress,
|
||||
OwnerAgentID: &memberID,
|
||||
Channel: ChannelDashboard,
|
||||
ChatID: testTeamID.String(),
|
||||
}
|
||||
mb.taskStore.mu.Unlock()
|
||||
|
||||
memberCtx := store.WithAgentID(ctx, memberID)
|
||||
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
|
||||
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
|
||||
|
||||
result := tool.Execute(memberCtx, map[string]any{
|
||||
"action": "comment",
|
||||
"task_id": taskID.String(),
|
||||
"text": "completely stuck",
|
||||
"type": "blocker",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
mb.mu.Lock()
|
||||
inboundCount := len(mb.inbound)
|
||||
mb.mu.Unlock()
|
||||
if inboundCount == 0 {
|
||||
t.Error("expected inbound escalation message to be published")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EscalationDisabled", func(t *testing.T) {
|
||||
mb, tool, _, memberID, ctx := newTestTeamSetup()
|
||||
// Explicitly disable escalation in settings
|
||||
mb.team.Settings = json.RawMessage(`{"blocker_escalation":{"enabled":false}}`)
|
||||
mb.taskStore.mu.Lock()
|
||||
mb.taskStore.team = mb.team
|
||||
mb.taskStore.mu.Unlock()
|
||||
|
||||
taskID := uuid.New()
|
||||
mb.taskStore.mu.Lock()
|
||||
mb.taskStore.tasks[taskID] = &store.TeamTaskData{
|
||||
BaseModel: store.BaseModel{ID: taskID, CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
TeamID: testTeamID,
|
||||
Subject: "No escalation task",
|
||||
Status: store.TeamTaskStatusInProgress,
|
||||
OwnerAgentID: &memberID,
|
||||
Channel: ChannelDashboard,
|
||||
ChatID: testTeamID.String(),
|
||||
}
|
||||
mb.taskStore.mu.Unlock()
|
||||
|
||||
memberCtx := store.WithAgentID(ctx, memberID)
|
||||
memberCtx = WithTaskActionFlags(memberCtx, &TaskActionFlags{})
|
||||
memberCtx = WithTeamTaskID(memberCtx, taskID.String())
|
||||
|
||||
result := tool.Execute(memberCtx, map[string]any{
|
||||
"action": "comment",
|
||||
"task_id": taskID.String(),
|
||||
"text": "stuck here",
|
||||
"type": "blocker",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
mb.mu.Lock()
|
||||
inboundCount := len(mb.inbound)
|
||||
mb.mu.Unlock()
|
||||
if inboundCount != 0 {
|
||||
t.Errorf("expected no inbound escalation, got %d", inboundCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user