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.
This commit is contained in:
iliya 2026-03-09 12:47:06 +02:00
parent b369b779cc
commit 9c63959a08
11 changed files with 430 additions and 114 deletions

View file

@ -86,7 +86,7 @@ function getPaths(flags, teamName) {
} }
function inferLeadName(paths) { function inferLeadName(paths) {
const config = readJson(path.join(paths.teamDir, 'config.json'), null); const config = readTeamConfig(paths);
if (!config || !Array.isArray(config.members)) { if (!config || !Array.isArray(config.members)) {
return 'team-lead'; return 'team-lead';
} }
@ -99,6 +99,17 @@ function inferLeadName(paths) {
return config.members[0] ? String(config.members[0].name) : 'team-lead'; 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) { function isProcessAlive(pid) {
try { try {
process.kill(pid, 0); process.kill(pid, 0);
@ -291,5 +302,7 @@ module.exports = {
getPaths, getPaths,
inferLeadName, inferLeadName,
isProcessAlive, isProcessAlive,
readTeamConfig,
resolveLeadSessionId,
saveTaskAttachmentFile, saveTaskAttachmentFile,
}; };

View file

@ -111,7 +111,7 @@ function listTasks(paths, options = {}) {
} }
function resolveTaskRef(paths, taskRef, options = {}) { function resolveTaskRef(paths, taskRef, options = {}) {
const normalizedRef = String(taskRef || '').trim(); const normalizedRef = String(taskRef || '').trim().replace(/^#/, '');
if (!normalizedRef) { if (!normalizedRef) {
throw new Error('Missing taskId'); throw new Error('Missing taskId');
} }
@ -168,7 +168,8 @@ function computeInitialStatus(paths, input, owner, blockedByIds) {
const explicit = normalizeStatus(input.status); const explicit = normalizeStatus(input.status);
if (explicit) return explicit; if (explicit) return explicit;
if (blockedByIds.length > 0) return 'pending'; if (blockedByIds.length > 0) return 'pending';
return owner ? 'in_progress' : 'pending'; if (owner && input.startImmediately === true) return 'in_progress';
return 'pending';
} }
function pickTaskId(input) { function pickTaskId(input) {
@ -577,6 +578,45 @@ function buildTaskReference(task) {
return `#${task.displayId || deriveDisplayId(task.id)} (taskId: ${task.id})`; 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) { function formatTaskBriefing(paths, teamName, memberName) {
const kanbanState = readJson(path.join(paths.teamDir, 'kanban-state.json'), { const kanbanState = readJson(path.join(paths.teamDir, 'kanban-state.json'), {
teamName, teamName,
@ -585,57 +625,51 @@ function formatTaskBriefing(paths, teamName, memberName) {
}); });
const activeTasks = listTasks(paths) const activeTasks = listTasks(paths)
.filter((task) => task.owner === memberName && task.status !== 'deleted') .filter((task) => task.owner === memberName && task.status !== 'deleted')
.sort((a, b) => String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, { .sort(compareTasksForBriefing);
numeric: true,
sensitivity: 'base',
}));
if (activeTasks.length === 0) { if (activeTasks.length === 0) {
return `No pending tasks for ${memberName}.`; return `No assigned tasks for ${memberName}.`;
} }
const lines = []; const groups = {
for (const task of activeTasks) { in_progress: activeTasks.filter((task) => task.status === 'in_progress'),
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined; pending: activeTasks.filter((task) => task.status === 'pending'),
const reviewState = kanbanEntry && kanbanEntry.column ? `, review=${kanbanEntry.column}` : ''; completed: activeTasks.filter((task) => task.status === 'completed'),
const effectiveReviewState = };
normalizeTaskReviewState(task.reviewState) !== 'none'
? normalizeTaskReviewState(task.reviewState) const lines = [`Task briefing for ${memberName}:`];
: reviewState
? String(kanbanEntry.column) if (groups.in_progress.length > 0) {
: 'none'; lines.push('', 'In progress:');
lines.push( for (const task of groups.in_progress) {
`${buildTaskReference(task)} [status=${task.status}${effectiveReviewState !== 'none' ? `, review=${effectiveReviewState}` : ''}] ${task.subject}` const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
); const reviewState = getEffectiveReviewState(kanbanEntry, task);
if (task.description) lines.push(` Description: ${task.description}`); lines.push(formatBriefTaskLine(task, reviewState));
if (task.blockedBy && task.blockedBy.length > 0) { if (task.description) {
const blockedLabels = task.blockedBy lines.push(` Description: ${task.description}`);
.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}`);
} }
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)));
} }
} }

View file

@ -1,8 +1,84 @@
const taskStore = require('./taskStore.js'); const taskStore = require('./taskStore.js');
const runtimeHelpers = require('./runtimeHelpers.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) { 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) { function getTask(context, taskId) {
@ -44,7 +120,20 @@ function restoreTask(context, taskId, actor) {
} }
function setTaskOwner(context, taskId, owner) { 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) { function updateTaskFields(context, taskId, fields) {

View file

@ -118,6 +118,60 @@ describe('agent-teams-controller API', () => {
expect(rows[1].id).toBe(registered.id); 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', () => { it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -36,7 +36,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}), ...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}),
...(related?.length ? { related: related.join(',') } : {}), ...(related?.length ? { related: related.join(',') } : {}),
...(prompt ? { prompt } : {}), ...(prompt ? { prompt } : {}),
...(startImmediately === false && owner ? { status: 'pending' } : {}), ...(startImmediately !== undefined ? { startImmediately } : {}),
}) })
) )
); );

View file

@ -96,6 +96,7 @@ describe('agent-teams-mcp tools', () => {
owner: 'alice', owner: 'alice',
}) })
); );
expect(createdTask.status).toBe('pending');
const listedTasks = parseJsonToolResult( const listedTasks = parseJsonToolResult(
await getTool('task_list').execute({ 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 () => { it('covers review_request_changes and full process lifecycle tools', async () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const teamName = 'beta'; const teamName = 'beta';

View file

@ -742,7 +742,7 @@ export class TeamDataService {
/* best-effort */ /* best-effort */
} }
const shouldStart = request.owner && request.startImmediately !== false; const shouldStart = request.owner && request.startImmediately === true;
const task = controller.tasks.createTask({ const task = controller.tasks.createTask({
subject: request.subject, subject: request.subject,
...(request.description?.trim() ? { description: request.description.trim() } : {}), ...(request.description?.trim() ? { description: request.description.trim() } : {}),
@ -751,48 +751,10 @@ export class TeamDataService {
...(related.length > 0 ? { related } : {}), ...(related.length > 0 ? { related } : {}),
...(projectPath ? { projectPath } : {}), ...(projectPath ? { projectPath } : {}),
createdBy: 'user', createdBy: 'user',
...(shouldStart ? { status: 'in_progress' } : { status: 'pending' }), ...(request.prompt?.trim() ? { prompt: request.prompt.trim() } : {}),
...(shouldStart ? { startImmediately: true } : {}),
}) as TeamTask; }) 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; return task;
} }

View file

@ -352,6 +352,7 @@ function buildMemberSpawnPrompt(
${getAgentLanguageInstruction()} ${getAgentLanguageInstruction()}
Introduce yourself briefly (name and role) and confirm you are ready. Introduce yourself briefly (name and role) and confirm you are ready.
Then wait for task assignments. 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()} ${buildTeammateAgentBlockReminder()}
Include the following agent-only instructions verbatim in the prompt: Include the following agent-only instructions verbatim in the prompt:
@ -363,8 +364,9 @@ ${processRegistration}`;
function buildTaskStatusProtocol(teamName: string): string { function buildTaskStatusProtocol(teamName: string): string {
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
0. IMPORTANT ID RULE: 0. IMPORTANT ID RULE:
- In MCP tool calls, ALWAYS use the exact canonical taskId value shown in the board/task snapshot. - If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls.
- Human-facing summaries may use the short display label like #abcd1234 for readability. - 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: 1. Use MCP tool task_start to mark task started:
{ teamName: "${teamName}", taskId: "<taskId>" } { teamName: "${teamName}", taskId: "<taskId>" }
- Start the task ONLY when you are actually beginning work on it. - Start the task ONLY when you are actually beginning work on it.
@ -404,6 +406,11 @@ function buildTaskStatusProtocol(teamName: string): string {
12. DEPENDENCY AWARENESS: 12. DEPENDENCY AWARENESS:
When your task has blockedBy dependencies, check if they are completed before starting. 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. 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.`); 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 ` - ${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. */ /** 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 ` - ${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 { function buildProvisioningPrompt(request: TeamCreateRequest): string {
@ -803,13 +810,15 @@ function buildLaunchPrompt(
${languageInstruction} ${languageInstruction}
The team has been reconnected after a restart. 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()} ${buildTeammateAgentBlockReminder()}
Your FIRST action: call MCP tool task_briefing with: Your FIRST action: call MCP tool task_briefing with:
{ teamName: "${request.teamName}", memberName: "${m.name}" } { teamName: "${request.teamName}", memberName: "${m.name}" }
Then resume in_progress tasks first, then pending tasks. Then:
If you have no tasks, wait for new assignments.`; - 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'); .join('\n\n');

View file

@ -179,17 +179,6 @@ export const MessageComposer = ({
} }
}, [sending, sendError, draft]); }, [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 { addFiles: draftAddFiles } = draft;
const handleFileInputChange = useCallback( const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
@ -280,7 +269,6 @@ export const MessageComposer = ({
<div <div
className="relative mb-3 p-3" className="relative mb-3 p-3"
role="group" role="group"
onKeyDownCapture={handleKeyDownCapture}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}

View file

@ -229,7 +229,7 @@ describe('TeamDataService', () => {
}); });
it('creates task with status pending when startImmediately is false', async () => { 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( const service = new TeamDataService(
{ {
listTeams: vi.fn(), listTeams: vi.fn(),
@ -274,8 +274,66 @@ describe('TeamDataService', () => {
expect(result.status).toBe('pending'); expect(result.status).toBe('pending');
expect(createTaskMock).toHaveBeenCalledWith( 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 () => { it('persists explicit related task links when creating a task', async () => {

View file

@ -216,6 +216,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`); expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".'); 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); 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_OPEN}`);
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`); expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".'); 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); await svc.cancelProvisioning(runId);
}); });