From 5159846971937949ff0a993d5674ac471a1c6212 Mon Sep 17 00:00:00 2001 From: Silvin Lubecki Date: Sat, 24 Jan 2026 17:32:23 +0100 Subject: [PATCH 1/6] feat: add tasks tool with dependency management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new 'tasks' toolset separate from 'todo' that provides dependency-aware task management inspired by Claude Code's task system: Features: - BlockedBy/Blocks relationships between tasks - Automatic enforcement: cannot start blocked tasks - Visual indicators: ✓=done, ■=in-progress, □=pending, ⚠=blocked - Owner assignment for tasks - Circular dependency detection - Unblock notifications when dependencies complete New tools: - create_task: Create task with optional blocked_by and owner - create_tasks: Batch create tasks with dependencies - update_tasks: Update status with dependency enforcement - list_tasks: List with stats and blocked indicators - add_task_dependency: Add blockers to existing task - remove_task_dependency: Remove blockers - get_blocked_tasks: Query blocked tasks Usage in config: toolsets: - type: tasks shared: true # Optional: share across agents The original 'todo' tool remains unchanged for simple use cases. Assisted-By: cagent --- pkg/teamloader/registry.go | 8 + pkg/tools/builtin/tasks.go | 588 +++++++++++++++++++++++++++++++++++++ 2 files changed, 596 insertions(+) create mode 100644 pkg/tools/builtin/tasks.go diff --git a/pkg/teamloader/registry.go b/pkg/teamloader/registry.go index 7374605cc..c218fdd13 100644 --- a/pkg/teamloader/registry.go +++ b/pkg/teamloader/registry.go @@ -59,6 +59,7 @@ func NewDefaultToolsetRegistry() *ToolsetRegistry { r := NewToolsetRegistry() // Register all built-in toolset creators r.Register("todo", createTodoTool) + r.Register("tasks", createTasksTool) r.Register("memory", createMemoryTool) r.Register("think", createThinkTool) r.Register("shell", createShellTool) @@ -80,6 +81,13 @@ func createTodoTool(_ context.Context, toolset latest.Toolset, _ string, _ *conf return builtin.NewTodoTool(), nil } +func createTasksTool(_ context.Context, toolset latest.Toolset, _ string, _ *config.RuntimeConfig) (tools.ToolSet, error) { + if toolset.Shared { + return builtin.NewSharedTasksTool(), nil + } + return builtin.NewTasksTool(), nil +} + func createMemoryTool(_ context.Context, toolset latest.Toolset, parentDir string, runConfig *config.RuntimeConfig) (tools.ToolSet, error) { var memoryPath string if filepath.IsAbs(toolset.Path) { diff --git a/pkg/tools/builtin/tasks.go b/pkg/tools/builtin/tasks.go new file mode 100644 index 000000000..1ad682941 --- /dev/null +++ b/pkg/tools/builtin/tasks.go @@ -0,0 +1,588 @@ +package builtin + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/docker/cagent/pkg/concurrent" + "github.com/docker/cagent/pkg/tools" +) + +const ( + ToolNameCreateTask = "create_task" + ToolNameCreateTasks = "create_tasks" + ToolNameUpdateTasks = "update_tasks" + ToolNameListTasks = "list_tasks" + ToolNameAddTaskDep = "add_task_dependency" + ToolNameRemoveTaskDep = "remove_task_dependency" + ToolNameGetBlockedTasks = "get_blocked_tasks" +) + +type TasksTool struct { + tools.BaseToolSet + handler *tasksHandler +} + +var _ tools.ToolSet = (*TasksTool)(nil) + +// Task represents a task with optional dependencies +type Task struct { + ID string `json:"id" jsonschema:"ID of the task"` + Description string `json:"description" jsonschema:"Description of the task"` + Status string `json:"status" jsonschema:"Status: pending, in-progress, or completed"` + BlockedBy []string `json:"blocked_by,omitempty" jsonschema:"IDs of tasks that must be completed before this one can start"` + Blocks []string `json:"blocks,omitempty" jsonschema:"IDs of tasks that are waiting for this one to complete"` + Owner string `json:"owner,omitempty" jsonschema:"Owner/assignee of this task"` +} + +type CreateTaskArgs struct { + Description string `json:"description" jsonschema:"Description of the task,required"` + BlockedBy []string `json:"blocked_by,omitempty" jsonschema:"IDs of tasks that must be completed before this one can start"` + Owner string `json:"owner,omitempty" jsonschema:"Owner/assignee of this task"` +} + +type CreateTaskItem struct { + Description string `json:"description" jsonschema:"Description of the task,required"` + BlockedBy []string `json:"blocked_by,omitempty" jsonschema:"IDs of tasks that must be completed before this one can start"` + Owner string `json:"owner,omitempty" jsonschema:"Owner/assignee of this task"` +} + +type CreateTasksArgs struct { + Tasks []CreateTaskItem `json:"tasks" jsonschema:"List of tasks to create,required"` +} + +type TaskUpdate struct { + ID string `json:"id" jsonschema:"ID of the task,required"` + Status string `json:"status,omitempty" jsonschema:"New status: pending, in-progress, or completed"` + Owner string `json:"owner,omitempty" jsonschema:"New owner/assignee"` +} + +type UpdateTasksArgs struct { + Updates []TaskUpdate `json:"updates" jsonschema:"List of task updates,required"` +} + +type AddTaskDependencyArgs struct { + TaskID string `json:"task_id" jsonschema:"ID of the task to add dependencies to,required"` + BlockedBy []string `json:"blocked_by" jsonschema:"IDs of tasks that must be completed first,required"` +} + +type RemoveTaskDependencyArgs struct { + TaskID string `json:"task_id" jsonschema:"ID of the task to remove dependencies from,required"` + BlockedBy []string `json:"blocked_by" jsonschema:"IDs of blocking tasks to remove,required"` +} + +type GetBlockedTasksArgs struct { + BlockedBy string `json:"blocked_by,omitempty" jsonschema:"Filter by specific blocker ID (optional)"` +} + +type tasksHandler struct { + tasks *concurrent.Slice[Task] +} + +var NewSharedTasksTool = sync.OnceValue(NewTasksTool) + +func NewTasksTool() *TasksTool { + return &TasksTool{ + handler: &tasksHandler{ + tasks: concurrent.NewSlice[Task](), + }, + } +} + +func (t *TasksTool) Instructions() string { + return `## Using the Tasks Tools + +IMPORTANT: Use these tools to track tasks with dependencies: + +1. Before starting complex work: + - Create tasks using create_task with blocked_by for dependencies + - Break down work into smaller tasks + +2. Dependencies: + - Tasks with blocked_by cannot start until blockers are completed + - Completing a task unblocks dependent tasks + - Use list_tasks to see blocked status + +3. While working: + - Use list_tasks to see available tasks + - Mark tasks as "in-progress" when starting + - Mark as "completed" when done + +4. Visual indicators in list_tasks: + - ✓ = completed, ■ = in-progress, □ = pending, ⚠ = blocked` +} + +func (h *tasksHandler) canStart(taskID string) (bool, []string) { + task, idx := h.tasks.Find(func(t Task) bool { return t.ID == taskID }) + if idx == -1 { + return false, []string{"task not found"} + } + if len(task.BlockedBy) == 0 { + return true, nil + } + var pendingBlockers []string + for _, blockerID := range task.BlockedBy { + blocker, blockerIdx := h.tasks.Find(func(t Task) bool { return t.ID == blockerID }) + if blockerIdx != -1 && blocker.Status != "completed" { + pendingBlockers = append(pendingBlockers, blockerID) + } + } + return len(pendingBlockers) == 0, pendingBlockers +} + +func (h *tasksHandler) findTask(id string) (*Task, int) { + task, idx := h.tasks.Find(func(t Task) bool { return t.ID == id }) + if idx == -1 { + return nil, -1 + } + return &task, idx +} + +func (h *tasksHandler) taskExists(id string) bool { + _, idx := h.findTask(id) + return idx != -1 +} + +func (h *tasksHandler) hasCircularDependency(taskID string, newBlockedBy []string) bool { + blocked := make(map[string]bool) + var collectBlocked func(id string) + collectBlocked = func(id string) { + task, idx := h.findTask(id) + if idx == -1 { + return + } + for _, blockedID := range task.Blocks { + if !blocked[blockedID] { + blocked[blockedID] = true + collectBlocked(blockedID) + } + } + } + collectBlocked(taskID) + for _, blockerID := range newBlockedBy { + if blocked[blockerID] || blockerID == taskID { + return true + } + } + return false +} + +func (h *tasksHandler) getUnblockedTasks(completedID string) []string { + var unblocked []string + h.tasks.Range(func(_ int, task Task) bool { + for _, blockerID := range task.BlockedBy { + if blockerID == completedID { + if canStart, _ := h.canStart(task.ID); canStart && task.Status == "pending" { + unblocked = append(unblocked, task.ID) + } + break + } + } + return true + }) + return unblocked +} + +func (h *tasksHandler) createTask(_ context.Context, params CreateTaskArgs) (*tools.ToolCallResult, error) { + for _, blockerID := range params.BlockedBy { + if !h.taskExists(blockerID) { + return tools.ResultError(fmt.Sprintf("invalid blocked_by reference: %s not found", blockerID)), nil + } + } + id := fmt.Sprintf("task_%d", h.tasks.Length()+1) + task := Task{ + ID: id, + Description: params.Description, + Status: "pending", + BlockedBy: params.BlockedBy, + Owner: params.Owner, + } + h.tasks.Append(task) + for _, blockerID := range params.BlockedBy { + _, idx := h.findTask(blockerID) + if idx != -1 { + h.tasks.Update(idx, func(t Task) Task { + t.Blocks = append(t.Blocks, id) + return t + }) + } + } + var output strings.Builder + fmt.Fprintf(&output, "Created task [%s]: %s", id, params.Description) + if len(params.BlockedBy) > 0 { + fmt.Fprintf(&output, " (blocked by %s)", strings.Join(params.BlockedBy, ", ")) + } + return &tools.ToolCallResult{Output: output.String(), Meta: h.tasks.All()}, nil +} + +func (h *tasksHandler) createTasks(_ context.Context, params CreateTasksArgs) (*tools.ToolCallResult, error) { + start := h.tasks.Length() + var createdIDs []string + for i, item := range params.Tasks { + for _, blockerID := range item.BlockedBy { + if !h.taskExists(blockerID) { + isEarlierInBatch := false + for j := 0; j < i; j++ { + if fmt.Sprintf("task_%d", start+j+1) == blockerID { + isEarlierInBatch = true + break + } + } + if !isEarlierInBatch { + return tools.ResultError(fmt.Sprintf("invalid blocked_by reference: %s not found", blockerID)), nil + } + } + } + id := fmt.Sprintf("task_%d", start+i+1) + task := Task{ + ID: id, + Description: item.Description, + Status: "pending", + BlockedBy: item.BlockedBy, + Owner: item.Owner, + } + h.tasks.Append(task) + createdIDs = append(createdIDs, id) + for _, blockerID := range item.BlockedBy { + _, idx := h.findTask(blockerID) + if idx != -1 { + h.tasks.Update(idx, func(t Task) Task { + t.Blocks = append(t.Blocks, id) + return t + }) + } + } + } + return &tools.ToolCallResult{ + Output: fmt.Sprintf("Created %d tasks: %s", len(params.Tasks), strings.Join(createdIDs, ", ")), + Meta: h.tasks.All(), + }, nil +} + +func (h *tasksHandler) updateTasks(_ context.Context, params UpdateTasksArgs) (*tools.ToolCallResult, error) { + var notFound, updated, blocked, newlyUnblocked []string + for _, update := range params.Updates { + task, idx := h.findTask(update.ID) + if idx == -1 { + notFound = append(notFound, update.ID) + continue + } + if update.Status == "in-progress" && task.Status == "pending" { + if canStart, blockers := h.canStart(update.ID); !canStart { + blocked = append(blocked, fmt.Sprintf("cannot start %s: blocked by %s", update.ID, strings.Join(blockers, ", "))) + continue + } + } + wasCompleting := update.Status == "completed" && task.Status != "completed" + h.tasks.Update(idx, func(t Task) Task { + if update.Status != "" { + t.Status = update.Status + } + if update.Owner != "" { + t.Owner = update.Owner + } + return t + }) + updated = append(updated, fmt.Sprintf("%s -> %s", update.ID, update.Status)) + if wasCompleting { + newlyUnblocked = append(newlyUnblocked, h.getUnblockedTasks(update.ID)...) + } + } + var output strings.Builder + if len(updated) > 0 { + fmt.Fprintf(&output, "Updated %d tasks: %s", len(updated), strings.Join(updated, ", ")) + } + for _, id := range newlyUnblocked { + if output.Len() > 0 { + output.WriteString("; ") + } + fmt.Fprintf(&output, "%s is now unblocked", id) + } + if len(blocked) > 0 { + if output.Len() > 0 { + output.WriteString("; ") + } + output.WriteString(strings.Join(blocked, "; ")) + } + if len(notFound) > 0 { + if output.Len() > 0 { + output.WriteString("; ") + } + fmt.Fprintf(&output, "Not found: %s", strings.Join(notFound, ", ")) + } + if len(updated) == 0 && (len(notFound) > 0 || len(blocked) > 0) { + return tools.ResultError(output.String()), nil + } + if h.allCompleted() { + h.tasks.Clear() + } + return &tools.ToolCallResult{Output: output.String(), Meta: h.tasks.All()}, nil +} + +func (h *tasksHandler) allCompleted() bool { + if h.tasks.Length() == 0 { + return false + } + allDone := true + h.tasks.Range(func(_ int, task Task) bool { + if task.Status != "completed" { + allDone = false + return false + } + return true + }) + return allDone +} + +func (h *tasksHandler) listTasks(_ context.Context, _ tools.ToolCall) (*tools.ToolCallResult, error) { + var output strings.Builder + var completed, inProgress, pending, blockedCount int + h.tasks.Range(func(_ int, task Task) bool { + switch task.Status { + case "completed": + completed++ + case "in-progress": + inProgress++ + default: + pending++ + if canStart, _ := h.canStart(task.ID); !canStart { + blockedCount++ + } + } + return true + }) + if h.tasks.Length() == 0 { + return &tools.ToolCallResult{Output: "No tasks.\n", Meta: h.tasks.All()}, nil + } + fmt.Fprintf(&output, "Tasks (%d done, %d in progress, %d pending", completed, inProgress, pending) + if blockedCount > 0 { + fmt.Fprintf(&output, ", %d blocked", blockedCount) + } + output.WriteString(")\n\n") + h.tasks.Range(func(_ int, task Task) bool { + var icon, suffix string + switch task.Status { + case "completed": + icon = "✓" + case "in-progress": + icon = "■" + default: + if canStart, blockers := h.canStart(task.ID); canStart { + icon = "□" + } else { + icon = "⚠" + suffix = fmt.Sprintf(" → blocked by: %s", strings.Join(blockers, ", ")) + } + } + fmt.Fprintf(&output, "%s [%s] %s", icon, task.ID, task.Description) + if task.Owner != "" { + fmt.Fprintf(&output, " (%s)", task.Owner) + } + output.WriteString(suffix + "\n") + return true + }) + return &tools.ToolCallResult{Output: output.String(), Meta: h.tasks.All()}, nil +} + +func (h *tasksHandler) addDependency(_ context.Context, params AddTaskDependencyArgs) (*tools.ToolCallResult, error) { + task, idx := h.findTask(params.TaskID) + if idx == -1 { + return tools.ResultError(fmt.Sprintf("task not found: %s", params.TaskID)), nil + } + if task.Status == "completed" { + return tools.ResultError(fmt.Sprintf("cannot add dependency to completed task: %s", params.TaskID)), nil + } + for _, blockerID := range params.BlockedBy { + if !h.taskExists(blockerID) { + return tools.ResultError(fmt.Sprintf("blocker not found: %s", blockerID)), nil + } + if blockerID == params.TaskID { + return tools.ResultError(fmt.Sprintf("task cannot depend on itself: %s", params.TaskID)), nil + } + } + if h.hasCircularDependency(params.TaskID, params.BlockedBy) { + return tools.ResultError("circular dependency detected"), nil + } + existingBlockers := make(map[string]bool) + for _, b := range task.BlockedBy { + existingBlockers[b] = true + } + var added, alreadyExists []string + for _, blockerID := range params.BlockedBy { + if existingBlockers[blockerID] { + alreadyExists = append(alreadyExists, blockerID) + } else { + added = append(added, blockerID) + } + } + if len(added) == 0 { + return &tools.ToolCallResult{ + Output: fmt.Sprintf("Dependency already exists: %s is already blocked by %s", params.TaskID, strings.Join(alreadyExists, ", ")), + Meta: h.tasks.All(), + }, nil + } + h.tasks.Update(idx, func(t Task) Task { + t.BlockedBy = append(t.BlockedBy, added...) + return t + }) + for _, blockerID := range added { + _, blockerIdx := h.findTask(blockerID) + if blockerIdx != -1 { + h.tasks.Update(blockerIdx, func(t Task) Task { + t.Blocks = append(t.Blocks, params.TaskID) + return t + }) + } + } + return &tools.ToolCallResult{ + Output: fmt.Sprintf("Added dependency: %s is now blocked by %s", params.TaskID, strings.Join(added, ", ")), + Meta: h.tasks.All(), + }, nil +} + +func (h *tasksHandler) removeDependency(_ context.Context, params RemoveTaskDependencyArgs) (*tools.ToolCallResult, error) { + task, idx := h.findTask(params.TaskID) + if idx == -1 { + return tools.ResultError(fmt.Sprintf("task not found: %s", params.TaskID)), nil + } + toRemove := make(map[string]bool) + for _, b := range params.BlockedBy { + toRemove[b] = true + } + var removed, newBlockedBy []string + for _, blockerID := range task.BlockedBy { + if toRemove[blockerID] { + removed = append(removed, blockerID) + } else { + newBlockedBy = append(newBlockedBy, blockerID) + } + } + if len(removed) == 0 { + return &tools.ToolCallResult{ + Output: fmt.Sprintf("No matching dependencies found to remove from %s", params.TaskID), + Meta: h.tasks.All(), + }, nil + } + h.tasks.Update(idx, func(t Task) Task { + t.BlockedBy = newBlockedBy + return t + }) + for _, blockerID := range removed { + _, blockerIdx := h.findTask(blockerID) + if blockerIdx != -1 { + h.tasks.Update(blockerIdx, func(t Task) Task { + var newBlocks []string + for _, b := range t.Blocks { + if b != params.TaskID { + newBlocks = append(newBlocks, b) + } + } + t.Blocks = newBlocks + return t + }) + } + } + return &tools.ToolCallResult{ + Output: fmt.Sprintf("Removed dependency: %s is no longer blocked by %s", params.TaskID, strings.Join(removed, ", ")), + Meta: h.tasks.All(), + }, nil +} + +func (h *tasksHandler) getBlockedTasks(_ context.Context, params GetBlockedTasksArgs) (*tools.ToolCallResult, error) { + var output strings.Builder + output.WriteString("Blocked tasks:\n") + found := false + h.tasks.Range(func(_ int, task Task) bool { + if len(task.BlockedBy) == 0 || task.Status == "completed" { + return true + } + if params.BlockedBy != "" { + hasBlocker := false + for _, b := range task.BlockedBy { + if b == params.BlockedBy { + hasBlocker = true + break + } + } + if !hasBlocker { + return true + } + } + if canStart, blockers := h.canStart(task.ID); !canStart { + found = true + fmt.Fprintf(&output, "- [%s] %s → blocked by: %s\n", task.ID, task.Description, strings.Join(blockers, ", ")) + } + return true + }) + if !found { + output.Reset() + output.WriteString("No blocked tasks") + if params.BlockedBy != "" { + fmt.Fprintf(&output, " (filtered by %s)", params.BlockedBy) + } + output.WriteString(".\n") + } + return &tools.ToolCallResult{Output: output.String(), Meta: h.tasks.All()}, nil +} + +func (t *TasksTool) Tools(context.Context) ([]tools.Tool, error) { + return []tools.Tool{ + { + Name: ToolNameCreateTask, + Category: "tasks", + Description: "Create a new task. Use blocked_by to specify dependencies on other tasks.", + Parameters: tools.MustSchemaFor[CreateTaskArgs](), + Handler: tools.NewHandler(t.handler.createTask), + Annotations: tools.ToolAnnotations{Title: "Create Task", ReadOnlyHint: true}, + }, + { + Name: ToolNameCreateTasks, + Category: "tasks", + Description: "Create multiple tasks at once with dependencies.", + Parameters: tools.MustSchemaFor[CreateTasksArgs](), + Handler: tools.NewHandler(t.handler.createTasks), + Annotations: tools.ToolAnnotations{Title: "Create Tasks", ReadOnlyHint: true}, + }, + { + Name: ToolNameUpdateTasks, + Category: "tasks", + Description: "Update the status of tasks. Cannot start a task blocked by incomplete dependencies.", + Parameters: tools.MustSchemaFor[UpdateTasksArgs](), + Handler: tools.NewHandler(t.handler.updateTasks), + Annotations: tools.ToolAnnotations{Title: "Update Tasks", ReadOnlyHint: true}, + }, + { + Name: ToolNameListTasks, + Category: "tasks", + Description: "List all tasks with status and dependencies. Visual indicators: ✓=done, ■=in-progress, □=available, ⚠=blocked", + Handler: t.handler.listTasks, + Annotations: tools.ToolAnnotations{Title: "List Tasks", ReadOnlyHint: true}, + }, + { + Name: ToolNameAddTaskDep, + Category: "tasks", + Description: "Add a dependency to an existing task.", + Parameters: tools.MustSchemaFor[AddTaskDependencyArgs](), + Handler: tools.NewHandler(t.handler.addDependency), + Annotations: tools.ToolAnnotations{Title: "Add Task Dependency", ReadOnlyHint: true}, + }, + { + Name: ToolNameRemoveTaskDep, + Category: "tasks", + Description: "Remove a dependency from a task.", + Parameters: tools.MustSchemaFor[RemoveTaskDependencyArgs](), + Handler: tools.NewHandler(t.handler.removeDependency), + Annotations: tools.ToolAnnotations{Title: "Remove Task Dependency", ReadOnlyHint: true}, + }, + { + Name: ToolNameGetBlockedTasks, + Category: "tasks", + Description: "Get a list of all blocked tasks and what is blocking them.", + Parameters: tools.MustSchemaFor[GetBlockedTasksArgs](), + Handler: tools.NewHandler(t.handler.getBlockedTasks), + Annotations: tools.ToolAnnotations{Title: "Get Blocked Tasks", ReadOnlyHint: true}, + }, + }, nil +} From 11add000fb6a0d01b8b0418dd18b0243271ce8b2 Mon Sep 17 00:00:00 2001 From: Silvin Lubecki Date: Sat, 24 Jan 2026 17:38:20 +0100 Subject: [PATCH 2/6] feat: add tests and TUI support for tasks tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests (tasks_test.go): - Test task creation with/without dependencies - Test canStart logic for blocked tasks - Test update enforcement (cannot start blocked) - Test completion unblocking dependents - Test list_tasks output with visual indicators - Test add/remove dependencies - Test circular dependency detection - Test shared instance behavior - Test cross-agent sharing simulation TUI components: - Add taskstool package with sidebar component - Display tasks with dependency indicators in sidebar - Show stats: X done · Y active · Z pending · W blocked - Visual icons: ✓ □ ■ ⚠ - Owner display and blocked-by indicators - Update sidebar to render both todos and tasks E2E test configs: - tasks_dependencies.yaml: single agent with tasks - shared_tasks.yaml: multi-agent with shared tasks Assisted-By: cagent --- e2e/testdata/shared_tasks.yaml | 36 ++ e2e/testdata/tasks_dependencies.yaml | 16 + pkg/tools/builtin/tasks_test.go | 592 ++++++++++++++++++ pkg/tui/components/sidebar/sidebar.go | 11 + pkg/tui/components/tool/taskstool/sidebar.go | 155 +++++ .../components/tool/taskstool/taskstool.go | 20 + pkg/tui/page/chat/runtime_events.go | 11 +- 7 files changed, 838 insertions(+), 3 deletions(-) create mode 100644 e2e/testdata/shared_tasks.yaml create mode 100644 e2e/testdata/tasks_dependencies.yaml create mode 100644 pkg/tools/builtin/tasks_test.go create mode 100644 pkg/tui/components/tool/taskstool/sidebar.go create mode 100644 pkg/tui/components/tool/taskstool/taskstool.go diff --git a/e2e/testdata/shared_tasks.yaml b/e2e/testdata/shared_tasks.yaml new file mode 100644 index 000000000..97b8c2b67 --- /dev/null +++ b/e2e/testdata/shared_tasks.yaml @@ -0,0 +1,36 @@ +version: "2" + +agents: + root: + model: openai/gpt-5-mini + description: Coordinator that delegates to specialized agents + instruction: | + You coordinate work using a shared task list. + Use transfer_task to delegate work to sub-agents. + sub_agents: + - backend + - frontend + toolsets: + - type: tasks + shared: true + - type: transfer_task + + backend: + model: openai/gpt-5-mini + description: Backend developer + instruction: | + You handle backend tasks. + You share a task list with other agents. + toolsets: + - type: tasks + shared: true + + frontend: + model: openai/gpt-5-mini + description: Frontend developer + instruction: | + You handle frontend tasks. + You share a task list with other agents. + toolsets: + - type: tasks + shared: true diff --git a/e2e/testdata/tasks_dependencies.yaml b/e2e/testdata/tasks_dependencies.yaml new file mode 100644 index 000000000..23bfa4803 --- /dev/null +++ b/e2e/testdata/tasks_dependencies.yaml @@ -0,0 +1,16 @@ +version: "2" + +agents: + root: + model: openai/gpt-5-mini + description: Test agent for tasks with dependencies + instruction: | + You are a helpful assistant that uses tasks tools with dependencies. + + When creating tasks: + - Use blocked_by to specify dependencies + - Use owner to assign tasks + + Always use the tasks tools to track work. + toolsets: + - type: tasks diff --git a/pkg/tools/builtin/tasks_test.go b/pkg/tools/builtin/tasks_test.go new file mode 100644 index 000000000..9974381e8 --- /dev/null +++ b/pkg/tools/builtin/tasks_test.go @@ -0,0 +1,592 @@ +package builtin + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/cagent/pkg/tools" +) + +// ============================================================================= +// Unit Tests: Task Creation with Dependencies +// ============================================================================= + +func TestTasksTool_CreateTask_Basic(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + result, err := tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Setup database", + }) + + require.NoError(t, err) + assert.Contains(t, result.Output, "Created task [task_1]: Setup database") + + tasks := tool.handler.tasks.All() + require.Len(t, tasks, 1) + assert.Equal(t, "task_1", tasks[0].ID) + assert.Equal(t, "pending", tasks[0].Status) +} + +func TestTasksTool_CreateTask_WithBlockedBy(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + // Create prerequisite tasks first + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{Description: "Task 1"}) + require.NoError(t, err) + _, err = tool.handler.createTask(t.Context(), CreateTaskArgs{Description: "Task 2"}) + require.NoError(t, err) + + // Create a task that depends on both + result, err := tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Task 3", + BlockedBy: []string{"task_1", "task_2"}, + }) + + require.NoError(t, err) + assert.Contains(t, result.Output, "Created task [task_3]: Task 3") + assert.Contains(t, result.Output, "blocked by task_1, task_2") + + tasks := tool.handler.tasks.All() + require.Len(t, tasks, 3) + assert.Equal(t, []string{"task_1", "task_2"}, tasks[2].BlockedBy) +} + +func TestTasksTool_CreateTask_WithInvalidBlockedBy(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + result, err := tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Some task", + BlockedBy: []string{"task_999"}, + }) + + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Output, "invalid blocked_by reference: task_999 not found") +} + +func TestTasksTool_CreateTask_WithOwner(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + result, err := tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Backend task", + Owner: "backend-dev", + }) + + require.NoError(t, err) + assert.Contains(t, result.Output, "Created task [task_1]: Backend task") + + tasks := tool.handler.tasks.All() + require.Len(t, tasks, 1) + assert.Equal(t, "backend-dev", tasks[0].Owner) +} + +func TestTasksTool_CreateTasks_Batch(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + result, err := tool.handler.createTasks(t.Context(), CreateTasksArgs{ + Tasks: []CreateTaskItem{ + {Description: "Research"}, + {Description: "Design", BlockedBy: []string{"task_1"}}, + {Description: "Implement", BlockedBy: []string{"task_2"}}, + }, + }) + + require.NoError(t, err) + assert.Contains(t, result.Output, "Created 3 tasks") + + tasks := tool.handler.tasks.All() + require.Len(t, tasks, 3) + assert.Empty(t, tasks[0].BlockedBy) + assert.Equal(t, []string{"task_1"}, tasks[1].BlockedBy) + assert.Equal(t, []string{"task_2"}, tasks[2].BlockedBy) +} + +// ============================================================================= +// Unit Tests: canStart Logic +// ============================================================================= + +func TestTasksTool_CanStart_NoDependencies(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{Description: "Independent task"}) + require.NoError(t, err) + + canStart, blockers := tool.handler.canStart("task_1") + assert.True(t, canStart) + assert.Empty(t, blockers) +} + +func TestTasksTool_CanStart_WithPendingBlockers(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{Description: "Blocker"}) + require.NoError(t, err) + _, err = tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Dependent", + BlockedBy: []string{"task_1"}, + }) + require.NoError(t, err) + + canStart, blockers := tool.handler.canStart("task_2") + assert.False(t, canStart) + assert.Equal(t, []string{"task_1"}, blockers) +} + +func TestTasksTool_CanStart_WithCompletedBlockers(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{Description: "Blocker"}) + require.NoError(t, err) + _, err = tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Dependent", + BlockedBy: []string{"task_1"}, + }) + require.NoError(t, err) + + // Complete the blocker + _, err = tool.handler.updateTasks(t.Context(), UpdateTasksArgs{ + Updates: []TaskUpdate{{ID: "task_1", Status: "completed"}}, + }) + require.NoError(t, err) + + canStart, blockers := tool.handler.canStart("task_2") + assert.True(t, canStart) + assert.Empty(t, blockers) +} + +func TestTasksTool_CanStart_MultipleBlockers_PartiallyCompleted(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTasks(t.Context(), CreateTasksArgs{ + Tasks: []CreateTaskItem{ + {Description: "Blocker 1"}, + {Description: "Blocker 2"}, + {Description: "Dependent", BlockedBy: []string{"task_1", "task_2"}}, + }, + }) + require.NoError(t, err) + + // Complete only one blocker + _, err = tool.handler.updateTasks(t.Context(), UpdateTasksArgs{ + Updates: []TaskUpdate{{ID: "task_1", Status: "completed"}}, + }) + require.NoError(t, err) + + canStart, blockers := tool.handler.canStart("task_3") + assert.False(t, canStart) + assert.Equal(t, []string{"task_2"}, blockers) +} + +// ============================================================================= +// Unit Tests: Update with Dependency Enforcement +// ============================================================================= + +func TestTasksTool_UpdateTasks_CannotStartBlocked(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{Description: "Blocker"}) + require.NoError(t, err) + _, err = tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Blocked", + BlockedBy: []string{"task_1"}, + }) + require.NoError(t, err) + + result, err := tool.handler.updateTasks(t.Context(), UpdateTasksArgs{ + Updates: []TaskUpdate{{ID: "task_2", Status: "in-progress"}}, + }) + + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Output, "cannot start task_2: blocked by task_1") +} + +func TestTasksTool_UpdateTasks_CanStartAfterBlockerCompleted(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{Description: "Blocker"}) + require.NoError(t, err) + _, err = tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Blocked", + BlockedBy: []string{"task_1"}, + }) + require.NoError(t, err) + + // Complete blocker first + _, err = tool.handler.updateTasks(t.Context(), UpdateTasksArgs{ + Updates: []TaskUpdate{{ID: "task_1", Status: "completed"}}, + }) + require.NoError(t, err) + + // Now can start the dependent + result, err := tool.handler.updateTasks(t.Context(), UpdateTasksArgs{ + Updates: []TaskUpdate{{ID: "task_2", Status: "in-progress"}}, + }) + + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, result.Output, "task_2 -> in-progress") +} + +func TestTasksTool_UpdateTasks_CompletionUnblocks(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTasks(t.Context(), CreateTasksArgs{ + Tasks: []CreateTaskItem{ + {Description: "First"}, + {Description: "Second", BlockedBy: []string{"task_1"}}, + {Description: "Third", BlockedBy: []string{"task_2"}}, + }, + }) + require.NoError(t, err) + + result, err := tool.handler.updateTasks(t.Context(), UpdateTasksArgs{ + Updates: []TaskUpdate{{ID: "task_1", Status: "completed"}}, + }) + + require.NoError(t, err) + assert.Contains(t, result.Output, "task_1 -> completed") + assert.Contains(t, result.Output, "task_2 is now unblocked") + + // task_3 should still be blocked + canStart, blockers := tool.handler.canStart("task_3") + assert.False(t, canStart) + assert.Equal(t, []string{"task_2"}, blockers) +} + +// ============================================================================= +// Unit Tests: List Tasks +// ============================================================================= + +func TestTasksTool_ListTasks_Empty(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + result, err := tool.handler.listTasks(t.Context(), tools.ToolCall{}) + + require.NoError(t, err) + assert.Contains(t, result.Output, "No tasks") +} + +func TestTasksTool_ListTasks_WithDependencies(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTasks(t.Context(), CreateTasksArgs{ + Tasks: []CreateTaskItem{ + {Description: "Research"}, + {Description: "Design", BlockedBy: []string{"task_1"}}, + {Description: "Implement", BlockedBy: []string{"task_2"}}, + }, + }) + require.NoError(t, err) + + result, err := tool.handler.listTasks(t.Context(), tools.ToolCall{}) + + require.NoError(t, err) + assert.Contains(t, result.Output, "□ [task_1] Research") + assert.Contains(t, result.Output, "⚠ [task_2] Design → blocked by: task_1") + assert.Contains(t, result.Output, "⚠ [task_3] Implement → blocked by: task_2") +} + +func TestTasksTool_ListTasks_StatusIcons(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTasks(t.Context(), CreateTasksArgs{ + Tasks: []CreateTaskItem{ + {Description: "Done"}, + {Description: "Active"}, + {Description: "Pending"}, + }, + }) + require.NoError(t, err) + + _, err = tool.handler.updateTasks(t.Context(), UpdateTasksArgs{ + Updates: []TaskUpdate{ + {ID: "task_1", Status: "completed"}, + {ID: "task_2", Status: "in-progress"}, + }, + }) + require.NoError(t, err) + + result, err := tool.handler.listTasks(t.Context(), tools.ToolCall{}) + + require.NoError(t, err) + assert.Contains(t, result.Output, "✓ [task_1] Done") + assert.Contains(t, result.Output, "■ [task_2] Active") + assert.Contains(t, result.Output, "□ [task_3] Pending") +} + +func TestTasksTool_ListTasks_ShowsOwner(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Backend work", + Owner: "backend-dev", + }) + require.NoError(t, err) + + result, err := tool.handler.listTasks(t.Context(), tools.ToolCall{}) + + require.NoError(t, err) + assert.Contains(t, result.Output, "(backend-dev)") +} + +func TestTasksTool_ListTasks_Stats(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTasks(t.Context(), CreateTasksArgs{ + Tasks: []CreateTaskItem{ + {Description: "Task 1"}, + {Description: "Task 2"}, + {Description: "Task 3"}, + {Description: "Task 4", BlockedBy: []string{"task_1"}}, + }, + }) + require.NoError(t, err) + + _, err = tool.handler.updateTasks(t.Context(), UpdateTasksArgs{ + Updates: []TaskUpdate{ + {ID: "task_1", Status: "completed"}, + {ID: "task_2", Status: "in-progress"}, + }, + }) + require.NoError(t, err) + + result, err := tool.handler.listTasks(t.Context(), tools.ToolCall{}) + + require.NoError(t, err) + assert.Contains(t, result.Output, "1 done") + assert.Contains(t, result.Output, "1 in progress") + assert.Contains(t, result.Output, "2 pending") +} + +// ============================================================================= +// Unit Tests: Add/Remove Dependencies +// ============================================================================= + +func TestTasksTool_AddDependency(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTasks(t.Context(), CreateTasksArgs{ + Tasks: []CreateTaskItem{ + {Description: "First"}, + {Description: "Second"}, + }, + }) + require.NoError(t, err) + + result, err := tool.handler.addDependency(t.Context(), AddTaskDependencyArgs{ + TaskID: "task_2", + BlockedBy: []string{"task_1"}, + }) + + require.NoError(t, err) + assert.Contains(t, result.Output, "Added dependency: task_2 is now blocked by task_1") + + tasks := tool.handler.tasks.All() + assert.Equal(t, []string{"task_1"}, tasks[1].BlockedBy) + assert.Contains(t, tasks[0].Blocks, "task_2") +} + +func TestTasksTool_AddDependency_PreventCircular(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{Description: "First"}) + require.NoError(t, err) + _, err = tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Second", + BlockedBy: []string{"task_1"}, + }) + require.NoError(t, err) + + // Try circular: task_1 blocked by task_2 + result, err := tool.handler.addDependency(t.Context(), AddTaskDependencyArgs{ + TaskID: "task_1", + BlockedBy: []string{"task_2"}, + }) + + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Output, "circular dependency detected") +} + +func TestTasksTool_AddDependency_PreventSelfDependency(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{Description: "Task"}) + require.NoError(t, err) + + result, err := tool.handler.addDependency(t.Context(), AddTaskDependencyArgs{ + TaskID: "task_1", + BlockedBy: []string{"task_1"}, + }) + + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Output, "cannot depend on itself") +} + +func TestTasksTool_RemoveDependency(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{Description: "First"}) + require.NoError(t, err) + _, err = tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Second", + BlockedBy: []string{"task_1"}, + }) + require.NoError(t, err) + + result, err := tool.handler.removeDependency(t.Context(), RemoveTaskDependencyArgs{ + TaskID: "task_2", + BlockedBy: []string{"task_1"}, + }) + + require.NoError(t, err) + assert.Contains(t, result.Output, "Removed dependency") + + tasks := tool.handler.tasks.All() + assert.Empty(t, tasks[1].BlockedBy) + assert.NotContains(t, tasks[0].Blocks, "task_2") +} + +// ============================================================================= +// Unit Tests: Get Blocked Tasks +// ============================================================================= + +func TestTasksTool_GetBlockedTasks(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTasks(t.Context(), CreateTasksArgs{ + Tasks: []CreateTaskItem{ + {Description: "Root"}, + {Description: "Child 1", BlockedBy: []string{"task_1"}}, + {Description: "Child 2", BlockedBy: []string{"task_1"}}, + }, + }) + require.NoError(t, err) + + result, err := tool.handler.getBlockedTasks(t.Context(), GetBlockedTasksArgs{}) + + require.NoError(t, err) + assert.Contains(t, result.Output, "task_2") + assert.Contains(t, result.Output, "task_3") + assert.Contains(t, result.Output, "blocked by: task_1") +} + +func TestTasksTool_GetBlockedTasks_FilterByBlocker(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + _, err := tool.handler.createTasks(t.Context(), CreateTasksArgs{ + Tasks: []CreateTaskItem{ + {Description: "Blocker A"}, + {Description: "Blocker B"}, + {Description: "Blocked by A", BlockedBy: []string{"task_1"}}, + {Description: "Blocked by B", BlockedBy: []string{"task_2"}}, + }, + }) + require.NoError(t, err) + + result, err := tool.handler.getBlockedTasks(t.Context(), GetBlockedTasksArgs{ + BlockedBy: "task_1", + }) + + require.NoError(t, err) + assert.Contains(t, result.Output, "task_3") + assert.NotContains(t, result.Output, "task_4") +} + +// ============================================================================= +// Unit Tests: Shared Instance +// ============================================================================= + +func TestTasksTool_SharedInstance(t *testing.T) { + t.Parallel() + + shared1 := NewSharedTasksTool() + shared2 := NewSharedTasksTool() + assert.Same(t, shared1, shared2, "NewSharedTasksTool should return same instance") + + nonShared1 := NewTasksTool() + nonShared2 := NewTasksTool() + assert.NotSame(t, nonShared1, nonShared2, "NewTasksTool should return different instances") +} + +func TestTasksTool_CrossAgentSharing(t *testing.T) { + // Simulates two agents sharing a task list + tool := NewTasksTool() + + // Agent A creates a task + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Task from Agent A", + Owner: "agent-a", + }) + require.NoError(t, err) + + // Agent B creates a dependent task + _, err = tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Task from Agent B", + Owner: "agent-b", + BlockedBy: []string{"task_1"}, + }) + require.NoError(t, err) + + // Both tasks visible + tasks := tool.handler.tasks.All() + require.Len(t, tasks, 2) + assert.Equal(t, "agent-a", tasks[0].Owner) + assert.Equal(t, "agent-b", tasks[1].Owner) + + // Agent A completes their task + result, err := tool.handler.updateTasks(t.Context(), UpdateTasksArgs{ + Updates: []TaskUpdate{{ID: "task_1", Status: "completed"}}, + }) + require.NoError(t, err) + assert.Contains(t, result.Output, "task_2 is now unblocked") + + // Agent B can now start + canStart, _ := tool.handler.canStart("task_2") + assert.True(t, canStart) +} + +// ============================================================================= +// Unit Tests: Schema +// ============================================================================= + +func TestTasksTool_Schema(t *testing.T) { + t.Parallel() + tool := NewTasksTool() + + allTools, err := tool.Tools(t.Context()) + require.NoError(t, err) + require.Len(t, allTools, 7) + + // Verify all tools have correct category + for _, tt := range allTools { + assert.Equal(t, "tasks", tt.Category) + } +} diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index c9ee159dc..526952b44 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -20,6 +20,7 @@ import ( "github.com/docker/cagent/pkg/tui/components/scrollbar" "github.com/docker/cagent/pkg/tui/components/spinner" "github.com/docker/cagent/pkg/tui/components/tab" + "github.com/docker/cagent/pkg/tui/components/tool/taskstool" "github.com/docker/cagent/pkg/tui/components/tool/todotool" "github.com/docker/cagent/pkg/tui/components/toolcommon" "github.com/docker/cagent/pkg/tui/core/layout" @@ -43,6 +44,7 @@ type Model interface { SetTokenUsage(event *runtime.TokenUsageEvent) SetTodos(result *tools.ToolCallResult) error + SetTasks(result *tools.ToolCallResult) error SetMode(mode Mode) SetAgentInfo(agentName, model, description string) SetTeamInfo(availableAgents []runtime.AgentDetails) @@ -73,6 +75,7 @@ type model struct { sessionUsage map[string]*runtime.Usage // sessionID -> latest usage snapshot sessionAgent map[string]string // sessionID -> agent name todoComp *todotool.SidebarComponent + tasksComp *taskstool.SidebarComponent mcpInit bool ragIndexing map[string]*ragIndexingState // strategy name -> indexing state spinner spinner.Spinner @@ -112,6 +115,7 @@ func New(sessionState *service.SessionState, opts ...Option) Model { sessionUsage: make(map[string]*runtime.Usage), sessionAgent: make(map[string]string), todoComp: todotool.NewSidebarComponent(), + tasksComp: taskstool.NewSidebarComponent(), spinner: spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle), sessionTitle: "New session", ragIndexing: make(map[string]*ragIndexingState), @@ -148,6 +152,10 @@ func (m *model) SetTodos(result *tools.ToolCallResult) error { return m.todoComp.SetTodos(result) } +func (m *model) SetTasks(result *tools.ToolCallResult) error { + return m.tasksComp.SetTasks(result) +} + // SetAgentInfo sets the current agent information and updates the model in availableAgents func (m *model) SetAgentInfo(agentName, modelID, description string) { m.currentAgent = agentName @@ -526,6 +534,9 @@ func (m *model) renderSections(contentWidth int) []string { m.todoComp.SetSize(contentWidth) appendSection(strings.TrimSuffix(m.todoComp.Render(), "\n")) + m.tasksComp.SetSize(contentWidth) + appendSection(strings.TrimSuffix(m.tasksComp.Render(), "\n")) + return lines } diff --git a/pkg/tui/components/tool/taskstool/sidebar.go b/pkg/tui/components/tool/taskstool/sidebar.go new file mode 100644 index 000000000..dd7d613c3 --- /dev/null +++ b/pkg/tui/components/tool/taskstool/sidebar.go @@ -0,0 +1,155 @@ +package taskstool + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/docker/cagent/pkg/tools" + "github.com/docker/cagent/pkg/tools/builtin" + "github.com/docker/cagent/pkg/tui/components/tab" + "github.com/docker/cagent/pkg/tui/components/toolcommon" + "github.com/docker/cagent/pkg/tui/styles" +) + +// SidebarComponent represents the tasks display component for the sidebar +type SidebarComponent struct { + tasks []builtin.Task + width int +} + +func NewSidebarComponent() *SidebarComponent { + return &SidebarComponent{ + width: 20, + } +} + +func (c *SidebarComponent) SetSize(width int) { + c.width = width +} + +func (c *SidebarComponent) SetTasks(result *tools.ToolCallResult) error { + if result == nil || result.Meta == nil { + return nil + } + + tasks, ok := result.Meta.([]builtin.Task) + if !ok { + return nil + } + + c.tasks = tasks + return nil +} + +func (c *SidebarComponent) Render() string { + if len(c.tasks) == 0 { + return "" + } + + var lines []string + + // Add summary stats + lines = append(lines, c.renderStats()) + lines = append(lines, "") + + // Render each task + for _, task := range c.tasks { + lines = append(lines, c.renderTaskLine(task)) + } + + return c.renderTab("Tasks", strings.Join(lines, "\n")) +} + +func (c *SidebarComponent) renderStats() string { + var completed, inProgress, pending, blocked int + for _, task := range c.tasks { + switch task.Status { + case "completed": + completed++ + case "in-progress": + inProgress++ + default: + pending++ + if len(task.BlockedBy) > 0 && !c.allBlockersCompleted(task.BlockedBy) { + blocked++ + } + } + } + + var parts []string + if completed > 0 { + parts = append(parts, fmt.Sprintf("%d done", completed)) + } + if inProgress > 0 { + parts = append(parts, fmt.Sprintf("%d active", inProgress)) + } + if pending > 0 { + parts = append(parts, fmt.Sprintf("%d pending", pending)) + } + if blocked > 0 { + parts = append(parts, styles.WarningStyle.Render(fmt.Sprintf("%d blocked", blocked))) + } + + return strings.Join(parts, " · ") +} + +func (c *SidebarComponent) allBlockersCompleted(blockerIDs []string) bool { + for _, blockerID := range blockerIDs { + for _, task := range c.tasks { + if task.ID == blockerID && task.Status != "completed" { + return false + } + } + } + return true +} + +func (c *SidebarComponent) renderTaskLine(task builtin.Task) string { + icon, iconStyle := renderTaskIcon(task.Status) + + // Check if blocked + isBlocked := len(task.BlockedBy) > 0 && !c.allBlockersCompleted(task.BlockedBy) + if isBlocked && task.Status == "pending" { + icon = "⚠" + iconStyle = styles.WarningStyle + } + + // Build the line + prefix := iconStyle.Render(icon) + " " + prefixWidth := lipgloss.Width(prefix) + + // Calculate available width for description + maxDescWidth := c.width - prefixWidth + + // Add owner suffix if present + var ownerSuffix string + if task.Owner != "" { + ownerSuffix = styles.MutedStyle.Render(" (" + task.Owner + ")") + maxDescWidth -= lipgloss.Width(ownerSuffix) + } + + description := toolcommon.TruncateText(task.Description, maxDescWidth) + + // Apply strikethrough for completed items + if task.Status == "completed" { + description = styles.CompletedStyle.Strikethrough(true).Render(description) + } else { + description = styles.TabPrimaryStyle.Render(description) + } + + line := prefix + description + ownerSuffix + + // Add blocked-by indicator on next line if blocked + if isBlocked { + blockerText := styles.MutedStyle.Render(" → blocked by: " + strings.Join(task.BlockedBy, ", ")) + line += "\n" + toolcommon.TruncateText(blockerText, c.width) + } + + return line +} + +func (c *SidebarComponent) renderTab(title, content string) string { + return tab.Render(title, content, c.width) +} diff --git a/pkg/tui/components/tool/taskstool/taskstool.go b/pkg/tui/components/tool/taskstool/taskstool.go new file mode 100644 index 000000000..773dcc54b --- /dev/null +++ b/pkg/tui/components/tool/taskstool/taskstool.go @@ -0,0 +1,20 @@ +package taskstool + +import ( + "charm.land/lipgloss/v2" + + "github.com/docker/cagent/pkg/tui/styles" +) + +func renderTaskIcon(status string) (string, lipgloss.Style) { + switch status { + case "pending": + return "□", styles.ToBeDoneStyle + case "in-progress": + return "■", styles.InProgressStyle + case "completed": + return "✓", styles.CompletedStyle + default: + return "?", styles.ToBeDoneStyle + } +} diff --git a/pkg/tui/page/chat/runtime_events.go b/pkg/tui/page/chat/runtime_events.go index 0adf10625..5e68ff005 100644 --- a/pkg/tui/page/chat/runtime_events.go +++ b/pkg/tui/page/chat/runtime_events.go @@ -219,9 +219,14 @@ func (p *chatPage) handleToolCallResponse(msg *runtime.ToolCallResponseEvent) te } toolCmd := p.messages.AddToolResult(msg, status) - // Update todo sidebar if this is a todo tool - if msg.ToolDefinition.Category == "todo" && !msg.Result.IsError { - _ = p.sidebar.SetTodos(msg.Result) + // Update sidebar for todo/tasks tools + if !msg.Result.IsError { + switch msg.ToolDefinition.Category { + case "todo": + _ = p.sidebar.SetTodos(msg.Result) + case "tasks": + _ = p.sidebar.SetTasks(msg.Result) + } } return tea.Batch(toolCmd, p.messages.ScrollToBottom(), spinnerCmd) From ad708d721f058fe27f4464a7c8d2dc8dc14e78eb Mon Sep 17 00:00:00 2001 From: Silvin Lubecki Date: Sat, 24 Jan 2026 17:41:17 +0100 Subject: [PATCH 3/6] chore: use tasks tool in golang_developer agent Replace todo with tasks to leverage dependency management for more structured development workflows. Assisted-By: cagent --- golang_developer.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/golang_developer.yaml b/golang_developer.yaml index 0749a0b63..a9efd1ee6 100755 --- a/golang_developer.yaml +++ b/golang_developer.yaml @@ -97,7 +97,7 @@ agents: toolsets: - type: filesystem - type: shell - - type: todo + - type: tasks - type: fetch sub_agents: - librarian From 67ff0c235a5c6bdf4a77898db018f7c755b0eb71 Mon Sep 17 00:00:00 2001 From: Silvin Lubecki Date: Sat, 24 Jan 2026 17:51:11 +0100 Subject: [PATCH 4/6] fix: address lint issues in tasks tool - Fix gci import formatting in tasks.go - Combine append calls in taskstool/sidebar.go (gocritic) - Use integer range syntax for loop (intrange) Assisted-By: cagent --- pkg/tools/builtin/tasks.go | 16 ++++++++-------- pkg/tui/components/tool/taskstool/sidebar.go | 3 +-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/pkg/tools/builtin/tasks.go b/pkg/tools/builtin/tasks.go index 1ad682941..470d4fde3 100644 --- a/pkg/tools/builtin/tasks.go +++ b/pkg/tools/builtin/tasks.go @@ -11,13 +11,13 @@ import ( ) const ( - ToolNameCreateTask = "create_task" - ToolNameCreateTasks = "create_tasks" - ToolNameUpdateTasks = "update_tasks" - ToolNameListTasks = "list_tasks" - ToolNameAddTaskDep = "add_task_dependency" - ToolNameRemoveTaskDep = "remove_task_dependency" - ToolNameGetBlockedTasks = "get_blocked_tasks" + ToolNameCreateTask = "create_task" + ToolNameCreateTasks = "create_tasks" + ToolNameUpdateTasks = "update_tasks" + ToolNameListTasks = "list_tasks" + ToolNameAddTaskDep = "add_task_dependency" + ToolNameRemoveTaskDep = "remove_task_dependency" + ToolNameGetBlockedTasks = "get_blocked_tasks" ) type TasksTool struct { @@ -224,7 +224,7 @@ func (h *tasksHandler) createTasks(_ context.Context, params CreateTasksArgs) (* for _, blockerID := range item.BlockedBy { if !h.taskExists(blockerID) { isEarlierInBatch := false - for j := 0; j < i; j++ { + for j := range i { if fmt.Sprintf("task_%d", start+j+1) == blockerID { isEarlierInBatch = true break diff --git a/pkg/tui/components/tool/taskstool/sidebar.go b/pkg/tui/components/tool/taskstool/sidebar.go index dd7d613c3..141408fd9 100644 --- a/pkg/tui/components/tool/taskstool/sidebar.go +++ b/pkg/tui/components/tool/taskstool/sidebar.go @@ -51,8 +51,7 @@ func (c *SidebarComponent) Render() string { var lines []string // Add summary stats - lines = append(lines, c.renderStats()) - lines = append(lines, "") + lines = append(lines, c.renderStats(), "") // Render each task for _, task := range c.tasks { From 26b45aaaf2a33e9726bbfb1cc27451a731ed4cdf Mon Sep 17 00:00:00 2001 From: Silvin Lubecki Date: Sun, 25 Jan 2026 18:56:39 +0100 Subject: [PATCH 5/6] fix(tui): show blocker descriptions instead of IDs in tasks sidebar Assisted-By: cagent --- pkg/tui/components/tool/taskstool/sidebar.go | 27 +++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/pkg/tui/components/tool/taskstool/sidebar.go b/pkg/tui/components/tool/taskstool/sidebar.go index 141408fd9..5a0b6e61a 100644 --- a/pkg/tui/components/tool/taskstool/sidebar.go +++ b/pkg/tui/components/tool/taskstool/sidebar.go @@ -142,7 +142,8 @@ func (c *SidebarComponent) renderTaskLine(task builtin.Task) string { // Add blocked-by indicator on next line if blocked if isBlocked { - blockerText := styles.MutedStyle.Render(" → blocked by: " + strings.Join(task.BlockedBy, ", ")) + blockerNames := c.getBlockerDescriptions(task.BlockedBy) + blockerText := styles.MutedStyle.Render(" → blocked by: " + strings.Join(blockerNames, ", ")) line += "\n" + toolcommon.TruncateText(blockerText, c.width) } @@ -152,3 +153,27 @@ func (c *SidebarComponent) renderTaskLine(task builtin.Task) string { func (c *SidebarComponent) renderTab(title, content string) string { return tab.Render(title, content, c.width) } + +// getBlockerDescriptions returns short descriptions for the given blocker IDs +func (c *SidebarComponent) getBlockerDescriptions(blockerIDs []string) []string { + result := make([]string, 0, len(blockerIDs)) + for _, id := range blockerIDs { + found := false + for _, task := range c.tasks { + if task.ID == id { + // Truncate description to keep it short + desc := task.Description + if len(desc) > 20 { + desc = desc[:17] + "..." + } + result = append(result, desc) + found = true + break + } + } + if !found { + result = append(result, id) // Fallback to ID if not found + } + } + return result +} From 7633b5d111bbb9428f17aea81de07dfb84f74f22 Mon Sep 17 00:00:00 2001 From: Silvin Lubecki Date: Sun, 25 Jan 2026 18:56:46 +0100 Subject: [PATCH 6/6] feat(tasks): add cross-session persistence Add file-based persistence for tasks with --task-list flag or CAGENT_TASK_LIST_ID env var. Tasks are stored in ~/.cagent/tasks/.json. - Add TaskStore interface with FileTaskStore and MemoryTaskStore - Add GetTasksDir() to paths package - Add TaskListID to RuntimeConfig - Add --task-list flag and CAGENT_TASK_LIST_ID env var support - Lazy loading: tasks loaded on first access - Atomic writes with temp file + rename Without --task-list flag, tasks remain in-memory only (no persistence). Assisted-By: cagent --- cmd/root/flags.go | 22 ++ cmd/root/flags_test.go | 50 ++++ pkg/config/runtime.go | 1 + pkg/paths/paths.go | 5 + pkg/teamloader/registry.go | 13 +- pkg/tools/builtin/tasks.go | 75 +++++- pkg/tools/builtin/tasks_store.go | 133 +++++++++ pkg/tools/builtin/tasks_store_test.go | 268 +++++++++++++++++++ pkg/tui/components/tool/taskstool/sidebar.go | 34 +-- 9 files changed, 583 insertions(+), 18 deletions(-) create mode 100644 pkg/tools/builtin/tasks_store.go create mode 100644 pkg/tools/builtin/tasks_store_test.go diff --git a/cmd/root/flags.go b/cmd/root/flags.go index b441ef217..416bc47e0 100644 --- a/cmd/root/flags.go +++ b/cmd/root/flags.go @@ -16,10 +16,13 @@ import ( const ( flagModelsGateway = "models-gateway" envModelsGateway = "CAGENT_MODELS_GATEWAY" + flagTaskList = "task-list" + envTaskListID = "CAGENT_TASK_LIST_ID" ) func addRuntimeConfigFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig) { addGatewayFlags(cmd, runConfig) + addTaskListFlags(cmd, runConfig) cmd.PersistentFlags().StringSliceVar(&runConfig.EnvFiles, "env-from-file", nil, "Set environment variables from file") cmd.PersistentFlags().BoolVar(&runConfig.GlobalCodeMode, "code-mode-tools", false, "Provide a single tool to call other tools via Javascript") cmd.PersistentFlags().StringVar(&runConfig.WorkingDir, "working-dir", "", "Set the working directory for the session (applies to tools and relative paths)") @@ -94,3 +97,22 @@ func addGatewayFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig) { return nil } } + +func addTaskListFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig) { + cmd.PersistentFlags().StringVar(&runConfig.TaskListID, flagTaskList, "", "Use a persistent task list with the given ID") + + persistentPreRunE := cmd.PersistentPreRunE + cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error { + // Precedence: CLI flag > environment variable + if runConfig.TaskListID != "" { + logFlagShadowing(os.Getenv(envTaskListID), envTaskListID, flagTaskList) + } else if taskListID := os.Getenv(envTaskListID); taskListID != "" { + runConfig.TaskListID = taskListID + } + + if persistentPreRunE != nil { + return persistentPreRunE(c, args) + } + return nil + } +} diff --git a/cmd/root/flags_test.go b/cmd/root/flags_test.go index b2a9c330e..fb7809021 100644 --- a/cmd/root/flags_test.go +++ b/cmd/root/flags_test.go @@ -152,3 +152,53 @@ func TestCanonize(t *testing.T) { }) } } + +func TestTaskListLogic(t *testing.T) { + tests := []struct { + name string + env string + args []string + expected string + }{ + { + name: "empty_by_default", + expected: "", + }, + { + name: "from_env", + env: "my-project", + expected: "my-project", + }, + { + name: "from_cli", + args: []string{"--task-list", "cli-project"}, + expected: "cli-project", + }, + { + name: "cli_overrides_env", + env: "env-project", + args: []string{"--task-list", "cli-project"}, + expected: "cli-project", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CAGENT_TASK_LIST_ID", tt.env) + + cmd := &cobra.Command{ + RunE: func(*cobra.Command, []string) error { + return nil + }, + } + runConfig := config.RuntimeConfig{} + addTaskListFlags(cmd, &runConfig) + + cmd.SetArgs(tt.args) + err := cmd.Execute() + + require.NoError(t, err) + assert.Equal(t, tt.expected, runConfig.TaskListID) + }) + } +} diff --git a/pkg/config/runtime.go b/pkg/config/runtime.go index dd64bcbb4..12673beb4 100644 --- a/pkg/config/runtime.go +++ b/pkg/config/runtime.go @@ -20,6 +20,7 @@ type Config struct { ModelsGateway string GlobalCodeMode bool WorkingDir string + TaskListID string // ID for persistent task list (from --task-list or CAGENT_TASK_LIST_ID) } func (runConfig *RuntimeConfig) Clone() *RuntimeConfig { diff --git a/pkg/paths/paths.go b/pkg/paths/paths.go index e4b20230e..535a8d0d3 100644 --- a/pkg/paths/paths.go +++ b/pkg/paths/paths.go @@ -41,3 +41,8 @@ func GetHomeDir() string { } return filepath.Clean(homeDir) } + +// GetTasksDir returns the directory for storing task lists. +func GetTasksDir() string { + return filepath.Join(GetDataDir(), "tasks") +} diff --git a/pkg/teamloader/registry.go b/pkg/teamloader/registry.go index c218fdd13..5ba00748a 100644 --- a/pkg/teamloader/registry.go +++ b/pkg/teamloader/registry.go @@ -81,10 +81,21 @@ func createTodoTool(_ context.Context, toolset latest.Toolset, _ string, _ *conf return builtin.NewTodoTool(), nil } -func createTasksTool(_ context.Context, toolset latest.Toolset, _ string, _ *config.RuntimeConfig) (tools.ToolSet, error) { +func createTasksTool(_ context.Context, toolset latest.Toolset, _ string, runConfig *config.RuntimeConfig) (tools.ToolSet, error) { + // Determine task list ID: CLI/env takes precedence + listID := runConfig.TaskListID + if toolset.Shared { + // Shared mode uses in-memory storage (no persistence) return builtin.NewSharedTasksTool(), nil } + + if listID != "" { + // Use file-based persistence with the specified list ID + return builtin.NewTasksToolWithStore(builtin.NewFileTaskStore(listID)), nil + } + + // Default: in-memory only (no persistence) return builtin.NewTasksTool(), nil } diff --git a/pkg/tools/builtin/tasks.go b/pkg/tools/builtin/tasks.go index 470d4fde3..9be95cb13 100644 --- a/pkg/tools/builtin/tasks.go +++ b/pkg/tools/builtin/tasks.go @@ -3,6 +3,7 @@ package builtin import ( "context" "fmt" + "log/slog" "strings" "sync" @@ -78,19 +79,60 @@ type GetBlockedTasksArgs struct { } type tasksHandler struct { - tasks *concurrent.Slice[Task] + tasks *concurrent.Slice[Task] + store TaskStore + loaded bool } -var NewSharedTasksTool = sync.OnceValue(NewTasksTool) +// Shared instance for shared: true (no persistence) +var NewSharedTasksTool = sync.OnceValue(func() *TasksTool { + return NewTasksToolWithStore(NewMemoryTaskStore()) +}) +// NewTasksTool creates a new TasksTool with in-memory storage only func NewTasksTool() *TasksTool { + return NewTasksToolWithStore(NewMemoryTaskStore()) +} + +// NewTasksToolWithStore creates a new TasksTool with the specified store +func NewTasksToolWithStore(store TaskStore) *TasksTool { return &TasksTool{ handler: &tasksHandler{ tasks: concurrent.NewSlice[Task](), + store: store, }, } } +// ensureLoaded loads tasks from store on first access (lazy loading) +func (h *tasksHandler) ensureLoaded() { + if h.loaded { + return + } + h.loaded = true + + tasks, err := h.store.Load() + if err != nil { + slog.Error("Failed to load tasks from store", "error", err) + return + } + + for _, task := range tasks { + h.tasks.Append(task) + } + + if len(tasks) > 0 { + slog.Debug("Loaded tasks from store", "count", len(tasks)) + } +} + +// save persists tasks to store +func (h *tasksHandler) save() { + if err := h.store.Save(h.tasks.All()); err != nil { + slog.Error("Failed to save tasks to store", "error", err) + } +} + func (t *TasksTool) Instructions() string { return `## Using the Tasks Tools @@ -186,6 +228,8 @@ func (h *tasksHandler) getUnblockedTasks(completedID string) []string { } func (h *tasksHandler) createTask(_ context.Context, params CreateTaskArgs) (*tools.ToolCallResult, error) { + h.ensureLoaded() + for _, blockerID := range params.BlockedBy { if !h.taskExists(blockerID) { return tools.ResultError(fmt.Sprintf("invalid blocked_by reference: %s not found", blockerID)), nil @@ -209,6 +253,9 @@ func (h *tasksHandler) createTask(_ context.Context, params CreateTaskArgs) (*to }) } } + + h.save() + var output strings.Builder fmt.Fprintf(&output, "Created task [%s]: %s", id, params.Description) if len(params.BlockedBy) > 0 { @@ -218,6 +265,8 @@ func (h *tasksHandler) createTask(_ context.Context, params CreateTaskArgs) (*to } func (h *tasksHandler) createTasks(_ context.Context, params CreateTasksArgs) (*tools.ToolCallResult, error) { + h.ensureLoaded() + start := h.tasks.Length() var createdIDs []string for i, item := range params.Tasks { @@ -255,6 +304,9 @@ func (h *tasksHandler) createTasks(_ context.Context, params CreateTasksArgs) (* } } } + + h.save() + return &tools.ToolCallResult{ Output: fmt.Sprintf("Created %d tasks: %s", len(params.Tasks), strings.Join(createdIDs, ", ")), Meta: h.tasks.All(), @@ -262,6 +314,8 @@ func (h *tasksHandler) createTasks(_ context.Context, params CreateTasksArgs) (* } func (h *tasksHandler) updateTasks(_ context.Context, params UpdateTasksArgs) (*tools.ToolCallResult, error) { + h.ensureLoaded() + var notFound, updated, blocked, newlyUnblocked []string for _, update := range params.Updates { task, idx := h.findTask(update.ID) @@ -318,6 +372,9 @@ func (h *tasksHandler) updateTasks(_ context.Context, params UpdateTasksArgs) (* if h.allCompleted() { h.tasks.Clear() } + + h.save() + return &tools.ToolCallResult{Output: output.String(), Meta: h.tasks.All()}, nil } @@ -337,6 +394,8 @@ func (h *tasksHandler) allCompleted() bool { } func (h *tasksHandler) listTasks(_ context.Context, _ tools.ToolCall) (*tools.ToolCallResult, error) { + h.ensureLoaded() + var output strings.Builder var completed, inProgress, pending, blockedCount int h.tasks.Range(func(_ int, task Task) bool { @@ -387,6 +446,8 @@ func (h *tasksHandler) listTasks(_ context.Context, _ tools.ToolCall) (*tools.To } func (h *tasksHandler) addDependency(_ context.Context, params AddTaskDependencyArgs) (*tools.ToolCallResult, error) { + h.ensureLoaded() + task, idx := h.findTask(params.TaskID) if idx == -1 { return tools.ResultError(fmt.Sprintf("task not found: %s", params.TaskID)), nil @@ -436,6 +497,9 @@ func (h *tasksHandler) addDependency(_ context.Context, params AddTaskDependency }) } } + + h.save() + return &tools.ToolCallResult{ Output: fmt.Sprintf("Added dependency: %s is now blocked by %s", params.TaskID, strings.Join(added, ", ")), Meta: h.tasks.All(), @@ -443,6 +507,8 @@ func (h *tasksHandler) addDependency(_ context.Context, params AddTaskDependency } func (h *tasksHandler) removeDependency(_ context.Context, params RemoveTaskDependencyArgs) (*tools.ToolCallResult, error) { + h.ensureLoaded() + task, idx := h.findTask(params.TaskID) if idx == -1 { return tools.ResultError(fmt.Sprintf("task not found: %s", params.TaskID)), nil @@ -484,6 +550,9 @@ func (h *tasksHandler) removeDependency(_ context.Context, params RemoveTaskDepe }) } } + + h.save() + return &tools.ToolCallResult{ Output: fmt.Sprintf("Removed dependency: %s is no longer blocked by %s", params.TaskID, strings.Join(removed, ", ")), Meta: h.tasks.All(), @@ -491,6 +560,8 @@ func (h *tasksHandler) removeDependency(_ context.Context, params RemoveTaskDepe } func (h *tasksHandler) getBlockedTasks(_ context.Context, params GetBlockedTasksArgs) (*tools.ToolCallResult, error) { + h.ensureLoaded() + var output strings.Builder output.WriteString("Blocked tasks:\n") found := false diff --git a/pkg/tools/builtin/tasks_store.go b/pkg/tools/builtin/tasks_store.go new file mode 100644 index 000000000..bb76c5890 --- /dev/null +++ b/pkg/tools/builtin/tasks_store.go @@ -0,0 +1,133 @@ +package builtin + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/docker/cagent/pkg/paths" +) + +// TaskListFile represents the JSON structure for persisted task lists +type TaskListFile struct { + Version int `json:"version"` + Tasks []Task `json:"tasks"` +} + +const taskListFileVersion = 1 + +// TaskStore defines the interface for task persistence +type TaskStore interface { + // Load loads tasks from the store. Returns empty slice if not found. + Load() ([]Task, error) + // Save persists tasks to the store. + Save(tasks []Task) error +} + +// FileTaskStore implements TaskStore using a JSON file +type FileTaskStore struct { + listID string + baseDir string + mu sync.RWMutex +} + +// NewFileTaskStore creates a new file-based task store +func NewFileTaskStore(listID string) *FileTaskStore { + return &FileTaskStore{ + listID: listID, + baseDir: paths.GetTasksDir(), + } +} + +// NewFileTaskStoreWithDir creates a file-based task store with a custom directory (for testing) +func NewFileTaskStoreWithDir(listID, baseDir string) *FileTaskStore { + return &FileTaskStore{ + listID: listID, + baseDir: baseDir, + } +} + +func (s *FileTaskStore) filePath() string { + // Sanitize listID to be safe as filename + safeID := filepath.Base(s.listID) + return filepath.Join(s.baseDir, safeID+".json") +} + +// Load loads tasks from the JSON file +func (s *FileTaskStore) Load() ([]Task, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + path := s.filePath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist yet - return empty list + return []Task{}, nil + } + return nil, fmt.Errorf("reading task file: %w", err) + } + + var file TaskListFile + if err := json.Unmarshal(data, &file); err != nil { + return nil, fmt.Errorf("parsing task file: %w", err) + } + + return file.Tasks, nil +} + +// Save persists tasks to the JSON file +func (s *FileTaskStore) Save(tasks []Task) error { + s.mu.Lock() + defer s.mu.Unlock() + + path := s.filePath() + + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("creating tasks directory: %w", err) + } + + file := TaskListFile{ + Version: taskListFileVersion, + Tasks: tasks, + } + + data, err := json.MarshalIndent(file, "", " ") + if err != nil { + return fmt.Errorf("marshaling tasks: %w", err) + } + + // Write atomically using temp file + rename + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o600); err != nil { + return fmt.Errorf("writing task file: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) // Clean up on failure + return fmt.Errorf("renaming task file: %w", err) + } + + return nil +} + +// MemoryTaskStore implements TaskStore with in-memory storage only (no persistence) +// Used when no listID is provided +type MemoryTaskStore struct{} + +func NewMemoryTaskStore() *MemoryTaskStore { + return &MemoryTaskStore{} +} + +func (s *MemoryTaskStore) Load() ([]Task, error) { + return []Task{}, nil +} + +func (s *MemoryTaskStore) Save(_ []Task) error { + // No-op for memory store + return nil +} diff --git a/pkg/tools/builtin/tasks_store_test.go b/pkg/tools/builtin/tasks_store_test.go new file mode 100644 index 000000000..12046ed95 --- /dev/null +++ b/pkg/tools/builtin/tasks_store_test.go @@ -0,0 +1,268 @@ +package builtin + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/cagent/pkg/tools" +) + +func TestFileTaskStore_SaveAndLoad(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := NewFileTaskStoreWithDir("test-project", tmpDir) + + // Initially empty + tasks, err := store.Load() + require.NoError(t, err) + assert.Empty(t, tasks) + + // Save some tasks + tasksToSave := []Task{ + {ID: "task_1", Description: "First task", Status: "pending"}, + {ID: "task_2", Description: "Second task", Status: "in-progress", BlockedBy: []string{"task_1"}}, + } + err = store.Save(tasksToSave) + require.NoError(t, err) + + // Load them back + loaded, err := store.Load() + require.NoError(t, err) + require.Len(t, loaded, 2) + assert.Equal(t, "task_1", loaded[0].ID) + assert.Equal(t, "task_2", loaded[1].ID) + assert.Equal(t, []string{"task_1"}, loaded[1].BlockedBy) +} + +func TestFileTaskStore_FileCreatedOnSave(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := NewFileTaskStoreWithDir("my-project", tmpDir) + expectedPath := filepath.Join(tmpDir, "my-project.json") + + // File should not exist yet + _, err := os.Stat(expectedPath) + assert.True(t, os.IsNotExist(err)) + + // Save creates the file + err = store.Save([]Task{{ID: "task_1", Description: "Test", Status: "pending"}}) + require.NoError(t, err) + + // File should now exist + _, err = os.Stat(expectedPath) + assert.NoError(t, err) +} + +func TestFileTaskStore_LoadNonExistentFile(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := NewFileTaskStoreWithDir("nonexistent", tmpDir) + + // Should return empty list, not error + tasks, err := store.Load() + require.NoError(t, err) + assert.Empty(t, tasks) +} + +func TestFileTaskStore_SanitizesListID(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + // Try to use path traversal - should be sanitized + store := NewFileTaskStoreWithDir("../../../etc/passwd", tmpDir) + + err := store.Save([]Task{{ID: "task_1", Description: "Test", Status: "pending"}}) + require.NoError(t, err) + + // File should be created in tmpDir with sanitized name, not elsewhere + expectedPath := filepath.Join(tmpDir, "passwd.json") + _, err = os.Stat(expectedPath) + assert.NoError(t, err) +} + +func TestMemoryTaskStore_NoOp(t *testing.T) { + t.Parallel() + + store := NewMemoryTaskStore() + + // Load always returns empty + tasks, err := store.Load() + require.NoError(t, err) + assert.Empty(t, tasks) + + // Save is a no-op + err = store.Save([]Task{{ID: "task_1", Description: "Test", Status: "pending"}}) + require.NoError(t, err) + + // Still empty after save + tasks, err = store.Load() + require.NoError(t, err) + assert.Empty(t, tasks) +} + +func TestTasksToolWithStore_Persistence(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + // Create first tool instance and add a task + store1 := NewFileTaskStoreWithDir("persistent-test", tmpDir) + tool1 := NewTasksToolWithStore(store1) + + _, err := tool1.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Persistent task", + }) + require.NoError(t, err) + + // Create second tool instance with same store ID + store2 := NewFileTaskStoreWithDir("persistent-test", tmpDir) + tool2 := NewTasksToolWithStore(store2) + + // Should load the task from the first instance + result, err := tool2.handler.listTasks(t.Context(), tools.ToolCall{}) + require.NoError(t, err) + assert.Contains(t, result.Output, "Persistent task") +} + +func TestTasksToolWithStore_LazyLoading(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + // Pre-populate a task file + store := NewFileTaskStoreWithDir("lazy-test", tmpDir) + err := store.Save([]Task{ + {ID: "task_1", Description: "Pre-existing task", Status: "pending"}, + }) + require.NoError(t, err) + + // Create tool - should not load yet + tool := NewTasksToolWithStore(NewFileTaskStoreWithDir("lazy-test", tmpDir)) + assert.False(t, tool.handler.loaded) + + // First operation triggers load + result, err := tool.handler.listTasks(t.Context(), tools.ToolCall{}) + require.NoError(t, err) + assert.True(t, tool.handler.loaded) + assert.Contains(t, result.Output, "Pre-existing task") +} + +func TestTasksToolWithStore_PersistsDependencies(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + // Create tasks with dependencies + store1 := NewFileTaskStoreWithDir("deps-test", tmpDir) + tool1 := NewTasksToolWithStore(store1) + + // Create first task + _, err := tool1.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Setup database", + }) + require.NoError(t, err) + + // Create second task that depends on first + _, err = tool1.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Run migrations", + BlockedBy: []string{"task_1"}, + }) + require.NoError(t, err) + + // Load in new instance + store2 := NewFileTaskStoreWithDir("deps-test", tmpDir) + tool2 := NewTasksToolWithStore(store2) + + result, err := tool2.handler.listTasks(t.Context(), tools.ToolCall{}) + require.NoError(t, err) + assert.Contains(t, result.Output, "Setup database") + assert.Contains(t, result.Output, "Run migrations") + assert.Contains(t, result.Output, "blocked by") +} + +func TestTasksToolWithStore_PersistsStatusChanges(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + // Create and complete a task + store1 := NewFileTaskStoreWithDir("status-test", tmpDir) + tool1 := NewTasksToolWithStore(store1) + + _, err := tool1.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Task to complete", + }) + require.NoError(t, err) + + _, err = tool1.handler.updateTasks(t.Context(), UpdateTasksArgs{ + Updates: []TaskUpdate{{ID: "task_1", Status: "in-progress"}}, + }) + require.NoError(t, err) + + // Load in new instance - should see in-progress status + store2 := NewFileTaskStoreWithDir("status-test", tmpDir) + tool2 := NewTasksToolWithStore(store2) + + result, err := tool2.handler.listTasks(t.Context(), tools.ToolCall{}) + require.NoError(t, err) + assert.Contains(t, result.Output, "■") // in-progress icon + assert.Contains(t, result.Output, "1 in progress") +} + +func TestTasksToolWithStore_ClearsOnAllCompleted(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + store := NewFileTaskStoreWithDir("clear-test", tmpDir) + tool := NewTasksToolWithStore(store) + + // Create and complete a task + _, err := tool.handler.createTask(t.Context(), CreateTaskArgs{ + Description: "Single task", + }) + require.NoError(t, err) + + _, err = tool.handler.updateTasks(t.Context(), UpdateTasksArgs{ + Updates: []TaskUpdate{{ID: "task_1", Status: "completed"}}, + }) + require.NoError(t, err) + + // Load in new instance - should be empty (cleared when all completed) + store2 := NewFileTaskStoreWithDir("clear-test", tmpDir) + tool2 := NewTasksToolWithStore(store2) + + result, err := tool2.handler.listTasks(t.Context(), tools.ToolCall{}) + require.NoError(t, err) + assert.Contains(t, result.Output, "No tasks") +} + +func TestFileTaskStore_AtomicWrite(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := NewFileTaskStoreWithDir("atomic-test", tmpDir) + + // Save initial tasks + err := store.Save([]Task{ + {ID: "task_1", Description: "Initial", Status: "pending"}, + }) + require.NoError(t, err) + + // Verify no .tmp file left behind + tmpFile := filepath.Join(tmpDir, "atomic-test.json.tmp") + _, err = os.Stat(tmpFile) + assert.True(t, os.IsNotExist(err), "temp file should not exist after save") + + // Verify main file exists + mainFile := filepath.Join(tmpDir, "atomic-test.json") + _, err = os.Stat(mainFile) + assert.NoError(t, err, "main file should exist") +} diff --git a/pkg/tui/components/tool/taskstool/sidebar.go b/pkg/tui/components/tool/taskstool/sidebar.go index 5a0b6e61a..86920ec9b 100644 --- a/pkg/tui/components/tool/taskstool/sidebar.go +++ b/pkg/tui/components/tool/taskstool/sidebar.go @@ -158,22 +158,26 @@ func (c *SidebarComponent) renderTab(title, content string) string { func (c *SidebarComponent) getBlockerDescriptions(blockerIDs []string) []string { result := make([]string, 0, len(blockerIDs)) for _, id := range blockerIDs { - found := false - for _, task := range c.tasks { - if task.ID == id { - // Truncate description to keep it short - desc := task.Description - if len(desc) > 20 { - desc = desc[:17] + "..." - } - result = append(result, desc) - found = true - break - } - } - if !found { - result = append(result, id) // Fallback to ID if not found + desc := c.findTaskDescription(id) + if desc == "" { + desc = id // Fallback to ID if not found } + result = append(result, desc) } return result } + +// findTaskDescription finds and returns a truncated description for a task ID +func (c *SidebarComponent) findTaskDescription(id string) string { + for _, task := range c.tasks { + if task.ID != id { + continue + } + desc := task.Description + if len(desc) > 20 { + desc = desc[:17] + "..." + } + return desc + } + return "" +}