diff --git a/packages/api/src/data/repositories/activity-stream.repository.ts b/packages/api/src/data/repositories/activity-stream.repository.ts index 280b4fc1..9e4f3770 100644 --- a/packages/api/src/data/repositories/activity-stream.repository.ts +++ b/packages/api/src/data/repositories/activity-stream.repository.ts @@ -45,6 +45,7 @@ export interface Activity { isDm: boolean; artifactId: string | null; childSessionId: string | null; + taskGroupId: string | null; createdAt: Date; completedAt: Date | null; durationMs: number | null; @@ -451,6 +452,7 @@ export class ActivityStreamRepository { isDm: row.is_dm ?? true, artifactId: row.artifact_id, childSessionId: row.child_session_id, + taskGroupId: row.task_group_id ?? null, createdAt: new Date(row.created_at), completedAt: row.completed_at ? new Date(row.completed_at) : null, durationMs: row.duration_ms, diff --git a/packages/api/src/data/repositories/project-tasks.repository.ts b/packages/api/src/data/repositories/project-tasks.repository.ts index eeb1ac84..631f6ba3 100644 --- a/packages/api/src/data/repositories/project-tasks.repository.ts +++ b/packages/api/src/data/repositories/project-tasks.repository.ts @@ -28,6 +28,8 @@ export interface ProjectTask { task_order?: number | null; due_date?: string | null; metadata?: Record; + outcome?: string | null; + outcome_reason?: string | null; created_at: string; updated_at: string; } @@ -53,6 +55,9 @@ export interface UpdateProjectTaskInput { priority?: TaskPriority; tags?: string[]; blocked_by?: string[]; + outcome?: string; + outcome_reason?: string; + completed_at?: string | null; } export class ProjectTasksRepository { @@ -230,7 +235,21 @@ export class ProjectTasksRepository { * Mark task as completed */ async completeTask(id: string): Promise { - return this.update(id, { status: 'completed' }); + return this.update(id, { status: 'completed', outcome: 'completed' }); + } + + async closeTask( + id: string, + outcome: 'completed' | 'skipped' | 'blocked' | 'failed', + reason?: string + ): Promise { + const status = outcome === 'completed' ? 'completed' : 'blocked'; + return this.update(id, { + status, + outcome, + outcome_reason: reason, + completed_at: new Date().toISOString(), + }); } /** @@ -247,6 +266,18 @@ export class ProjectTasksRepository { return this.update(id, { status: 'pending' }); } + async findByGroupId(groupId: string): Promise { + const { data, error } = await this.client + .from('tasks') + .select('*') + .eq('task_group_id', groupId) + .order('task_order', { ascending: true, nullsFirst: false }) + .order('created_at', { ascending: true }); + + if (error) throw new Error(`Failed to get group tasks: ${error.message}`); + return (data || []) as unknown as ProjectTask[]; + } + /** * Archive a task (soft delete). Tasks are never hard-deleted — * the execution log must be reproducible. diff --git a/packages/api/src/data/repositories/task-groups.repository.ts b/packages/api/src/data/repositories/task-groups.repository.ts index e2970697..2d7da135 100644 --- a/packages/api/src/data/repositories/task-groups.repository.ts +++ b/packages/api/src/data/repositories/task-groups.repository.ts @@ -48,6 +48,10 @@ export interface TaskGroup { strategy_started_at: string | null; strategy_paused_at: string | null; owner_agent_id: string | null; + group_number: number; + slug: string | null; + outcome: string | null; + conclusion: string | null; created_at: string; updated_at: string; } @@ -118,6 +122,8 @@ export interface UpdateTaskGroupInput { strategy_started_at?: string | null; strategy_paused_at?: string | null; owner_agent_id?: string | null; + outcome?: string | null; + conclusion?: string | null; } export interface ListTaskGroupsOptions { @@ -264,6 +270,8 @@ export class TaskGroupsRepository { if (input.strategy_paused_at !== undefined) updates.strategy_paused_at = input.strategy_paused_at; if (input.owner_agent_id !== undefined) updates.owner_agent_id = input.owner_agent_id; + if (input.outcome !== undefined) updates.outcome = input.outcome; + if (input.conclusion !== undefined) updates.conclusion = input.conclusion; const { data, error } = await this.client .from('task_groups' as never) diff --git a/packages/api/src/data/supabase/types.ts b/packages/api/src/data/supabase/types.ts index c9cf7701..2b7066ec 100644 --- a/packages/api/src/data/supabase/types.ts +++ b/packages/api/src/data/supabase/types.ts @@ -3131,13 +3131,76 @@ export type Database = { }, ]; }; + task_group_comments: { + Row: { + agent_id: string | null; + comment_type: string; + content: string; + created_at: string; + created_by_identity_id: string | null; + deleted_at: string | null; + id: string; + metadata: Json; + task_group_id: string; + updated_at: string; + user_id: string; + }; + Insert: { + agent_id?: string | null; + comment_type?: string; + content: string; + created_at?: string; + created_by_identity_id?: string | null; + deleted_at?: string | null; + id?: string; + metadata?: Json; + task_group_id: string; + updated_at?: string; + user_id: string; + }; + Update: { + agent_id?: string | null; + comment_type?: string; + content?: string; + created_at?: string; + created_by_identity_id?: string | null; + deleted_at?: string | null; + id?: string; + metadata?: Json; + task_group_id?: string; + updated_at?: string; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'task_group_comments_created_by_identity_id_fkey'; + columns: ['created_by_identity_id']; + referencedRelation: 'agent_identities'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'task_group_comments_task_group_id_fkey'; + columns: ['task_group_id']; + referencedRelation: 'task_groups'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'task_group_comments_user_id_fkey'; + columns: ['user_id']; + referencedRelation: 'users'; + referencedColumns: ['id']; + }, + ]; + }; task_groups: { Row: { autonomous: boolean; + conclusion: string | null; context_summary: string | null; created_at: string; current_task_index: number; description: string | null; + group_number: number; id: string; identity_id: string | null; instructions: string | null; @@ -3145,6 +3208,7 @@ export type Database = { max_sessions: number | null; metadata: Json; next_run_after: string | null; + outcome: string | null; output_status: string | null; output_target: string | null; owner_agent_id: string | null; @@ -3152,6 +3216,7 @@ export type Database = { priority: string; project_id: string | null; sessions_used: number; + slug: string | null; status: string; strategy: string | null; strategy_config: Json; @@ -3166,10 +3231,12 @@ export type Database = { }; Insert: { autonomous?: boolean; + conclusion?: string | null; context_summary?: string | null; created_at?: string; current_task_index?: number; description?: string | null; + group_number: number; id?: string; identity_id?: string | null; instructions?: string | null; @@ -3177,6 +3244,7 @@ export type Database = { max_sessions?: number | null; metadata?: Json; next_run_after?: string | null; + outcome?: string | null; output_status?: string | null; output_target?: string | null; owner_agent_id?: string | null; @@ -3184,6 +3252,7 @@ export type Database = { priority?: string; project_id?: string | null; sessions_used?: number; + slug?: string | null; status?: string; strategy?: string | null; strategy_config?: Json; @@ -3198,10 +3267,12 @@ export type Database = { }; Update: { autonomous?: boolean; + conclusion?: string | null; context_summary?: string | null; created_at?: string; current_task_index?: number; description?: string | null; + group_number?: number; id?: string; identity_id?: string | null; instructions?: string | null; @@ -3209,6 +3280,7 @@ export type Database = { max_sessions?: number | null; metadata?: Json; next_run_after?: string | null; + outcome?: string | null; output_status?: string | null; output_target?: string | null; owner_agent_id?: string | null; @@ -3216,6 +3288,7 @@ export type Database = { priority?: string; project_id?: string | null; sessions_used?: number; + slug?: string | null; status?: string; strategy?: string | null; strategy_config?: Json; @@ -3259,6 +3332,8 @@ export type Database = { due_date: string | null; id: string; metadata: Json; + outcome: string | null; + outcome_reason: string | null; priority: string | null; project_id: string | null; status: string; @@ -3278,6 +3353,8 @@ export type Database = { due_date?: string | null; id?: string; metadata?: Json; + outcome?: string | null; + outcome_reason?: string | null; priority?: string | null; project_id?: string | null; status?: string; @@ -3297,6 +3374,8 @@ export type Database = { due_date?: string | null; id?: string; metadata?: Json; + outcome?: string | null; + outcome_reason?: string | null; priority?: string | null; project_id?: string | null; status?: string; diff --git a/packages/api/src/mcp/tools/activity-stream-handlers.ts b/packages/api/src/mcp/tools/activity-stream-handlers.ts index ed0b27bf..04b186b2 100644 --- a/packages/api/src/mcp/tools/activity-stream-handlers.ts +++ b/packages/api/src/mcp/tools/activity-stream-handlers.ts @@ -322,6 +322,7 @@ export async function handleGetActivity(args: unknown, dataComposer: DataCompose contactId: a.contactId, sessionId: a.sessionId, payload: a.payload ?? undefined, + taskGroupId: a.taskGroupId ?? undefined, status: a.status, createdAt: a.createdAt.toISOString(), completedAt: a.completedAt?.toISOString(), diff --git a/packages/api/src/mcp/tools/index.ts b/packages/api/src/mcp/tools/index.ts index 12c24a79..d798d4ef 100644 --- a/packages/api/src/mcp/tools/index.ts +++ b/packages/api/src/mcp/tools/index.ts @@ -26,9 +26,17 @@ import { handleCreateTaskGroup, handleListTaskGroups, handleUpdateTaskGroup, + handleAddTaskGroupComment, + handleListTaskGroupComments, + handleCloseTask, + handleCloseTaskGroup, createTaskGroupSchema, listTaskGroupsSchema, updateTaskGroupSchema, + addTaskGroupCommentSchema, + listTaskGroupCommentsSchema, + closeTaskSchema, + closeTaskGroupSchema, } from './task-handlers'; import { handleSendResponse, handleGetPendingMessages, handleMarkRead } from './response-handlers'; @@ -1011,6 +1019,130 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId } ); + // Register add_task_group_comment tool + server.registerTool( + 'add_task_group_comment', + { + description: `Add a comment to a task group. Comments are attributed to the calling agent. Use for progress updates, status changes, or conclusion summaries. + +User can be identified by ONE of: userId, email, phone, or platform + platformId`, + inputSchema: addTaskGroupCommentSchema, + }, + async (args) => { + try { + return await handleAddTaskGroupComment(args, dataComposer); + } catch (error) { + logger.error('Error in add_task_group_comment:', error); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }), + }, + ], + isError: true, + }; + } + } + ); + + // Register list_task_group_comments tool + server.registerTool( + 'list_task_group_comments', + { + description: `List comments on a task group. Returns comments in chronological order. Filter by comment type (comment, conclusion, status_change). + +User can be identified by ONE of: userId, email, phone, or platform + platformId`, + inputSchema: listTaskGroupCommentsSchema, + }, + async (args) => { + try { + return await handleListTaskGroupComments(args, dataComposer); + } catch (error) { + logger.error('Error in list_task_group_comments:', error); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }), + }, + ], + isError: true, + }; + } + } + ); + + // Register close_task tool + server.registerTool( + 'close_task', + { + description: `Close a task with an outcome. Use instead of complete_task when the task wasn't simply completed — e.g., it was skipped, blocked, or failed. Advances the strategy if one is active. + +Outcomes: completed (done), skipped (not needed), blocked (cannot proceed), failed (attempted but failed). + +User can be identified by ONE of: userId, email, phone, or platform + platformId`, + inputSchema: closeTaskSchema, + }, + async (args) => { + try { + return await handleCloseTask(args, dataComposer); + } catch (error) { + logger.error('Error in close_task:', error); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }), + }, + ], + isError: true, + }; + } + } + ); + + // Register close_task_group tool + server.registerTool( + 'close_task_group', + { + description: `Close a task group with an outcome and conclusion. Posts a conclusion comment and sets the group outcome. Auto-generates a summary if no conclusion is provided. Cancels any active strategy. + +Outcomes: completed (all done), partial (some done), abandoned (gave up), failed (critical failure). + +User can be identified by ONE of: userId, email, phone, or platform + platformId`, + inputSchema: closeTaskGroupSchema, + }, + async (args) => { + try { + return await handleCloseTaskGroup(args, dataComposer); + } catch (error) { + logger.error('Error in close_task_group:', error); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }), + }, + ], + isError: true, + }; + } + } + ); + // ===================================================== // TASK GROUP TOOLS // ===================================================== diff --git a/packages/api/src/mcp/tools/task-handlers.integration.test.ts b/packages/api/src/mcp/tools/task-handlers.integration.test.ts index a324aebf..6631029a 100644 --- a/packages/api/src/mcp/tools/task-handlers.integration.test.ts +++ b/packages/api/src/mcp/tools/task-handlers.integration.test.ts @@ -258,3 +258,250 @@ describe.skipIf(!canRun)('handleUpdateTaskGroup (integration)', () => { expect(data?.identity_id).toBe(identity.id); }); }); + +// ===================================================== +// handleCloseTask (integration) +// ===================================================== + +describe.skipIf(!canRun)('handleCloseTask (integration)', () => { + let client: SupabaseClient; + let dc: any; + const createdTaskIds: string[] = []; + const createdGroupIds: string[] = []; + + beforeAll(async () => { + client = createClient(SUPABASE_URL!, SUPABASE_KEY!, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + const { ProjectTasksRepository } = + await import('../../data/repositories/project-tasks.repository'); + const { TaskGroupsRepository } = await import('../../data/repositories/task-groups.repository'); + + dc = { + getClient: () => client, + repositories: { + tasks: new ProjectTasksRepository(client), + taskGroups: new TaskGroupsRepository(client), + projects: { findById: vi.fn().mockResolvedValue(null) }, + memory: { remember: vi.fn().mockResolvedValue({ id: 'mem-1' }) }, + activityStream: { logActivity: vi.fn().mockResolvedValue({ id: 'act-1' }) }, + }, + }; + }, 15_000); + + afterAll(async () => { + if (!client) return; + if (createdTaskIds.length > 0) await client.from('tasks').delete().in('id', createdTaskIds); + if (createdGroupIds.length > 0) + await client.from('task_groups').delete().in('id', createdGroupIds); + }, 10_000); + + async function seedTask(overrides: Record = {}): Promise { + const task = await dc.repositories.tasks.create({ + user_id: TEST_USER_ID!, + title: `__close_integration_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + description: 'Integration test — safe to delete', + status: 'in_progress', + priority: 'medium', + tags: ['__test'], + ...overrides, + }); + createdTaskIds.push(task.id); + return task.id; + } + + it('closes a task with completed outcome and persists to DB', async () => { + const { handleCloseTask } = await import('./task-handlers'); + const taskId = await seedTask(); + + const response = await handleCloseTask( + { userId: TEST_USER_ID!, taskId, outcome: 'completed', summary: 'Shipped!' }, + dc + ); + expect(response.isError).toBeFalsy(); + + const body = JSON.parse(response.content[0].text); + expect(body.success).toBe(true); + expect(body.task.outcome).toBe('completed'); + + const { data } = await client + .from('tasks') + .select('status, outcome, outcome_reason, completed_at') + .eq('id', taskId) + .single(); + + expect(data?.status).toBe('completed'); + expect(data?.outcome).toBe('completed'); + expect(data?.completed_at).not.toBeNull(); + }); + + it('closes a task with skipped outcome and reason', async () => { + const { handleCloseTask } = await import('./task-handlers'); + const taskId = await seedTask(); + + const response = await handleCloseTask( + { userId: TEST_USER_ID!, taskId, outcome: 'skipped', reason: 'Not needed' }, + dc + ); + expect(response.isError).toBeFalsy(); + + const { data } = await client + .from('tasks') + .select('status, outcome, outcome_reason') + .eq('id', taskId) + .single(); + + expect(data?.status).toBe('blocked'); + expect(data?.outcome).toBe('skipped'); + expect(data?.outcome_reason).toBe('Not needed'); + }); + + it('refuses to close a task owned by a different user', async () => { + const { handleCloseTask } = await import('./task-handlers'); + const taskId = await seedTask(); + + const response = await handleCloseTask( + { userId: '00000000-0000-0000-0000-000000000999', taskId, outcome: 'completed' }, + dc + ); + + expect(response.isError).toBe(true); + const body = JSON.parse(response.content[0].text); + expect(['User not found', 'Task does not belong to this user']).toContain(body.error); + }); +}); + +// ===================================================== +// handleCloseTaskGroup (integration) +// ===================================================== + +describe.skipIf(!canRun)('handleCloseTaskGroup (integration)', () => { + let client: SupabaseClient; + let dc: any; + const createdGroupIds: string[] = []; + const createdTaskIds: string[] = []; + + beforeAll(async () => { + client = createClient(SUPABASE_URL!, SUPABASE_KEY!, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + const { ProjectTasksRepository } = + await import('../../data/repositories/project-tasks.repository'); + const { TaskGroupsRepository } = await import('../../data/repositories/task-groups.repository'); + + dc = { + getClient: () => client, + repositories: { + tasks: new ProjectTasksRepository(client), + taskGroups: new TaskGroupsRepository(client), + projects: { findById: vi.fn().mockResolvedValue(null) }, + activityStream: { logActivity: vi.fn().mockResolvedValue({ id: 'act-1' }) }, + }, + }; + }, 15_000); + + afterAll(async () => { + if (!client) return; + if (createdTaskIds.length > 0) await client.from('tasks').delete().in('id', createdTaskIds); + if (createdGroupIds.length > 0) { + await client.from('task_group_comments').delete().in('task_group_id', createdGroupIds); + await client.from('task_groups').delete().in('id', createdGroupIds); + } + }, 10_000); + + async function seedGroup(): Promise { + const group = await dc.repositories.taskGroups.create({ + user_id: TEST_USER_ID!, + title: `__close_group_integration_${Date.now()}`, + description: 'Integration test — safe to delete', + priority: 'low', + tags: ['__test'], + }); + createdGroupIds.push(group.id); + return group.id; + } + + async function seedTask(groupId: string, status = 'completed'): Promise { + const task = await dc.repositories.tasks.create({ + user_id: TEST_USER_ID!, + title: `__group_task_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + status, + priority: 'medium', + tags: ['__test'], + task_group_id: groupId, + }); + createdTaskIds.push(task.id); + return task.id; + } + + it('closes a group with auto-generated conclusion and persists to DB', async () => { + const { handleCloseTaskGroup } = await import('./task-handlers'); + const groupId = await seedGroup(); + await seedTask(groupId, 'completed'); + await seedTask(groupId, 'completed'); + await seedTask(groupId, 'pending'); + + const response = await handleCloseTaskGroup( + { userId: TEST_USER_ID!, groupId, outcome: 'completed' }, + dc + ); + expect(response.isError).toBeFalsy(); + + const body = JSON.parse(response.content[0].text); + expect(body.success).toBe(true); + expect(body.group.outcome).toBe('completed'); + expect(body.group.conclusion).toContain('2/3 tasks completed'); + expect(body.group.stats.total).toBe(3); + expect(body.group.stats.completed).toBe(2); + + const { data } = await client + .from('task_groups') + .select('status, outcome, conclusion') + .eq('id', groupId) + .single(); + + expect(data?.status).toBe('completed'); + expect(data?.outcome).toBe('completed'); + expect(data?.conclusion).toContain('2/3 tasks completed'); + }); + + it('posts a conclusion comment when closing', async () => { + const { handleCloseTaskGroup } = await import('./task-handlers'); + const groupId = await seedGroup(); + await seedTask(groupId, 'completed'); + + await handleCloseTaskGroup( + { userId: TEST_USER_ID!, groupId, outcome: 'completed', conclusion: 'All done!' }, + dc + ); + + const { data: comments } = await client + .from('task_group_comments') + .select('content, comment_type') + .eq('task_group_id', groupId) + .eq('comment_type', 'conclusion'); + + expect(comments).toHaveLength(1); + expect(comments![0].content).toBe('All done!'); + }); + + it('rejects closing an already-completed group', async () => { + const { handleCloseTaskGroup } = await import('./task-handlers'); + const groupId = await seedGroup(); + + // Close it first + await handleCloseTaskGroup({ userId: TEST_USER_ID!, groupId, outcome: 'completed' }, dc); + + // Try closing again + const response = await handleCloseTaskGroup( + { userId: TEST_USER_ID!, groupId, outcome: 'abandoned' }, + dc + ); + + expect(response.isError).toBe(true); + const body = JSON.parse(response.content[0].text); + expect(body.error).toBe('Task group is already completed'); + }); +}); diff --git a/packages/api/src/mcp/tools/task-handlers.test.ts b/packages/api/src/mcp/tools/task-handlers.test.ts index 4b6189e6..5b200fad 100644 --- a/packages/api/src/mcp/tools/task-handlers.test.ts +++ b/packages/api/src/mcp/tools/task-handlers.test.ts @@ -15,6 +15,10 @@ import { handleCreateTaskGroup, handleListTaskGroups, handleUpdateTaskGroup, + handleCloseTask, + handleCloseTaskGroup, + handleAddTaskGroupComment, + handleListTaskGroupComments, } from './task-handlers'; // ===================================================== @@ -64,11 +68,13 @@ function createMockDataComposer() { const mockTasksRepo = { create: vi.fn(), findById: vi.fn(), + findByGroupId: vi.fn(), listByUser: vi.fn(), listActiveTasks: vi.fn(), update: vi.fn(), startTask: vi.fn(), completeTask: vi.fn(), + closeTask: vi.fn(), getProjectStats: vi.fn(), }; @@ -90,14 +96,34 @@ function createMockDataComposer() { remember: vi.fn(), }; - // Minimal supabase client stub for identity/agent_identities lookups. - // Returns an empty result so `identityId` resolves to null by default. + const mockActivityStreamRepo = { + logActivity: vi.fn().mockResolvedValue({ id: 'activity-1' }), + }; + + // Minimal supabase client stub for identity/agent_identities lookups + // and task_group_comments inserts/queries. const identityChain = { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), in: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), single: vi.fn().mockResolvedValue({ data: null, error: null }), + insert: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { + id: 'comment-auto', + task_group_id: 'group-1', + content: '', + comment_type: 'conclusion', + created_at: '2026-04-29T10:00:00Z', + }, + error: null, + }), + }), + }), then: undefined as unknown, }; const mockClient = { @@ -111,6 +137,7 @@ function createMockDataComposer() { taskGroups: mockTaskGroupsRepo, projects: mockProjectsRepo, memory: mockMemoryRepo, + activityStream: mockActivityStreamRepo, }, }; } @@ -1869,3 +1896,667 @@ describe('handleListTaskGroups', () => { expect(dc.repositories.taskGroups.listByUser).not.toHaveBeenCalled(); }); }); + +// ===================================================== +// handleCloseTask +// ===================================================== + +describe('handleCloseTask', () => { + let dc: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + dc = createMockDataComposer(); + resolveUserMock.mockResolvedValue({ + user: { id: 'user-123' } as any, + resolvedBy: 'userId', + }); + }); + + it('closes a task with completed outcome', async () => { + dc.repositories.tasks.findById.mockResolvedValue({ + id: 'task-1', + user_id: 'user-123', + title: 'Build feature', + status: 'in_progress', + task_group_id: null, + }); + dc.repositories.tasks.closeTask.mockResolvedValue({ + id: 'task-1', + title: 'Build feature', + status: 'completed', + outcome: 'completed', + outcome_reason: null, + completed_at: '2026-04-29T10:00:00Z', + task_group_id: null, + }); + + const response = await handleCloseTask( + { userId: 'user-123', taskId: 'task-1', outcome: 'completed', summary: 'Done!' }, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBeFalsy(); + expect(data.success).toBe(true); + expect(data.task.outcome).toBe('completed'); + expect(data.task.completedAt).toBe('2026-04-29T10:00:00Z'); + expect(dc.repositories.tasks.closeTask).toHaveBeenCalledWith('task-1', 'completed', undefined); + }); + + it('closes a task with skipped outcome and reason', async () => { + dc.repositories.tasks.findById.mockResolvedValue({ + id: 'task-2', + user_id: 'user-123', + title: 'Optional cleanup', + status: 'pending', + task_group_id: null, + }); + dc.repositories.tasks.closeTask.mockResolvedValue({ + id: 'task-2', + title: 'Optional cleanup', + status: 'blocked', + outcome: 'skipped', + outcome_reason: 'Not needed after refactor', + completed_at: '2026-04-29T10:00:00Z', + task_group_id: null, + }); + + const response = await handleCloseTask( + { + userId: 'user-123', + taskId: 'task-2', + outcome: 'skipped', + reason: 'Not needed after refactor', + }, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(data.task.outcome).toBe('skipped'); + expect(data.task.outcomeReason).toBe('Not needed after refactor'); + expect(dc.repositories.tasks.closeTask).toHaveBeenCalledWith( + 'task-2', + 'skipped', + 'Not needed after refactor' + ); + }); + + it('logs task_completed activity for completed outcome', async () => { + dc.repositories.tasks.findById.mockResolvedValue({ + id: 'task-1', + user_id: 'user-123', + title: 'Build feature', + status: 'in_progress', + task_group_id: 'group-1', + }); + dc.repositories.tasks.closeTask.mockResolvedValue({ + id: 'task-1', + title: 'Build feature', + status: 'completed', + outcome: 'completed', + completed_at: '2026-04-29T10:00:00Z', + task_group_id: 'group-1', + }); + dc.repositories.taskGroups.findById.mockResolvedValue({ + id: 'group-1', + user_id: 'user-123', + strategy: null, + status: 'active', + }); + + await handleCloseTask( + { userId: 'user-123', taskId: 'task-1', outcome: 'completed', summary: 'Shipped it' }, + dc as any + ); + + expect(dc.repositories.activityStream.logActivity).toHaveBeenCalledWith( + expect.objectContaining({ + subtype: 'task_completed', + taskGroupId: 'group-1', + payload: expect.objectContaining({ + taskId: 'task-1', + outcome: 'completed', + summary: 'Shipped it', + }), + }) + ); + }); + + it('logs task_closed activity for non-completed outcomes', async () => { + dc.repositories.tasks.findById.mockResolvedValue({ + id: 'task-1', + user_id: 'user-123', + title: 'Broken thing', + status: 'in_progress', + task_group_id: null, + }); + dc.repositories.tasks.closeTask.mockResolvedValue({ + id: 'task-1', + title: 'Broken thing', + status: 'blocked', + outcome: 'failed', + completed_at: '2026-04-29T10:00:00Z', + task_group_id: null, + }); + + await handleCloseTask( + { userId: 'user-123', taskId: 'task-1', outcome: 'failed', reason: 'Dependency missing' }, + dc as any + ); + + expect(dc.repositories.activityStream.logActivity).toHaveBeenCalledWith( + expect.objectContaining({ + subtype: 'task_closed', + payload: expect.objectContaining({ + outcome: 'failed', + reason: 'Dependency missing', + }), + }) + ); + }); + + it('advances strategy when closing a task in a strategy group', async () => { + dc.repositories.tasks.findById.mockResolvedValue({ + id: 'task-1', + user_id: 'user-123', + title: 'Step 1', + status: 'in_progress', + task_group_id: 'group-1', + }); + dc.repositories.tasks.closeTask.mockResolvedValue({ + id: 'task-1', + title: 'Step 1', + status: 'completed', + outcome: 'completed', + completed_at: '2026-04-29T10:00:00Z', + task_group_id: 'group-1', + }); + dc.repositories.taskGroups.findById.mockResolvedValue({ + id: 'group-1', + user_id: 'user-123', + strategy: 'persistence', + status: 'active', + strategy_config: {}, + }); + + const response = await handleCloseTask( + { userId: 'user-123', taskId: 'task-1', outcome: 'completed' }, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(dc.repositories.taskGroups.findById).toHaveBeenCalledWith('group-1'); + }); + + it('returns error when task not found', async () => { + dc.repositories.tasks.findById.mockResolvedValue(null); + + const response = await handleCloseTask( + { userId: 'user-123', taskId: 'task-404', outcome: 'completed' }, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('Task not found'); + expect(dc.repositories.tasks.closeTask).not.toHaveBeenCalled(); + }); + + it('returns error when task belongs to different user', async () => { + dc.repositories.tasks.findById.mockResolvedValue({ + id: 'task-1', + user_id: 'other-user-456', + title: 'Not yours', + }); + + const response = await handleCloseTask( + { userId: 'user-123', taskId: 'task-1', outcome: 'completed' }, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('Task does not belong to this user'); + }); + + it('returns error when user not found', async () => { + resolveUserMock.mockResolvedValue(null); + + const response = await handleCloseTask( + { userId: 'nonexistent', taskId: 'task-1', outcome: 'completed' }, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('User not found'); + }); + + it('succeeds even when activity logging fails', async () => { + dc.repositories.tasks.findById.mockResolvedValue({ + id: 'task-1', + user_id: 'user-123', + title: 'Build feature', + status: 'in_progress', + task_group_id: null, + }); + dc.repositories.tasks.closeTask.mockResolvedValue({ + id: 'task-1', + title: 'Build feature', + status: 'completed', + outcome: 'completed', + completed_at: '2026-04-29T10:00:00Z', + task_group_id: null, + }); + dc.repositories.activityStream.logActivity.mockRejectedValue(new Error('Activity DB error')); + + const response = await handleCloseTask( + { userId: 'user-123', taskId: 'task-1', outcome: 'completed' }, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + }); +}); + +// ===================================================== +// handleCloseTaskGroup +// ===================================================== + +describe('handleCloseTaskGroup', () => { + let dc: ReturnType; + + const activeGroup = { + id: 'group-1', + user_id: 'user-123', + title: 'Feature rollout', + status: 'active', + strategy: null, + strategy_config: {}, + }; + + const sampleTasks = [ + { id: 't-1', status: 'completed', outcome: 'completed', title: 'Task 1' }, + { id: 't-2', status: 'completed', outcome: 'completed', title: 'Task 2' }, + { id: 't-3', status: 'pending', outcome: null, title: 'Task 3' }, + { id: 't-4', status: 'blocked', outcome: 'skipped', title: 'Task 4' }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + dc = createMockDataComposer(); + resolveUserMock.mockResolvedValue({ + user: { id: 'user-123' } as any, + resolvedBy: 'userId', + }); + }); + + it('closes a group with completed outcome and auto-generated conclusion', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue(activeGroup); + dc.repositories.tasks.findByGroupId.mockResolvedValue(sampleTasks); + dc.repositories.taskGroups.update.mockResolvedValue({ + ...activeGroup, + status: 'completed', + outcome: 'completed', + }); + + const response = await handleCloseTaskGroup( + { userId: 'user-123', groupId: 'group-1', outcome: 'completed' }, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(data.group.outcome).toBe('completed'); + expect(data.group.conclusion).toContain('2/4 tasks completed'); + expect(data.group.conclusion).toContain('1 skipped'); + expect(data.group.conclusion).toContain('1 pending'); + expect(data.group.stats.total).toBe(4); + expect(data.group.stats.completed).toBe(2); + expect(data.group.stats.skipped).toBe(1); + expect(data.group.stats.pending).toBe(1); + }); + + it('uses provided conclusion instead of auto-generating', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue(activeGroup); + dc.repositories.tasks.findByGroupId.mockResolvedValue(sampleTasks); + dc.repositories.taskGroups.update.mockResolvedValue({ + ...activeGroup, + status: 'completed', + }); + + const response = await handleCloseTaskGroup( + { + userId: 'user-123', + groupId: 'group-1', + outcome: 'completed', + conclusion: 'Shipped successfully via PR #100', + }, + dc as any + ); + + const data = parseResponse(response); + expect(data.group.conclusion).toBe('Shipped successfully via PR #100'); + expect(dc.repositories.taskGroups.update).toHaveBeenCalledWith( + 'group-1', + expect.objectContaining({ + conclusion: 'Shipped successfully via PR #100', + }) + ); + }); + + it('sets status to cancelled for non-completed outcomes', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue(activeGroup); + dc.repositories.tasks.findByGroupId.mockResolvedValue(sampleTasks); + dc.repositories.taskGroups.update.mockResolvedValue({ + ...activeGroup, + status: 'cancelled', + outcome: 'abandoned', + }); + + await handleCloseTaskGroup( + { userId: 'user-123', groupId: 'group-1', outcome: 'abandoned' }, + dc as any + ); + + expect(dc.repositories.taskGroups.update).toHaveBeenCalledWith( + 'group-1', + expect.objectContaining({ status: 'cancelled', outcome: 'abandoned' }) + ); + }); + + it('posts a conclusion comment to task_group_comments', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue(activeGroup); + dc.repositories.tasks.findByGroupId.mockResolvedValue([ + { id: 't-1', status: 'completed', outcome: 'completed', title: 'Done' }, + ]); + dc.repositories.taskGroups.update.mockResolvedValue({ + ...activeGroup, + status: 'completed', + }); + + await handleCloseTaskGroup( + { userId: 'user-123', groupId: 'group-1', outcome: 'completed' }, + dc as any + ); + + const client = dc.getClient(); + expect(client.from).toHaveBeenCalledWith('task_group_comments'); + }); + + it('logs task_group_closed activity event', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue(activeGroup); + dc.repositories.tasks.findByGroupId.mockResolvedValue(sampleTasks); + dc.repositories.taskGroups.update.mockResolvedValue({ + ...activeGroup, + status: 'completed', + }); + + await handleCloseTaskGroup( + { userId: 'user-123', groupId: 'group-1', outcome: 'completed' }, + dc as any + ); + + expect(dc.repositories.activityStream.logActivity).toHaveBeenCalledWith( + expect.objectContaining({ + subtype: 'task_group_closed', + taskGroupId: 'group-1', + payload: expect.objectContaining({ + groupId: 'group-1', + groupTitle: 'Feature rollout', + outcome: 'completed', + stats: expect.objectContaining({ total: 4, completed: 2 }), + }), + }) + ); + }); + + it('rejects closing an already-completed group', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue({ + ...activeGroup, + status: 'completed', + }); + + const response = await handleCloseTaskGroup( + { userId: 'user-123', groupId: 'group-1', outcome: 'completed' }, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('Task group is already completed'); + expect(dc.repositories.taskGroups.update).not.toHaveBeenCalled(); + }); + + it('rejects closing an already-cancelled group', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue({ + ...activeGroup, + status: 'cancelled', + }); + + const response = await handleCloseTaskGroup( + { userId: 'user-123', groupId: 'group-1', outcome: 'abandoned' }, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('Task group is already cancelled'); + }); + + it('returns error when group not found', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue(null); + + const response = await handleCloseTaskGroup( + { userId: 'user-123', groupId: 'group-404', outcome: 'completed' }, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('Task group not found'); + }); + + it('returns error when group belongs to different user', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue({ + ...activeGroup, + user_id: 'other-user-456', + }); + + const response = await handleCloseTaskGroup( + { userId: 'user-123', groupId: 'group-1', outcome: 'completed' }, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('Task group does not belong to this user'); + }); + + it('handles group with zero tasks gracefully', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue(activeGroup); + dc.repositories.tasks.findByGroupId.mockResolvedValue([]); + dc.repositories.taskGroups.update.mockResolvedValue({ + ...activeGroup, + status: 'completed', + }); + + const response = await handleCloseTaskGroup( + { userId: 'user-123', groupId: 'group-1', outcome: 'completed' }, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(data.group.conclusion).toContain('0/0 tasks completed'); + expect(data.group.stats.total).toBe(0); + }); +}); + +// ===================================================== +// handleAddTaskGroupComment +// ===================================================== + +describe('handleAddTaskGroupComment', () => { + let dc: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + dc = createMockDataComposer(); + resolveUserMock.mockResolvedValue({ + user: { id: 'user-123' } as any, + resolvedBy: 'userId', + }); + }); + + it('adds a comment to a task group', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue({ + id: 'group-1', + user_id: 'user-123', + title: 'Feature X', + }); + // Mock the Supabase insert chain for task_group_comments + const insertChain = { + select: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ + data: { + id: 'comment-1', + task_group_id: 'group-1', + content: 'Progress update', + comment_type: 'comment', + created_at: '2026-04-29T10:00:00Z', + }, + error: null, + }), + }; + const fromChain = { + insert: vi.fn().mockReturnValue(insertChain), + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: null, error: null }), + }; + dc.getClient.mockReturnValue({ from: vi.fn().mockReturnValue(fromChain) }); + + const response = await handleAddTaskGroupComment( + { + userId: 'user-123', + groupId: 'group-1', + content: 'Progress update', + commentType: 'comment', + }, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(data.comment.content).toBe('Progress update'); + expect(data.comment.commentType).toBe('comment'); + }); + + it('returns error when group not found', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue(null); + + const response = await handleAddTaskGroupComment( + { userId: 'user-123', groupId: 'group-404', content: 'Hello' }, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('Task group not found'); + }); + + it('returns error when group belongs to different user', async () => { + dc.repositories.taskGroups.findById.mockResolvedValue({ + id: 'group-1', + user_id: 'other-user-456', + title: 'Not yours', + }); + + const response = await handleAddTaskGroupComment( + { userId: 'user-123', groupId: 'group-1', content: 'Hello' }, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('Task group does not belong to this user'); + }); +}); + +// ===================================================== +// handleListTaskGroupComments +// ===================================================== + +describe('handleListTaskGroupComments', () => { + let dc: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + dc = createMockDataComposer(); + resolveUserMock.mockResolvedValue({ + user: { id: 'user-123' } as any, + resolvedBy: 'userId', + }); + }); + + it('lists comments for a task group', async () => { + const queryChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue({ + data: [ + { + id: 'c-1', + task_group_id: 'group-1', + content: 'Started work', + comment_type: 'comment', + agent_id: 'wren', + created_at: '2026-04-29T09:00:00Z', + }, + { + id: 'c-2', + task_group_id: 'group-1', + content: 'All done', + comment_type: 'conclusion', + agent_id: 'wren', + created_at: '2026-04-29T10:00:00Z', + }, + ], + error: null, + }), + }; + dc.getClient.mockReturnValue({ from: vi.fn().mockReturnValue(queryChain) }); + + const response = await handleListTaskGroupComments( + { userId: 'user-123', groupId: 'group-1', limit: 50 }, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(data.count).toBe(2); + expect(data.comments[0].content).toBe('Started work'); + expect(data.comments[1].commentType).toBe('conclusion'); + }); + + it('returns error when user not found', async () => { + resolveUserMock.mockResolvedValue(null); + + const response = await handleListTaskGroupComments( + { userId: 'nonexistent', groupId: 'group-1', limit: 50 }, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('User not found'); + }); +}); diff --git a/packages/api/src/mcp/tools/task-handlers.ts b/packages/api/src/mcp/tools/task-handlers.ts index ab715feb..0cfad948 100644 --- a/packages/api/src/mcp/tools/task-handlers.ts +++ b/packages/api/src/mcp/tools/task-handlers.ts @@ -277,6 +277,29 @@ export async function handleUpdateTask( const task = await dataComposer.repositories.tasks.update(args.taskId, updates); + if (args.status && args.status !== existing.status) { + try { + const agentId = getEffectiveAgentId(undefined) || 'system'; + await dataComposer.repositories.activityStream.logActivity({ + userId: resolved.user.id, + agentId, + type: 'state_change', + subtype: 'task_status_change', + content: `${task.title}: ${existing.status} → ${args.status}`, + taskGroupId: task.task_group_id || undefined, + payload: { + taskId: task.id, + taskTitle: task.title, + groupId: task.task_group_id || null, + from: existing.status, + to: args.status, + }, + }); + } catch (err) { + logger.warn('Failed to log task_status_change activity:', err); + } + } + return mcpResponse({ success: true, task: { @@ -307,6 +330,13 @@ export async function handleUpdateTask( export const completeTaskSchema = z.object({ ...userIdentifierSchema.shape, taskId: z.string().uuid().describe('Task ID to mark as completed'), + summary: z + .string() + .max(2000) + .optional() + .describe( + 'Brief summary of what was accomplished (shown in mission feed and preserved in activity stream)' + ), }); export async function handleCompleteTask( @@ -353,6 +383,28 @@ export async function handleCompleteTask( logger.warn('Failed to auto-remember task completion:', err); } + // Log task_completed to activity stream for mission feed visibility + try { + const agentId = getEffectiveAgentId(undefined) || 'system'; + const summaryText = args.summary || `Completed: ${task.title}`; + await dataComposer.repositories.activityStream.logActivity({ + userId: resolved.user.id, + agentId, + type: 'state_change', + subtype: 'task_completed', + content: summaryText, + taskGroupId: task.task_group_id || undefined, + payload: { + taskId: task.id, + taskTitle: task.title, + groupId: task.task_group_id || null, + summary: args.summary || null, + }, + }); + } catch (err) { + logger.warn('Failed to log task_completed activity:', err); + } + // Strategy advancement: if task belongs to a group with an active strategy, // advance to the next task and inject the strategy prompt. let strategyResult = null; @@ -417,6 +469,132 @@ export async function handleCompleteTask( } } +// ============================================================================ +// CLOSE TASK (with outcome) +// ============================================================================ + +const taskOutcomeSchema = z.enum(['completed', 'skipped', 'blocked', 'failed']); + +export const closeTaskSchema = z.object({ + ...userIdentifierSchema.shape, + taskId: z.string().uuid().describe('Task ID to close'), + outcome: taskOutcomeSchema.describe( + 'Outcome: completed (done), skipped (not needed), blocked (cannot proceed), failed (attempted but failed)' + ), + reason: z.string().max(2000).optional().describe('Why the task was closed with this outcome'), + summary: z + .string() + .max(2000) + .optional() + .describe('Brief summary of what happened (shown in mission feed)'), +}); + +export async function handleCloseTask( + args: z.infer, + dataComposer: DataComposer +): Promise { + try { + const resolved = await resolveUser(args as UserIdentifier, dataComposer); + if (!resolved) { + return mcpResponse({ success: false, error: 'User not found' }, true); + } + + const existing = await dataComposer.repositories.tasks.findById(args.taskId); + if (!existing) { + return mcpResponse({ success: false, error: 'Task not found' }, true); + } + if (existing.user_id !== resolved.user.id) { + return mcpResponse({ success: false, error: 'Task does not belong to this user' }, true); + } + + const task = await dataComposer.repositories.tasks.closeTask( + args.taskId, + args.outcome, + args.reason + ); + + const agentId = getEffectiveAgentId(undefined) || 'system'; + const outcomeLabel = + args.outcome === 'completed' + ? `✓ ${args.summary || task.title}` + : `${args.outcome}: ${args.summary || args.reason || task.title}`; + + try { + await dataComposer.repositories.activityStream.logActivity({ + userId: resolved.user.id, + agentId, + type: 'state_change', + subtype: args.outcome === 'completed' ? 'task_completed' : 'task_closed', + content: outcomeLabel, + taskGroupId: task.task_group_id || undefined, + payload: { + taskId: task.id, + taskTitle: task.title, + groupId: task.task_group_id || null, + outcome: args.outcome, + reason: args.reason || null, + summary: args.summary || null, + }, + }); + } catch (err) { + logger.warn('Failed to log task close activity:', err); + } + + let strategyResult = null; + if (task.task_group_id) { + try { + const group = await dataComposer.repositories.taskGroups.findById(task.task_group_id); + if (group && group.strategy && group.status === 'active') { + const strategyService = new StrategyService(dataComposer); + strategyResult = await strategyService.advanceStrategy( + task.task_group_id, + task.id, + resolved.user.id + ); + } + } catch (err) { + logger.warn('Failed to advance strategy after task close:', err); + } + } + + const response: Record = { + success: true, + task: { + id: task.id, + title: task.title, + status: task.status, + outcome: args.outcome, + outcomeReason: args.reason || null, + completedAt: task.completed_at, + }, + }; + + if (strategyResult) { + response.strategy = { + action: strategyResult.action, + nextTask: strategyResult.nextTask + ? { + id: strategyResult.nextTask.id, + title: strategyResult.nextTask.title, + description: strategyResult.nextTask.description, + } + : null, + stats: strategyResult.stats || null, + }; + } + + return mcpResponse(response); + } catch (error) { + return mcpResponse( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to close task', + }, + true + ); + } +} + // ============================================================================ // GET PROJECT TASK STATS // ============================================================================ @@ -557,6 +735,26 @@ export async function handleAddTaskComment( created_at: string; }; + try { + await dataComposer.repositories.activityStream.logActivity({ + userId: resolved.user.id, + agentId: agentId || 'system', + type: 'state_change', + subtype: 'task_comment', + content: args.content.trim().slice(0, 200), + taskGroupId: existing.task_group_id || undefined, + payload: { + taskId: existing.id, + taskTitle: existing.title, + commentId: comment.id, + groupId: existing.task_group_id || null, + fullContent: args.content.trim(), + }, + }); + } catch (err) { + logger.warn('Failed to log task_comment activity:', err); + } + return mcpResponse({ success: true, comment: { @@ -578,6 +776,353 @@ export async function handleAddTaskComment( } } +// ============================================================================ +// TASK GROUP COMMENTS — ADD / LIST +// ============================================================================ + +export const addTaskGroupCommentSchema = z.object({ + ...userIdentifierSchema.shape, + groupId: z.string().uuid().describe('Task group ID to comment on'), + content: z.string().min(1).max(5000).describe('Comment content'), + commentType: z + .enum(['comment', 'conclusion', 'status_change']) + .optional() + .default('comment') + .describe('Comment type (comment, conclusion, status_change)'), + agentId: z.string().optional().describe('Agent ID for identity attribution'), +}); + +export async function handleAddTaskGroupComment( + args: z.infer, + dataComposer: DataComposer +): Promise { + try { + const resolved = await resolveUser(args as UserIdentifier, dataComposer); + if (!resolved) { + return mcpResponse({ success: false, error: 'User not found' }, true); + } + + const group = await dataComposer.repositories.taskGroups.findById(args.groupId); + if (!group) { + return mcpResponse({ success: false, error: 'Task group not found' }, true); + } + if (group.user_id !== resolved.user.id) { + return mcpResponse( + { success: false, error: 'Task group does not belong to this user' }, + true + ); + } + + const agentId = getEffectiveAgentId(args.agentId); + const reqCtx = getRequestContext(); + const workspaceId = reqCtx?.workspaceId; + + const identityId = await resolveIdentityIdForAgent( + dataComposer, + resolved.user.id, + agentId, + workspaceId + ); + + const { data: rawComment, error } = await dataComposer + .getClient() + .from('task_group_comments' as never) + .insert({ + task_group_id: args.groupId, + user_id: resolved.user.id, + content: args.content.trim(), + comment_type: args.commentType || 'comment', + agent_id: agentId || null, + created_by_identity_id: identityId, + } as never) + .select() + .single(); + + if (error) { + return mcpResponse( + { success: false, error: `Failed to add comment: ${error.message}` }, + true + ); + } + + const comment = rawComment as unknown as { + id: string; + task_group_id: string; + content: string; + comment_type: string; + created_at: string; + }; + + try { + await dataComposer.repositories.activityStream.logActivity({ + userId: resolved.user.id, + agentId: agentId || 'system', + type: 'state_change', + subtype: 'task_group_comment', + content: args.content.trim().slice(0, 200), + taskGroupId: args.groupId, + payload: { + groupId: args.groupId, + groupTitle: group.title, + commentId: comment.id, + commentType: args.commentType || 'comment', + fullContent: args.content.trim(), + }, + }); + } catch (err) { + logger.warn('Failed to log task_group_comment activity:', err); + } + + return mcpResponse({ + success: true, + comment: { + id: comment.id, + groupId: comment.task_group_id, + content: comment.content, + commentType: comment.comment_type, + authorAgentId: agentId || null, + createdAt: comment.created_at, + }, + }); + } catch (error) { + return mcpResponse( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to add task group comment', + }, + true + ); + } +} + +export const listTaskGroupCommentsSchema = z.object({ + ...userIdentifierSchema.shape, + groupId: z.string().uuid().describe('Task group ID to list comments for'), + commentType: z + .enum(['comment', 'conclusion', 'status_change']) + .optional() + .describe('Filter by comment type'), + limit: z.number().min(1).max(100).optional().default(50).describe('Max results'), +}); + +export async function handleListTaskGroupComments( + args: z.infer, + dataComposer: DataComposer +): Promise { + try { + const resolved = await resolveUser(args as UserIdentifier, dataComposer); + if (!resolved) { + return mcpResponse({ success: false, error: 'User not found' }, true); + } + + let query = dataComposer + .getClient() + .from('task_group_comments' as never) + .select('*') + .eq('task_group_id', args.groupId) + .eq('user_id', resolved.user.id) + .is('deleted_at', null) + .order('created_at', { ascending: true }) + .limit(args.limit || 50); + + if (args.commentType) { + query = query.eq('comment_type', args.commentType); + } + + const { data, error } = await query; + if (error) { + return mcpResponse( + { success: false, error: `Failed to list comments: ${error.message}` }, + true + ); + } + + const comments = + (data as unknown as Array<{ + id: string; + task_group_id: string; + content: string; + comment_type: string; + agent_id: string | null; + created_at: string; + }>) || []; + + return mcpResponse({ + success: true, + count: comments.length, + comments: comments.map((c) => ({ + id: c.id, + groupId: c.task_group_id, + content: c.content, + commentType: c.comment_type, + authorAgentId: c.agent_id, + createdAt: c.created_at, + })), + }); + } catch (error) { + return mcpResponse( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to list task group comments', + }, + true + ); + } +} + +// ============================================================================ +// CLOSE TASK GROUP (with conclusion) +// ============================================================================ + +const taskGroupOutcomeSchema = z.enum(['completed', 'partial', 'abandoned', 'failed']); + +export const closeTaskGroupSchema = z.object({ + ...userIdentifierSchema.shape, + groupId: z.string().uuid().describe('Task group ID to close'), + outcome: taskGroupOutcomeSchema.describe( + 'Outcome: completed (all done), partial (some done), abandoned (gave up), failed (critical failure)' + ), + conclusion: z + .string() + .max(5000) + .optional() + .describe('Conclusion summary. Auto-generated if not provided.'), + agentId: z.string().optional().describe('Agent ID for attribution'), +}); + +export async function handleCloseTaskGroup( + args: z.infer, + dataComposer: DataComposer +): Promise { + try { + const resolved = await resolveUser(args as UserIdentifier, dataComposer); + if (!resolved) { + return mcpResponse({ success: false, error: 'User not found' }, true); + } + + const group = await dataComposer.repositories.taskGroups.findById(args.groupId); + if (!group) { + return mcpResponse({ success: false, error: 'Task group not found' }, true); + } + if (group.user_id !== resolved.user.id) { + return mcpResponse( + { success: false, error: 'Task group does not belong to this user' }, + true + ); + } + + if (group.status === 'completed' || group.status === 'cancelled') { + return mcpResponse({ success: false, error: `Task group is already ${group.status}` }, true); + } + + const tasks = await dataComposer.repositories.tasks.findByGroupId(args.groupId); + const completed = tasks.filter( + (t) => t.status === 'completed' || t.outcome === 'completed' + ).length; + const skipped = tasks.filter((t) => t.outcome === 'skipped').length; + const blocked = tasks.filter((t) => t.status === 'blocked' || t.outcome === 'blocked').length; + const failed = tasks.filter((t) => t.outcome === 'failed').length; + const pending = tasks.filter((t) => t.status === 'pending').length; + const inProgress = tasks.filter((t) => t.status === 'in_progress').length; + + const autoConclusion = + args.conclusion || + [ + `${completed}/${tasks.length} tasks completed`, + skipped > 0 ? `${skipped} skipped` : null, + blocked > 0 ? `${blocked} blocked` : null, + failed > 0 ? `${failed} failed` : null, + pending > 0 ? `${pending} pending` : null, + inProgress > 0 ? `${inProgress} in progress` : null, + ] + .filter(Boolean) + .join(', ') + '.'; + + // Clean up strategy resources (watchdog reminders) without logging a + // misleading strategy_cancelled event — the task_group_closed event we + // log below carries the real outcome (completed/partial/abandoned/failed). + if (group.strategy) { + try { + const strategyService = new StrategyService(dataComposer); + await strategyService.cleanupStrategyResources(args.groupId); + } catch (err) { + logger.warn('Failed to clean up strategy resources on group close:', err); + } + } + + await dataComposer.repositories.taskGroups.update(args.groupId, { + status: args.outcome === 'completed' ? 'completed' : 'cancelled', + outcome: args.outcome, + conclusion: autoConclusion, + }); + + const agentId = getEffectiveAgentId(args.agentId); + const reqCtx = getRequestContext(); + const workspaceId = reqCtx?.workspaceId; + const identityId = await resolveIdentityIdForAgent( + dataComposer, + resolved.user.id, + agentId, + workspaceId + ); + + const { error: commentError } = await dataComposer + .getClient() + .from('task_group_comments' as never) + .insert({ + task_group_id: args.groupId, + user_id: resolved.user.id, + content: autoConclusion, + comment_type: 'conclusion', + agent_id: agentId || null, + created_by_identity_id: identityId, + } as never); + + if (commentError) { + logger.warn('Failed to post conclusion comment:', commentError); + } + + try { + await dataComposer.repositories.activityStream.logActivity({ + userId: resolved.user.id, + agentId: agentId || 'system', + type: 'state_change', + subtype: 'task_group_closed', + content: `Group closed (${args.outcome}): ${autoConclusion}`, + taskGroupId: args.groupId, + payload: { + groupId: args.groupId, + groupTitle: group.title, + outcome: args.outcome, + conclusion: autoConclusion, + stats: { total: tasks.length, completed, skipped, blocked, failed, pending, inProgress }, + }, + }); + } catch (err) { + logger.warn('Failed to log task_group_closed activity:', err); + } + + return mcpResponse({ + success: true, + group: { + id: args.groupId, + title: group.title, + outcome: args.outcome, + conclusion: autoConclusion, + stats: { total: tasks.length, completed, skipped, blocked, failed, pending, inProgress }, + }, + }); + } catch (error) { + return mcpResponse( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to close task group', + }, + true + ); + } +} + // ============================================================================ // TASK GROUPS — CREATE / LIST // ============================================================================ diff --git a/packages/api/src/services/sessions/session-service.ts b/packages/api/src/services/sessions/session-service.ts index 6e665d9b..c9ee19fb 100644 --- a/packages/api/src/services/sessions/session-service.ts +++ b/packages/api/src/services/sessions/session-service.ts @@ -425,6 +425,21 @@ export class SessionService implements ISessionService { } } + const strategyGroupId = (metadata?.taskGroupId as string) || undefined; + const permissionOverlay = strategyGroupId + ? { + allow: [ + 'Bash(*)', + 'Edit(*)', + 'Write(*)', + 'Read(*)', + 'WebFetch(*)', + 'WebSearch', + 'mcp__*', + ], + } + : undefined; + const runnerConfig: ClaudeRunnerConfig = { workingDirectory: resolvedWorkingDirectory, mcpConfigPath: this.config.mcpConfigPath, @@ -446,6 +461,7 @@ export class SessionService implements ISessionService { agentId, ...(session.studioId ? { studioId: session.studioId } : {}), ...(sandboxBypass ? { sandboxBypass: true } : {}), + ...(permissionOverlay ? { permissionOverlay } : {}), // Propagate repo root so spawned backend's context token carries it repoRoot: resolvedWorkingDirectory.replace(/--[^/]+$/, ''), }; diff --git a/packages/api/src/services/strategy.service.test.ts b/packages/api/src/services/strategy.service.test.ts index 98a53bab..0c34c8f4 100644 --- a/packages/api/src/services/strategy.service.test.ts +++ b/packages/api/src/services/strategy.service.test.ts @@ -1093,6 +1093,21 @@ describe('StrategyService', () => { taskGroupId: 'group-1', }) ); + + // Verify owner agent was auto-triggered + expect(result.notified).toBe(true); + const { handleSendToInbox: sendMock } = await import('../mcp/tools/inbox-handlers'); + expect(sendMock).toHaveBeenCalledWith( + expect.objectContaining({ + recipientAgentId: 'wren', + trigger: true, + metadata: expect.objectContaining({ + reason: 'manual_resume', + groupId: 'group-1', + }), + }), + expect.anything() + ); }); it('should log approval_granted when resuming from approval gate pause', async () => { diff --git a/packages/api/src/services/strategy.service.ts b/packages/api/src/services/strategy.service.ts index 314c818a..f5065a28 100644 --- a/packages/api/src/services/strategy.service.ts +++ b/packages/api/src/services/strategy.service.ts @@ -526,10 +526,14 @@ export class StrategyService { const updatedGroup = { ...group, status: 'active' as const } as TaskGroup; const prompt = STRATEGY_PROMPTS[group.strategy as StrategyPreset](updatedGroup, nextTask); + // Auto-trigger the owner agent so resume doesn't require a separate trigger call + const triggered = await this.triggerOwnerAgent(updatedGroup, nextTask, 'manual_resume'); + return { action: 'next_task', nextTask, prompt, + notified: triggered, }; } @@ -1009,6 +1013,15 @@ export class StrategyService { /** * Cancel the watchdog reminder for a strategy (on pause/complete). */ + /** + * Clean up strategy resources (watchdog, etc.) without logging a + * strategy_cancelled event or changing group status. Use this when + * the caller manages its own status transition and activity logging. + */ + async cleanupStrategyResources(groupId: string): Promise { + await this.cancelWatchdogReminder(groupId); + } + private async cancelWatchdogReminder(groupId: string): Promise { try { await this.dataComposer diff --git a/packages/cli/src/commands/mission.test.ts b/packages/cli/src/commands/mission.test.ts index a1e91084..f16abfc4 100644 --- a/packages/cli/src/commands/mission.test.ts +++ b/packages/cli/src/commands/mission.test.ts @@ -1077,6 +1077,243 @@ describe('activityToFeedEvent state_change', () => { }); }); +// ── task lifecycle events ── + +describe('activityToFeedEvent task lifecycle', () => { + const activity = (overrides: Partial): MissionActivity => ({ + id: 'test-tl', + createdAt: '2026-04-29T10:00:00.000Z', + ...overrides, + }); + + describe('task_completed', () => { + it('formats completed task with summary', () => { + const event = activityToFeedEvent( + activity({ + type: 'state_change', + subtype: 'task_completed', + agentId: 'wren', + content: '✓ Shipped the auth module', + payload: { + taskId: 'task-1', + taskTitle: 'Implement auth', + outcome: 'completed', + summary: 'Shipped the auth module', + }, + }) + ); + + expect(event.type).toBe('strategy'); + expect(event.content).toContain('✓'); + expect(event.content).toContain('Shipped the auth module'); + expect(event.agent).toBe('wren'); + }); + + it('falls back to task title when no summary', () => { + const event = activityToFeedEvent( + activity({ + type: 'state_change', + subtype: 'task_completed', + agentId: 'wren', + content: '✓ Implement auth', + payload: { taskId: 'task-1', taskTitle: 'Implement auth', outcome: 'completed' }, + }) + ); + + expect(event.content).toContain('Implement auth'); + }); + + it('does not include taskTitle in detail line (redundant)', () => { + const event = activityToFeedEvent( + activity({ + type: 'state_change', + subtype: 'task_completed', + agentId: 'wren', + content: '✓ Done', + payload: { + groupId: '12345678-abcd-1234-5678-123456789abc', + taskTitle: 'Implement auth', + outcome: 'completed', + }, + }) + ); + + // detail includes groupId but not taskTitle for task_completed + expect(event.detail).toContain('group: 12345678'); + // taskTitle NOT in detail for task_completed (special case in code) + }); + }); + + describe('task_closed', () => { + it('formats skipped task with reason', () => { + const event = activityToFeedEvent( + activity({ + type: 'state_change', + subtype: 'task_closed', + agentId: 'wren', + content: 'skipped: Optional cleanup — Not needed', + payload: { + taskId: 'task-2', + taskTitle: 'Optional cleanup', + outcome: 'skipped', + reason: 'Not needed after refactor', + }, + }) + ); + + expect(event.type).toBe('strategy'); + expect(event.content).toContain('⏭'); + expect(event.content).toContain('skipped'); + expect(event.content).toContain('Optional cleanup'); + expect(event.content).toContain('Not needed after refactor'); + }); + + it('formats blocked task', () => { + const event = activityToFeedEvent( + activity({ + type: 'state_change', + subtype: 'task_closed', + agentId: 'wren', + content: 'blocked: DB migration', + payload: { + taskId: 'task-3', + taskTitle: 'DB migration', + outcome: 'blocked', + reason: 'Waiting on DBA', + }, + }) + ); + + expect(event.content).toContain('🚫'); + expect(event.content).toContain('blocked'); + expect(event.content).toContain('DB migration'); + }); + + it('formats failed task', () => { + const event = activityToFeedEvent( + activity({ + type: 'state_change', + subtype: 'task_closed', + agentId: 'wren', + content: 'failed: Build pipeline', + payload: { + taskId: 'task-4', + taskTitle: 'Build pipeline', + outcome: 'failed', + reason: 'Dependency missing', + }, + }) + ); + + expect(event.content).toContain('✗'); + expect(event.content).toContain('failed'); + expect(event.content).toContain('Build pipeline'); + }); + }); + + describe('task_group_closed', () => { + it('formats completed group with conclusion', () => { + const event = activityToFeedEvent( + activity({ + type: 'state_change', + subtype: 'task_group_closed', + agentId: 'wren', + content: 'Group closed (completed): 3/3 tasks completed.', + payload: { + groupId: 'group-1', + groupTitle: 'Feature rollout', + outcome: 'completed', + conclusion: '3/3 tasks completed.', + }, + }) + ); + + expect(event.type).toBe('strategy'); + expect(event.content).toContain('📋'); + expect(event.content).toContain('completed'); + expect(event.content).toContain('Feature rollout'); + expect(event.content).toContain('3/3 tasks completed'); + }); + + it('formats abandoned group', () => { + const event = activityToFeedEvent( + activity({ + type: 'state_change', + subtype: 'task_group_closed', + agentId: 'wren', + content: 'Group closed (abandoned): 1/5 tasks completed, 2 blocked.', + payload: { + groupId: 'group-2', + groupTitle: 'Legacy cleanup', + outcome: 'abandoned', + conclusion: '1/5 tasks completed, 2 blocked.', + }, + }) + ); + + expect(event.content).toContain('abandoned'); + expect(event.content).toContain('Legacy cleanup'); + }); + }); + + describe('task_group_comment', () => { + it('formats group comment with title and preview', () => { + const event = activityToFeedEvent( + activity({ + type: 'state_change', + subtype: 'task_group_comment', + agentId: 'wren', + content: 'Progress looking good', + payload: { + groupId: 'group-1', + groupTitle: 'Auth migration', + commentType: 'comment', + fullContent: 'Progress looking good', + }, + }) + ); + + expect(event.type).toBe('strategy'); + expect(event.content).toContain('Auth migration'); + expect(event.content).toContain('Progress looking good'); + }); + }); + + describe('detail line for task events', () => { + it('includes groupId prefix', () => { + const event = activityToFeedEvent( + activity({ + type: 'state_change', + subtype: 'task_completed', + agentId: 'wren', + content: '✓ Done', + payload: { + groupId: 'abcdef12-1234-5678-9abc-def012345678', + taskTitle: 'Test task', + outcome: 'completed', + }, + }) + ); + + expect(event.detail).toContain('group: abcdef12'); + }); + + it('includes strategy name when present', () => { + const event = activityToFeedEvent( + activity({ + type: 'state_change', + subtype: 'strategy_started', + agentId: 'wren', + content: 'persistence strategy started', + payload: { groupId: 'group-1', strategy: 'persistence' }, + }) + ); + + expect(event.detail).toContain('persistence'); + }); + }); +}); + // ── estimateRows ── describe('estimateRows', () => { diff --git a/packages/cli/src/commands/mission.ts b/packages/cli/src/commands/mission.ts index 8bf615b2..8c7ad28a 100644 --- a/packages/cli/src/commands/mission.ts +++ b/packages/cli/src/commands/mission.ts @@ -344,26 +344,125 @@ export function formatWorktreeLabel(folder: string): string { * Format a state_change activity into a human-readable summary showing actual values. * Payload shape: { changedFields, before, after, ... } */ +function formatStrategyEvent(activity: MissionActivity): string { + const p = activity.payload; + const subtype = activity.subtype || ''; + const groupTitle = typeof p?.groupTitle === 'string' ? p.groupTitle : undefined; + const reason = typeof p?.reason === 'string' ? p.reason : undefined; + const shortTitle = groupTitle + ? groupTitle.length > 50 + ? groupTitle.slice(0, 47) + '...' + : groupTitle + : undefined; + + switch (subtype) { + case 'strategy_started': + return shortTitle ? `strategy started: ${shortTitle}` : 'strategy started'; + case 'strategy_paused': + return shortTitle ? `paused: ${shortTitle}` : 'strategy paused'; + case 'strategy_resumed': + return shortTitle ? `resumed: ${shortTitle}` : 'strategy resumed'; + case 'approval_required': { + const routedTo = typeof p?.routedTo === 'string' ? p.routedTo : undefined; + const base = shortTitle ? `approval needed: ${shortTitle}` : 'approval needed'; + return routedTo ? `${base} → ${routedTo}` : base; + } + case 'approval_granted': + return shortTitle ? `approved: ${shortTitle}` : 'approval granted'; + case 'strategy_completed': + return shortTitle ? `completed: ${shortTitle}` : 'strategy completed'; + case 'strategy_cancelled': + return shortTitle ? `cancelled: ${shortTitle}` : 'strategy cancelled'; + case 'strategy_trigger': + return reason ? `trigger (${reason})` : 'strategy trigger'; + case 'strategy_trigger_failed': + return `trigger failed${reason ? `: ${reason}` : ''}`; + case 'watchdog_wakeup': + return shortTitle ? `watchdog check: ${shortTitle}` : 'watchdog wakeup'; + case 'watchdog_skip': { + const skipReason = typeof p?.skipReason === 'string' ? p.skipReason : reason; + return skipReason ? `watchdog skipped (${skipReason})` : 'watchdog skipped'; + } + case 'task_completed': { + const summary = typeof p?.summary === 'string' ? p.summary : undefined; + const taskTitle = typeof p?.taskTitle === 'string' ? p.taskTitle : undefined; + if (summary) return `✓ ${summary}`; + if (taskTitle) return `✓ completed: ${taskTitle}`; + return activity.content || 'task completed'; + } + case 'task_advanced': { + const taskTitle = typeof p?.taskTitle === 'string' ? p.taskTitle : undefined; + const taskIndex = typeof p?.taskIndex === 'number' ? p.taskIndex : undefined; + if (taskTitle && taskIndex !== undefined) return `→ task #${taskIndex + 1}: ${taskTitle}`; + if (taskTitle) return `→ next: ${taskTitle}`; + return activity.content || 'advanced to next task'; + } + case 'task_comment': { + const taskTitle = typeof p?.taskTitle === 'string' ? p.taskTitle : undefined; + const fullContent = typeof p?.fullContent === 'string' ? p.fullContent : activity.content; + const preview = fullContent ? compactPreview(fullContent, 100) : 'comment'; + return taskTitle ? `💬 ${taskTitle}: ${preview}` : `💬 ${preview}`; + } + case 'task_status_change': { + const taskTitle = typeof p?.taskTitle === 'string' ? p.taskTitle : undefined; + const from = typeof p?.from === 'string' ? p.from : undefined; + const to = typeof p?.to === 'string' ? p.to : undefined; + if (taskTitle && from && to) return `${taskTitle}: ${from} → ${to}`; + return activity.content || 'task status changed'; + } + case 'task_group_comment': { + const groupTitle = typeof p?.groupTitle === 'string' ? p.groupTitle : undefined; + const commentType = typeof p?.commentType === 'string' ? p.commentType : 'comment'; + const fullContent = typeof p?.fullContent === 'string' ? p.fullContent : activity.content; + const icon = commentType === 'conclusion' ? '📋' : '💬'; + const preview = fullContent ? compactPreview(fullContent, 100) : 'group comment'; + return groupTitle ? `${icon} ${groupTitle}: ${preview}` : `${icon} ${preview}`; + } + case 'task_closed': { + const taskTitle = typeof p?.taskTitle === 'string' ? p.taskTitle : undefined; + const outcome = typeof p?.outcome === 'string' ? p.outcome : 'closed'; + const reason = typeof p?.reason === 'string' ? p.reason : undefined; + const label = taskTitle || 'task'; + if (outcome === 'skipped') return `⏭ skipped: ${label}${reason ? ` — ${reason}` : ''}`; + if (outcome === 'blocked') return `🚫 blocked: ${label}${reason ? ` — ${reason}` : ''}`; + if (outcome === 'failed') return `✗ failed: ${label}${reason ? ` — ${reason}` : ''}`; + return activity.content || `closed: ${label}`; + } + case 'task_group_closed': { + const groupTitle = typeof p?.groupTitle === 'string' ? p.groupTitle : undefined; + const outcome = typeof p?.outcome === 'string' ? p.outcome : 'closed'; + const conclusion = typeof p?.conclusion === 'string' ? p.conclusion : undefined; + const label = groupTitle || 'group'; + return `📋 ${outcome}: ${label}${conclusion ? ` — ${compactPreview(conclusion, 60)}` : ''}`; + } + default: + if (subtype.startsWith('backend_crash:')) { + const backend = subtype.replace('backend_crash:', ''); + return `crash (${backend}): ${compactPreview(activity.content, 80)}`; + } + return activity.content || subtype || 'strategy event'; + } +} + function formatStateChange(activity: MissionActivity): string { + if (isStrategyEvent(activity)) return formatStrategyEvent(activity); + const p = activity.payload; const after = p?.after as Record | undefined; const changedFields = p?.changedFields as string[] | undefined; if (!after || !changedFields?.length) { - // Fallback to raw content if payload is missing return (activity.content || 'session updated').replace(/\s+/g, ' ').trim(); } const sessionId = typeof p?.sessionId === 'string' ? p.sessionId.slice(0, 8) : activity.sessionId?.slice(0, 8); - // Show the values that changed, not just the field names const parts: string[] = []; for (const field of changedFields) { const val = after[field]; if (val == null || val === '') continue; const strVal = String(val); - // Skip very long values (like context blobs) in the summary line if (strVal.length > 80) continue; parts.push(`${field}: ${strVal}`); } @@ -790,7 +889,36 @@ function printSnapshot(snapshot: MissionSnapshot): void { } } +const STRATEGY_SUBTYPES = new Set([ + 'strategy_started', + 'strategy_paused', + 'strategy_resumed', + 'strategy_completed', + 'strategy_cancelled', + 'strategy_trigger', + 'strategy_trigger_failed', + 'watchdog_wakeup', + 'watchdog_skip', + 'approval_required', + 'approval_granted', + 'task_completed', + 'task_advanced', + 'task_comment', + 'task_status_change', + 'task_group_comment', + 'task_closed', + 'task_group_closed', +]); + +function isStrategyEvent(activity: MissionActivity): boolean { + return ( + STRATEGY_SUBTYPES.has(activity.subtype || '') || + activity.subtype?.startsWith('backend_crash:') === true + ); +} + function mapActivityToFeedType(activity: MissionActivity): FeedEventType { + if (isStrategyEvent(activity)) return 'strategy'; switch (activity.type) { case 'message_in': return 'inbox'; @@ -964,9 +1092,25 @@ export function activityToFeedEvent( '-'; const detailParts: string[] = []; - if (messageType && messageType !== 'message') detailParts.push(`type: ${messageType}`); - if (threadKey) detailParts.push(`thread: ${threadKey}`); - if (studioLabel && studioLabel !== '-') detailParts.push(`studio: ${studioLabel}`); + if (isStrategyEvent(activity)) { + const groupId = typeof p?.groupId === 'string' ? p.groupId.slice(0, 8) : undefined; + const strategy = typeof p?.strategy === 'string' ? p.strategy : undefined; + const taskTitle = typeof p?.taskTitle === 'string' ? p.taskTitle : undefined; + const summary = typeof p?.summary === 'string' ? p.summary : undefined; + if (groupId) detailParts.push(`group: ${groupId}`); + if (strategy) detailParts.push(strategy); + if (taskTitle && activity.subtype !== 'task_completed') detailParts.push(`task: ${taskTitle}`); + if (summary && taskTitle) detailParts.push(`task: ${taskTitle}`); + if (activity.subtype === 'task_comment') { + const fullContent = typeof p?.fullContent === 'string' ? p.fullContent : undefined; + if (fullContent && fullContent.length > 100) detailParts.push(fullContent); + } + if (studioLabel && studioLabel !== '-') detailParts.push(`studio: ${studioLabel}`); + } else { + if (messageType && messageType !== 'message') detailParts.push(`type: ${messageType}`); + if (threadKey) detailParts.push(`thread: ${threadKey}`); + if (studioLabel && studioLabel !== '-') detailParts.push(`studio: ${studioLabel}`); + } return { id: activity.id, diff --git a/packages/cli/src/repl/ink/MissionApp.tsx b/packages/cli/src/repl/ink/MissionApp.tsx index e0d16a05..9b815d05 100644 --- a/packages/cli/src/repl/ink/MissionApp.tsx +++ b/packages/cli/src/repl/ink/MissionApp.tsx @@ -5,7 +5,14 @@ import { formatNow } from '../tui-components.js'; // ── Feed event types ── -export type FeedEventType = 'inbox' | 'activity' | 'task' | 'document' | 'session' | 'system'; +export type FeedEventType = + | 'inbox' + | 'activity' + | 'task' + | 'document' + | 'session' + | 'strategy' + | 'system'; export interface FeedEvent { id: string; @@ -57,6 +64,7 @@ const TYPE_COLORS: Record = { task: 'yellow', document: 'blue', session: 'green', + strategy: 'yellowBright', system: 'gray', }; @@ -66,6 +74,7 @@ const TYPE_ICONS: Record = { task: '✓', document: '📄', session: '🔄', + strategy: '🎯', system: '•', }; diff --git a/supabase/migrations/20260428022100_task_group_number_and_slug.sql b/supabase/migrations/20260428022100_task_group_number_and_slug.sql new file mode 100644 index 00000000..0f72848a --- /dev/null +++ b/supabase/migrations/20260428022100_task_group_number_and_slug.sql @@ -0,0 +1,64 @@ +-- Add auto-incrementing group_number and slug to task_groups +-- for human-friendly threadKeys like taskgroup:42 or taskgroup:lifecycle-fidelity + +ALTER TABLE public.task_groups + ADD COLUMN group_number integer NOT NULL DEFAULT 0, + ADD COLUMN slug text; + +-- Auto-assign group_number and slug on insert +CREATE OR REPLACE FUNCTION public.assign_task_group_number_and_slug() +RETURNS trigger +LANGUAGE plpgsql +AS $function$ +DECLARE + base_slug text; + candidate_slug text; + collision_exists boolean; +BEGIN + -- Auto-assign group_number (next sequential per user) + IF NEW.group_number IS NULL THEN + SELECT COALESCE(MAX(group_number), 0) + 1 + INTO NEW.group_number + FROM task_groups + WHERE user_id = NEW.user_id; + END IF; + + -- Auto-generate slug from title if not provided + IF NEW.slug IS NULL AND NEW.title IS NOT NULL THEN + base_slug := left( + regexp_replace( + regexp_replace(lower(trim(NEW.title)), '[^a-z0-9]+', '-', 'g'), + '^-+|-+$', '', 'g' + ), + 64 + ); + + IF base_slug = '' THEN + base_slug := 'group'; + END IF; + + candidate_slug := base_slug; + + SELECT EXISTS( + SELECT 1 FROM task_groups + WHERE user_id = NEW.user_id AND slug = candidate_slug + ) INTO collision_exists; + + IF collision_exists THEN + candidate_slug := base_slug || '-' || NEW.group_number::text; + END IF; + + NEW.slug := candidate_slug; + END IF; + + RETURN NEW; +END; +$function$; + +CREATE TRIGGER task_group_assign_number_slug + BEFORE INSERT ON public.task_groups + FOR EACH ROW EXECUTE FUNCTION public.assign_task_group_number_and_slug(); + +-- Unique indexes scoped per user +CREATE UNIQUE INDEX idx_task_groups_user_group_number ON public.task_groups(user_id, group_number); +CREATE UNIQUE INDEX idx_task_groups_user_slug ON public.task_groups(user_id, slug) WHERE slug IS NOT NULL; diff --git a/supabase/migrations/20260428022143_task_group_comments_and_outcome_fields.sql b/supabase/migrations/20260428022143_task_group_comments_and_outcome_fields.sql new file mode 100644 index 00000000..1cb26e39 --- /dev/null +++ b/supabase/migrations/20260428022143_task_group_comments_and_outcome_fields.sql @@ -0,0 +1,43 @@ +-- Task group comments table + task/task_group outcome fields +-- Mirrors artifact_comments pattern for auditable group lifecycle tracking + +CREATE TABLE public.task_group_comments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + task_group_id uuid NOT NULL REFERENCES public.task_groups(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES public.users(id), + agent_id text, + comment_type text NOT NULL DEFAULT 'comment' + CHECK (comment_type IN ('comment', 'conclusion', 'status_change')), + content text NOT NULL, + created_by_identity_id uuid REFERENCES public.agent_identities(id), + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_task_group_comments_task_group_id ON public.task_group_comments(task_group_id); +CREATE INDEX idx_task_group_comments_user_id ON public.task_group_comments(user_id); +CREATE INDEX idx_task_group_comments_agent_id ON public.task_group_comments(agent_id); +CREATE INDEX idx_task_group_comments_comment_type ON public.task_group_comments(comment_type); +CREATE INDEX idx_task_group_comments_created_by_identity_id ON public.task_group_comments(created_by_identity_id); + +CREATE TRIGGER update_task_group_comments_updated_at + BEFORE UPDATE ON public.task_group_comments + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +ALTER TABLE public.task_group_comments ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Service role full access to task_group_comments" + ON public.task_group_comments FOR ALL + USING ((auth.jwt() ->> 'role'::text) = 'service_role'::text); + +-- Task outcome fields: close with outcome instead of binary complete +ALTER TABLE public.tasks + ADD COLUMN outcome text CHECK (outcome IN ('completed', 'skipped', 'blocked', 'failed')), + ADD COLUMN outcome_reason text; + +-- Task group outcome/conclusion for auditable closure +ALTER TABLE public.task_groups + ADD COLUMN outcome text CHECK (outcome IN ('completed', 'partial', 'abandoned', 'failed')), + ADD COLUMN conclusion text; diff --git a/supabase/migrations/20260501063800_fix_group_number_default.sql b/supabase/migrations/20260501063800_fix_group_number_default.sql new file mode 100644 index 00000000..e8f2f881 --- /dev/null +++ b/supabase/migrations/20260501063800_fix_group_number_default.sql @@ -0,0 +1,22 @@ +-- Fix group_number default: DEFAULT 0 prevents the trigger from firing +-- because the trigger checks `IF NEW.group_number IS NULL`. With DEFAULT 0, +-- the value is never NULL on insert, so every group gets 0 and the second +-- insert for the same user violates the unique (user_id, group_number) index. +-- +-- Fix: change default to NULL so the trigger auto-assigns the next number. + +ALTER TABLE public.task_groups + ALTER COLUMN group_number DROP NOT NULL, + ALTER COLUMN group_number SET DEFAULT NULL; + +-- Backfill any existing rows that have group_number = 0. +-- Assign sequential numbers per user ordered by created_at. +WITH numbered AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn + FROM public.task_groups + WHERE group_number = 0 OR group_number IS NULL +) +UPDATE public.task_groups t +SET group_number = numbered.rn +FROM numbered +WHERE t.id = numbered.id;