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) {
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,
};

View file

@ -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)));
}
}

View file

@ -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) {

View file

@ -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 });

View file

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

View file

@ -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';

View file

@ -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;
}

View file

@ -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: "<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');

View file

@ -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<HTMLInputElement>) => {
@ -280,7 +269,6 @@ export const MessageComposer = ({
<div
className="relative mb-3 p-3"
role="group"
onKeyDownCapture={handleKeyDownCapture}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}

View file

@ -229,7 +229,7 @@ describe('TeamDataService', () => {
});
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 () => {

View file

@ -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);
});