From 9c63959a0841bf1aa8c94ae2f4477c28144d14e5 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 9 Mar 2026 12:47:06 +0200 Subject: [PATCH] feat: enhance task management with improved notification and briefing features - Refactored task creation logic to support explicit immediate start and maintain default pending status. - Implemented notification system for task owners upon assignment, including detailed task instructions. - Enhanced task briefing output to provide a compact view of assigned tasks, differentiating between in-progress, pending, and completed tasks. - Added utility functions for better task management and streamlined task reference handling. - Updated tests to cover new task assignment notifications and briefing functionalities, ensuring accurate task status representation. --- .../src/internal/runtimeHelpers.js | 15 +- .../src/internal/taskStore.js | 130 +++++++++++------- agent-teams-controller/src/internal/tasks.js | 93 ++++++++++++- .../test/controller.test.js | 54 ++++++++ mcp-server/src/tools/taskTools.ts | 2 +- mcp-server/test/tools.test.ts | 103 ++++++++++++++ src/main/services/team/TeamDataService.ts | 44 +----- .../services/team/TeamProvisioningService.ts | 23 +++- .../team/messages/MessageComposer.tsx | 12 -- .../services/team/TeamDataService.test.ts | 62 ++++++++- .../TeamProvisioningServicePrompts.test.ts | 6 + 11 files changed, 430 insertions(+), 114 deletions(-) diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index 2c058cbf..3f9e88ad 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -86,7 +86,7 @@ function getPaths(flags, teamName) { } function inferLeadName(paths) { - const config = readJson(path.join(paths.teamDir, 'config.json'), null); + const config = readTeamConfig(paths); if (!config || !Array.isArray(config.members)) { return 'team-lead'; } @@ -99,6 +99,17 @@ function inferLeadName(paths) { return config.members[0] ? String(config.members[0].name) : 'team-lead'; } +function readTeamConfig(paths) { + return readJson(path.join(paths.teamDir, 'config.json'), null); +} + +function resolveLeadSessionId(paths) { + const config = readTeamConfig(paths); + return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim() + ? config.leadSessionId.trim() + : undefined; +} + function isProcessAlive(pid) { try { process.kill(pid, 0); @@ -291,5 +302,7 @@ module.exports = { getPaths, inferLeadName, isProcessAlive, + readTeamConfig, + resolveLeadSessionId, saveTaskAttachmentFile, }; diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index 773aabfd..ed53193f 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -111,7 +111,7 @@ function listTasks(paths, options = {}) { } function resolveTaskRef(paths, taskRef, options = {}) { - const normalizedRef = String(taskRef || '').trim(); + const normalizedRef = String(taskRef || '').trim().replace(/^#/, ''); if (!normalizedRef) { throw new Error('Missing taskId'); } @@ -168,7 +168,8 @@ function computeInitialStatus(paths, input, owner, blockedByIds) { const explicit = normalizeStatus(input.status); if (explicit) return explicit; if (blockedByIds.length > 0) return 'pending'; - return owner ? 'in_progress' : 'pending'; + if (owner && input.startImmediately === true) return 'in_progress'; + return 'pending'; } function pickTaskId(input) { @@ -577,6 +578,45 @@ function buildTaskReference(task) { return `#${task.displayId || deriveDisplayId(task.id)} (taskId: ${task.id})`; } +function compareTasksForBriefing(a, b) { + const order = { + in_progress: 0, + pending: 1, + completed: 2, + deleted: 3, + }; + const byStatus = (order[a.status] ?? 99) - (order[b.status] ?? 99); + if (byStatus !== 0) return byStatus; + const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, { + numeric: true, + sensitivity: 'base', + }); + if (byDisplay !== 0) return byDisplay; + return String(a.id).localeCompare(String(b.id), undefined, { + numeric: true, + sensitivity: 'base', + }); +} + +function getEffectiveReviewState(kanbanEntry, task) { + if (normalizeTaskReviewState(task.reviewState) !== 'none') { + return normalizeTaskReviewState(task.reviewState); + } + return kanbanEntry && kanbanEntry.column ? String(kanbanEntry.column) : 'none'; +} + +function formatBriefTaskLine(task, reviewState) { + const reviewSuffix = reviewState !== 'none' ? `, review=${reviewState}` : ''; + return `- #${task.displayId || deriveDisplayId(task.id)} [status=${task.status}${reviewSuffix}] ${task.subject}`; +} + +function formatCommentLine(comment) { + const author = + typeof comment.author === 'string' && comment.author.trim() ? comment.author.trim() : 'unknown'; + const text = typeof comment.text === 'string' ? comment.text.trim() : ''; + return ` - ${author}: ${text || '(empty comment)'}`; +} + function formatTaskBriefing(paths, teamName, memberName) { const kanbanState = readJson(path.join(paths.teamDir, 'kanban-state.json'), { teamName, @@ -585,57 +625,51 @@ function formatTaskBriefing(paths, teamName, memberName) { }); const activeTasks = listTasks(paths) .filter((task) => task.owner === memberName && task.status !== 'deleted') - .sort((a, b) => String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, { - numeric: true, - sensitivity: 'base', - })); + .sort(compareTasksForBriefing); if (activeTasks.length === 0) { - return `No pending tasks for ${memberName}.`; + return `No assigned tasks for ${memberName}.`; } - const lines = []; - for (const task of activeTasks) { - const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined; - const reviewState = kanbanEntry && kanbanEntry.column ? `, review=${kanbanEntry.column}` : ''; - const effectiveReviewState = - normalizeTaskReviewState(task.reviewState) !== 'none' - ? normalizeTaskReviewState(task.reviewState) - : reviewState - ? String(kanbanEntry.column) - : 'none'; - lines.push( - `${buildTaskReference(task)} [status=${task.status}${effectiveReviewState !== 'none' ? `, review=${effectiveReviewState}` : ''}] ${task.subject}` - ); - if (task.description) lines.push(` Description: ${task.description}`); - if (task.blockedBy && task.blockedBy.length > 0) { - const blockedLabels = task.blockedBy - .map((depId) => { - try { - return buildTaskReference(readTask(paths, depId, { includeDeleted: true })); - } catch { - return depId; - } - }) - .join(', '); - lines.push(` Blocked by: ${blockedLabels}`); - } - if (task.related && task.related.length > 0) { - const relatedLabels = task.related - .map((relatedId) => { - try { - return buildTaskReference(readTask(paths, relatedId, { includeDeleted: true })); - } catch { - return relatedId; - } - }) - .join(', '); - lines.push(` Related: ${relatedLabels}`); - } - if (Array.isArray(task.comments) && task.comments.length > 0) { - for (const comment of task.comments.slice(-3)) { - lines.push(` Comment by ${comment.author}: ${comment.text}`); + const groups = { + in_progress: activeTasks.filter((task) => task.status === 'in_progress'), + pending: activeTasks.filter((task) => task.status === 'pending'), + completed: activeTasks.filter((task) => task.status === 'completed'), + }; + + const lines = [`Task briefing for ${memberName}:`]; + + if (groups.in_progress.length > 0) { + lines.push('', 'In progress:'); + for (const task of groups.in_progress) { + const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined; + const reviewState = getEffectiveReviewState(kanbanEntry, task); + lines.push(formatBriefTaskLine(task, reviewState)); + if (task.description) { + lines.push(` Description: ${task.description}`); } + if (Array.isArray(task.comments) && task.comments.length > 0) { + lines.push(' Comments:'); + for (const comment of task.comments) { + lines.push(formatCommentLine(comment)); + } + } + } + } + + if (groups.pending.length > 0) { + lines.push('', 'Pending:'); + for (const task of groups.pending) { + const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined; + lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task))); + } + } + + if (groups.completed.length > 0) { + lines.push('', 'Completed:'); + for (const task of groups.completed) { + const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined; + lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task))); } } diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 0c67f4ed..3ea5c1a8 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -1,8 +1,84 @@ const taskStore = require('./taskStore.js'); const runtimeHelpers = require('./runtimeHelpers.js'); +const messages = require('./messages.js'); +const { wrapAgentBlock } = require('./agentBlocks.js'); + +function normalizeActorName(value) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function isSameMember(left, right) { + return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase(); +} + +function buildAssignmentMessage(context, task, options = {}) { + const description = + typeof options.description === 'string' && options.description.trim() + ? options.description.trim() + : typeof task.description === 'string' && task.description.trim() + ? task.description.trim() + : ''; + const prompt = + typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : ''; + const taskLabel = `#${task.displayId || task.id}`; + const lines = [`New task assigned to you: ${taskLabel} "${task.subject}".`]; + + if (description) { + lines.push(``, `Description:`, description); + } + + if (prompt) { + lines.push(``, `Instructions:`, prompt); + } + + lines.push( + ``, + wrapAgentBlock(`Use the board MCP tools to work this task correctly: +1. Check the latest full context before starting: + task_get { teamName: "${context.teamName}", taskId: "${task.id}" } +2. When you actually begin work, mark it started: + task_start { teamName: "${context.teamName}", taskId: "${task.id}" } +3. When the work is done, mark it completed: + task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }`) + ); + + return lines.join('\n'); +} + +function maybeNotifyAssignedOwner(context, task, options = {}) { + const owner = normalizeActorName(task.owner); + if (!owner || task.status === 'deleted') { + return; + } + + const leadName = runtimeHelpers.inferLeadName(context.paths); + const sender = normalizeActorName(options.from) || leadName; + const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths); + if (isSameMember(owner, leadName) || isSameMember(owner, sender)) { + return; + } + + const summary = options.summary || `New task #${task.displayId || task.id} assigned`; + messages.sendMessage(context, { + member: owner, + from: sender, + text: buildAssignmentMessage(context, task, options), + summary, + source: 'system_notification', + ...(leadSessionId ? { leadSessionId } : {}), + }); +} function createTask(context, input) { - return taskStore.createTask(context.paths, input); + const task = taskStore.createTask(context.paths, input); + if (input && input.notifyOwner !== false) { + maybeNotifyAssignedOwner(context, task, { + description: input.description, + prompt: input.prompt, + from: input.from, + }); + } + return task; } function getTask(context, taskId) { @@ -44,7 +120,20 @@ function restoreTask(context, taskId, actor) { } function setTaskOwner(context, taskId, owner) { - return taskStore.setTaskOwner(context.paths, taskId, owner); + const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true }); + const updatedTask = taskStore.setTaskOwner(context.paths, taskId, owner); + + if ( + owner != null && + normalizeActorName(updatedTask.owner) && + !isSameMember(previousTask.owner, updatedTask.owner) + ) { + maybeNotifyAssignedOwner(context, updatedTask, { + summary: `Task #${updatedTask.displayId || updatedTask.id} assigned`, + }); + } + + return updatedTask; } function updateTaskFields(context, taskId, fields) { diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index c6a2894a..0159ee79 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -118,6 +118,60 @@ describe('agent-teams-controller API', () => { expect(rows[1].id).toBe(registered.id); }); + it('keeps assigned tasks pending by default, supports explicit immediate start, notifies owners, and keeps briefing compact', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + + const pendingTask = controller.tasks.createTask({ + subject: 'Queued task', + description: 'Do this later', + owner: 'bob', + prompt: 'Check the migration plan first.', + }); + const activeTask = controller.tasks.createTask({ + subject: 'Active task', + description: 'Resume immediately', + owner: 'bob', + startImmediately: true, + }); + const completedTask = controller.tasks.createTask({ + subject: 'Already done', + description: 'Completed task description should stay out of compact rows', + owner: 'bob', + }); + controller.tasks.completeTask(completedTask.id, 'bob'); + controller.tasks.addTaskComment(activeTask.id, { from: 'bob', text: 'Resumed work with latest context.' }); + + const reassignedTask = controller.tasks.createTask({ subject: 'Reassigned later' }); + controller.tasks.setTaskOwner(reassignedTask.id, 'bob'); + + expect(pendingTask.status).toBe('pending'); + expect(activeTask.status).toBe('in_progress'); + + const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json'); + const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8')); + expect(ownerInbox).toHaveLength(4); + expect(ownerInbox[0].summary).toContain(`#${pendingTask.displayId}`); + expect(ownerInbox[0].text).toContain('task_get'); + expect(ownerInbox[0].text).toContain('task_start'); + expect(ownerInbox[0].leadSessionId).toBe('lead-session-1'); + expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`); + + const briefing = await controller.tasks.taskBriefing('bob'); + expect(briefing).toContain('In progress:'); + expect(briefing).toContain(`#${activeTask.displayId}`); + expect(briefing).toContain('Description: Resume immediately'); + expect(briefing).toContain('Resumed work with latest context.'); + expect(briefing).toContain('Pending:'); + expect(briefing).toContain(`#${pendingTask.displayId}`); + expect(briefing).not.toContain('Description: Do this later'); + expect(briefing).toContain('Completed:'); + expect(briefing).toContain(`#${completedTask.displayId}`); + expect(briefing).not.toContain( + 'Completed task description should stay out of compact rows' + ); + }); + it('reconciles stale kanban rows and linked inbox comments idempotently', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 8921a11f..94bdaf07 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -36,7 +36,7 @@ export function registerTaskTools(server: Pick) { ...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}), ...(related?.length ? { related: related.join(',') } : {}), ...(prompt ? { prompt } : {}), - ...(startImmediately === false && owner ? { status: 'pending' } : {}), + ...(startImmediately !== undefined ? { startImmediately } : {}), }) ) ); diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index ff9005c1..ce309eb5 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -96,6 +96,7 @@ describe('agent-teams-mcp tools', () => { owner: 'alice', }) ); + expect(createdTask.status).toBe('pending'); const listedTasks = parseJsonToolResult( await getTool('task_list').execute({ @@ -276,6 +277,108 @@ describe('agent-teams-mcp tools', () => { ); }); + it('keeps owner-backed MCP tasks pending by default, supports explicit startImmediately, sends owner notifications, and returns compact task_briefing output', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'gamma'; + + const queuedTask = parseJsonToolResult( + await getTool('task_create').execute({ + claudeDir, + teamName, + subject: 'Queued work', + description: 'Pending description should stay out of briefing details', + owner: 'alice', + prompt: 'Read the plan before starting.', + }) + ); + expect(queuedTask.status).toBe('pending'); + + const activeTask = parseJsonToolResult( + await getTool('task_create').execute({ + claudeDir, + teamName, + subject: 'Active work', + description: 'This one is already in progress', + owner: 'alice', + startImmediately: true, + }) + ); + expect(activeTask.status).toBe('in_progress'); + + await getTool('task_add_comment').execute({ + claudeDir, + teamName, + taskId: activeTask.id, + text: 'Investigating the active task now.', + from: 'alice', + }); + + const completedTask = parseJsonToolResult( + await getTool('task_create').execute({ + claudeDir, + teamName, + subject: 'Done work', + description: 'Completed description should also stay compact', + owner: 'alice', + }) + ); + await getTool('task_complete').execute({ + claudeDir, + teamName, + taskId: completedTask.id, + actor: 'alice', + }); + + const unassignedTask = parseJsonToolResult( + await getTool('task_create').execute({ + claudeDir, + teamName, + subject: 'Assign later', + }) + ); + await getTool('task_set_owner').execute({ + claudeDir, + teamName, + taskId: unassignedTask.id, + owner: 'alice', + }); + + const queuedByHashRef = parseJsonToolResult( + await getTool('task_get').execute({ + claudeDir, + teamName, + taskId: `#${queuedTask.displayId}`, + }) + ); + expect(queuedByHashRef.id).toBe(queuedTask.id); + + const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'); + const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8')); + expect(ownerInbox).toHaveLength(4); + expect(ownerInbox[0].summary).toContain(`#${queuedTask.displayId}`); + expect(ownerInbox[0].text).toContain('task_get'); + expect(ownerInbox[0].text).toContain('task_start'); + expect(ownerInbox[0].text).toContain('Read the plan before starting.'); + expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`); + + const briefing = (await getTool('task_briefing').execute({ + claudeDir, + teamName, + memberName: 'alice', + })) as { content: Array<{ text: string }> }; + const briefingText = briefing.content[0]?.text ?? ''; + expect(briefingText).toContain('In progress:'); + expect(briefingText).toContain(`#${activeTask.displayId}`); + expect(briefingText).toContain('Description: This one is already in progress'); + expect(briefingText).toContain('Investigating the active task now.'); + expect(briefingText).toContain('Pending:'); + expect(briefingText).toContain(`#${queuedTask.displayId}`); + expect(briefingText).not.toContain('Pending description should stay out of briefing details'); + expect(briefingText).toContain('Completed:'); + expect(briefingText).toContain(`#${completedTask.displayId}`); + expect(briefingText).not.toContain('Completed description should also stay compact'); + }); + it('covers review_request_changes and full process lifecycle tools', async () => { const claudeDir = makeClaudeDir(); const teamName = 'beta'; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ab9e2c29..93b46c34 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -742,7 +742,7 @@ export class TeamDataService { /* best-effort */ } - const shouldStart = request.owner && request.startImmediately !== false; + const shouldStart = request.owner && request.startImmediately === true; const task = controller.tasks.createTask({ subject: request.subject, ...(request.description?.trim() ? { description: request.description.trim() } : {}), @@ -751,48 +751,10 @@ export class TeamDataService { ...(related.length > 0 ? { related } : {}), ...(projectPath ? { projectPath } : {}), createdBy: 'user', - ...(shouldStart ? { status: 'in_progress' } : { status: 'pending' }), + ...(request.prompt?.trim() ? { prompt: request.prompt.trim() } : {}), + ...(shouldStart ? { startImmediately: true } : {}), }) as TeamTask; - if (shouldStart && request.owner) { - try { - const leadName = await this.resolveLeadName(teamName); - - // Skip inbox notification when lead assigns a task to themselves (solo teams) - if (!this.isLeadOwner(request.owner, leadName)) { - // Build notification with full context — inbox is the primary delivery - // channel to agents (Claude Code monitors inbox via fs.watch) - const parts = [`New task assigned to you: ${this.getTaskLabel(task)} "${task.subject}".`]; - - if (request.description?.trim()) { - parts.push(`\nDescription:\n${request.description.trim()}`); - } - - if (request.prompt?.trim()) { - parts.push(`\nInstructions:\n${request.prompt.trim()}`); - } - - parts.push( - `\n${AGENT_BLOCK_OPEN}`, - `Update task status using the board MCP tools:`, - `task_start { teamName: "${teamName}", taskId: "${task.id}" }`, - `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, - AGENT_BLOCK_CLOSE - ); - - await this.sendMessage(teamName, { - member: request.owner, - from: leadName, - text: parts.join('\n'), - summary: `New task ${this.getTaskLabel(task)} assigned`, - source: 'system_notification', - }); - } - } catch { - // Best-effort notification — don't fail task creation if message fails - } - } - return task; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index d064cc25..e58b305f 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -352,6 +352,7 @@ function buildMemberSpawnPrompt( ${getAgentLanguageInstruction()} Introduce yourself briefly (name and role) and confirm you are ready. Then wait for task assignments. +When you later receive work or reconnect after a restart, use task_briefing as your compact queue view, then call task_get for the specific task you are about to resume or start. ${buildTeammateAgentBlockReminder()} Include the following agent-only instructions verbatim in the prompt: @@ -363,8 +364,9 @@ ${processRegistration}`; function buildTaskStatusProtocol(teamName: string): string { return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 0. IMPORTANT ID RULE: - - In MCP tool calls, ALWAYS use the exact canonical taskId value shown in the board/task snapshot. - - Human-facing summaries may use the short display label like #abcd1234 for readability. + - If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls. + - task_briefing may show short display labels like #abcd1234; MCP task tools also accept that short task ref. + - Human-facing summaries should use the short display label like #abcd1234 for readability. 1. Use MCP tool task_start to mark task started: { teamName: "${teamName}", taskId: "" } - Start the task ONLY when you are actually beginning work on it. @@ -404,6 +406,11 @@ function buildTaskStatusProtocol(teamName: string): string { 12. DEPENDENCY AWARENESS: When your task has blockedBy dependencies, check if they are completed before starting. When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed. +13. TASK QUEUE DISCIPLINE: + - Use task_briefing as a compact queue view of your assigned tasks. + - task_briefing may include full description/comments only for in_progress tasks; pending/completed entries may be minimal on purpose. + - Before resuming any in_progress task, call task_get for that task to refresh full context. + - When you become free, call task_briefing again, inspect pending tasks, then call task_get for the specific pending task you are about to start before task_start. Failure to follow this protocol means the task board will show incorrect status.`); } @@ -627,7 +634,7 @@ function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string : ''; return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${t.status}] ${t.subject}${deps}${desc}`; }); - return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`; + return `\nYour active tasks from last session (resume in_progress first, then pending):\n${lines.join('\n')}\n`; } /** Build a full task board snapshot for the lead. */ @@ -649,7 +656,7 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string { : ''; return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${t.status}]${owner} ${t.subject}${deps}${desc}`; }); - return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`; + return `\nCurrent task board (in_progress/pending):\n${lines.join('\n')}\n`; } function buildProvisioningPrompt(request: TeamCreateRequest): string { @@ -803,13 +810,15 @@ function buildLaunchPrompt( ${languageInstruction} The team has been reconnected after a restart. - ${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'} + ${hasTasks ? `You may have in_progress or pending tasks from the previous session.` : 'You have no assigned tasks currently.'} ${buildTeammateAgentBlockReminder()} Your FIRST action: call MCP tool task_briefing with: { teamName: "${request.teamName}", memberName: "${m.name}" } - Then resume in_progress tasks first, then pending tasks. - If you have no tasks, wait for new assignments.`; + Then: + - If task_briefing shows any in_progress task, call task_get for each in_progress task first and resume/finish those before touching pending tasks. + - Only after there are no active in_progress tasks, inspect pending tasks from task_briefing, call task_get for the specific pending task you choose, and only then run task_start when you truly begin. + - If you have no tasks, wait for new assignments.`; }) .join('\n\n'); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index aa068075..686ce101 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -179,17 +179,6 @@ export const MessageComposer = ({ } }, [sending, sendError, draft]); - const handleKeyDownCapture = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - handleSend(); - } - }, - [handleSend] - ); - const { addFiles: draftAddFiles } = draft; const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { @@ -280,7 +269,6 @@ export const MessageComposer = ({
{ }); it('creates task with status pending when startImmediately is false', async () => { - const createTaskMock = vi.fn((task) => task); + const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' })); const service = new TeamDataService( { listTeams: vi.fn(), @@ -274,8 +274,66 @@ describe('TeamDataService', () => { expect(result.status).toBe('pending'); expect(createTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' }) + expect.objectContaining({ owner: 'alice', createdBy: 'user' }) ); + expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ startImmediately: true })); + }); + + it('creates task with explicit immediate start only when startImmediately is true', async () => { + const createTaskMock = vi.fn((task) => ({ ...task, status: 'in_progress' })); + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [] })), + } as never, + { + getNextTaskId: vi.fn(async () => '2'), + getTasks: vi.fn(async () => []), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + { + createTask: createTaskMock, + addBlocksEntry: vi.fn(async () => undefined), + } as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + garbageCollect: vi.fn(async () => undefined), + } as never, + {} as never, + {} as never, + {} as never, + (_teamName: string) => + ({ + tasks: { + createTask: createTaskMock, + }, + }) as never + ); + + const result = await service.createTask('my-team', { + subject: 'Start now', + owner: 'alice', + startImmediately: true, + prompt: 'Begin immediately.', + }); + + expect(result.status).toBe('in_progress'); + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'alice', + createdBy: 'user', + startImmediately: true, + prompt: 'Begin immediately.', + }) + ); + expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ status: 'in_progress' })); }); it('persists explicit related task links when creating a task', async () => { diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 9d366bbd..729d736d 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -216,6 +216,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`); expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".'); + expect(prompt).toContain('use task_briefing as your compact queue view'); + expect(prompt).toContain('then call task_get for the specific task you are about to resume or start'); + expect(prompt).toContain('Use task_briefing as a compact queue view of your assigned tasks.'); await svc.cancelProvisioning(runId); }); @@ -273,6 +276,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`); expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".'); + expect(prompt).toContain('Your FIRST action: call MCP tool task_briefing'); + expect(prompt).toContain('call task_get for each in_progress task first'); + expect(prompt).toContain('call task_get for the specific pending task you choose'); await svc.cancelProvisioning(runId); });