From fadd4e04c57127b768d48392dca7a8f901dc093a Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 16 Mar 2026 13:39:35 +0200 Subject: [PATCH] feat: enhance task creation and message handling with exact messageId support - Updated `lookupMessage` function to enforce exact messageId matching, rejecting ambiguous cases. - Improved documentation for `task_create_from_message` to clarify its requirements and usage. - Enhanced `buildAssignmentMessage` and related functions to include messageId in task notifications for better provenance tracking. - Added tests to validate the integration of messageId in task creation and relay processes, ensuring accurate message handling. --- .../src/internal/messageStore.js | 10 +- agent-teams-controller/src/internal/tasks.js | 725 +++++++++--------- mcp-server/src/tools/taskTools.ts | 15 +- .../services/team/TeamMemberLogsFinder.ts | 6 +- .../services/team/TeamProvisioningService.ts | 4 + .../TeamProvisioningServicePrompts.test.ts | 1 + .../team/TeamProvisioningServiceRelay.test.ts | 86 +++ 7 files changed, 475 insertions(+), 372 deletions(-) diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index 701f8204..c966edd7 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -168,11 +168,11 @@ function appendSentMessage(paths, flags) { /** * Exact readonly lookup by messageId across sent messages and all inbox files. * - * Rules: - * - Match only rows where row.messageId === requestedMessageId. - * - Ignore rows where only relayOfMessageId matches. - * - If more than one exact match exists, reject as ambiguous. - * - Returns { message, store } or throws. + * Used by task_create_from_message to resolve provenance. Lookup is exact-messageId + * only and must never resolve by relayOfMessageId, text matching, or active context. + * Must reject ambiguous matches (same messageId in multiple stores) instead of guessing. + * + * Returns { message, store } or throws. */ function lookupMessage(paths, messageId) { const id = typeof messageId === 'string' ? messageId.trim() : ''; diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index c7d4bd35..e2809bb2 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -5,63 +5,63 @@ const processStore = require('./processStore.js'); const { wrapAgentBlock } = require('./agentBlocks.js'); function normalizeActorName(value) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; + return typeof value === 'string' && value.trim() ? value.trim() : ''; } function isSameMember(left, right) { - return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase(); + return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase(); } function isSameTaskMember(left, right, leadName) { - const normalizedLeft = normalizeActorName(left).toLowerCase(); - const normalizedRight = normalizeActorName(right).toLowerCase(); - const normalizedLead = normalizeActorName(leadName).toLowerCase(); - if (!normalizedLeft || !normalizedRight) { - return false; - } - if (normalizedLeft === normalizedRight) { - return true; - } - return ( - (normalizedLeft === 'team-lead' && normalizedRight === normalizedLead) || - (normalizedRight === 'team-lead' && normalizedLeft === normalizedLead) - ); + const normalizedLeft = normalizeActorName(left).toLowerCase(); + const normalizedRight = normalizeActorName(right).toLowerCase(); + const normalizedLead = normalizeActorName(leadName).toLowerCase(); + if (!normalizedLeft || !normalizedRight) { + return false; + } + if (normalizedLeft === normalizedRight) { + return true; + } + return ( + (normalizedLeft === 'team-lead' && normalizedRight === normalizedLead) || + (normalizedRight === 'team-lead' && normalizedLeft === normalizedLead) + ); } function quoteMarkdown(text) { - return String(text) - .split('\n') - .map((line) => `> ${line}`) - .join('\n'); + return String(text) + .split('\n') + .map((line) => `> ${line}`) + .join('\n'); } 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 you are idle and this task is ready to start, start it now. If you are busy, blocked, or still need more context, immediately add a short task comment with the reason and your best ETA or what you are waiting on, and keep this task in TODO until you actually begin.*`, - ]; + 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 you are idle and this task is ready to start, start it now. If you are busy, blocked, or still need more context, immediately add a short task comment with the reason and your best ETA or what you are waiting on, and keep this task in TODO until you actually begin.*`, + ]; - if (description) { - lines.push(``, `Description:`, description); - } + if (description) { + lines.push(``, `Description:`, description); + } - if (prompt) { - lines.push(``, `Instructions:`, prompt); - } + if (prompt) { + lines.push(``, `Instructions:`, prompt); + } - lines.push( - ``, - wrapAgentBlock(`Use the board MCP tools to work this task correctly: + 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. If you are idle and the task is ready to start after checking dependencies and context, call task_start now: @@ -70,304 +70,301 @@ function buildAssignmentMessage(context, task, options = {}) { task_add_comment { teamName: "${context.teamName}", taskId: "${task.id}", text: "", from: "" } 4. When the work is done, mark it completed: task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }`) - ); + ); - return lines.join('\n'); + return lines.join('\n'); } function buildCommentNotificationMessage(context, task, comment) { - const taskLabel = `#${task.displayId || task.id}`; - return [ - `**Comment on task ${taskLabel}**`, - `> ${task.subject}`, - ``, - quoteMarkdown(comment.text), - ``, - wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment: + const taskLabel = `#${task.displayId || task.id}`; + return [ + `**Comment on task ${taskLabel}** _${task.subject}_`, + ``, + quoteMarkdown(comment.text), + ``, + wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment: { teamName: "${context.teamName}", taskId: "${task.id}", text: "", from: "" }`), - ].join('\n'); + ].join('\n'); } function maybeNotifyAssignedOwner(context, task, options = {}) { - const owner = normalizeActorName(task.owner); - if (!owner || task.status === 'deleted') { - return; - } + 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 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), - taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined, - summary, - source: 'system_notification', - ...(leadSessionId ? { leadSessionId } : {}), - }); + const summary = options.summary || `New task #${task.displayId || task.id} assigned`; + messages.sendMessage(context, { + member: owner, + from: sender, + text: buildAssignmentMessage(context, task, options), + taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined, + summary, + source: 'system_notification', + ...(leadSessionId ? { leadSessionId } : {}), + }); } function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) { - if (!options.inserted || options.notifyOwner === false) { - return; - } - if (!task || task.status === 'deleted') { - return; - } - if (comment.type && comment.type !== 'regular') { - return; - } + if (!options.inserted || options.notifyOwner === false) { + return; + } + if (!task || task.status === 'deleted') { + return; + } + if (comment.type && comment.type !== 'regular') { + return; + } - const owner = normalizeActorName(task.owner); - if (!owner) { - return; - } + const owner = normalizeActorName(task.owner); + if (!owner) { + return; + } - const leadName = runtimeHelpers.inferLeadName(context.paths); - if (isSameTaskMember(owner, comment.author, leadName)) { - return; - } + const leadName = runtimeHelpers.inferLeadName(context.paths); + if (isSameTaskMember(owner, comment.author, leadName)) { + return; + } - const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths); - messages.sendMessage(context, { - member: owner, - from: normalizeActorName(comment.author) || leadName, - text: buildCommentNotificationMessage(context, task, comment), - taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined, - summary: `Comment on #${task.displayId || task.id}`, - source: 'system_notification', - ...(leadSessionId ? { leadSessionId } : {}), - }); + const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths); + messages.sendMessage(context, { + member: owner, + from: normalizeActorName(comment.author) || leadName, + text: buildCommentNotificationMessage(context, task, comment), + taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined, + summary: `Comment on #${task.displayId || task.id}`, + source: 'system_notification', + ...(leadSessionId ? { leadSessionId } : {}), + }); } function createTask(context, input) { - const task = taskStore.createTask(context.paths, input); - if (input && input.notifyOwner !== false) { - maybeNotifyAssignedOwner(context, task, { - description: input.description, - prompt: input.prompt, - taskRefs: [ - ...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []), - ...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []), - ], - from: input.from, - }); - } - return task; + const task = taskStore.createTask(context.paths, input); + if (input && input.notifyOwner !== false) { + maybeNotifyAssignedOwner(context, task, { + description: input.description, + prompt: input.prompt, + taskRefs: [ + ...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []), + ...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []), + ], + from: input.from, + }); + } + return task; } function getTask(context, taskId) { - return taskStore.readTask(context.paths, taskId, { includeDeleted: true }); + return taskStore.readTask(context.paths, taskId, { includeDeleted: true }); } function listTasks(context) { - return taskStore.listTasks(context.paths); + return taskStore.listTasks(context.paths); } function listDeletedTasks(context) { - return taskStore.listTasks(context.paths, { includeDeleted: true }).filter( - (task) => task.status === 'deleted' - ); + return taskStore.listTasks(context.paths, { includeDeleted: true }).filter( + (task) => task.status === 'deleted' + ); } function resolveTaskId(context, taskRef) { - return taskStore.resolveTaskRef(context.paths, taskRef, { includeDeleted: true }); + return taskStore.resolveTaskRef(context.paths, taskRef, { includeDeleted: true }); } function setTaskStatus(context, taskId, status, actor) { - return taskStore.setTaskStatus(context.paths, taskId, status, actor); + return taskStore.setTaskStatus(context.paths, taskId, status, actor); } function startTask(context, taskId, actor) { - const task = setTaskStatus(context, taskId, 'in_progress', actor); - // Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened - try { - const kanbanStore = require('./kanbanStore.js'); - const state = kanbanStore.readKanbanState(context.paths, context.teamName); - if (state.tasks[task.id]) { - delete state.tasks[task.id]; - kanbanStore.writeKanbanState(context.paths, context.teamName, state); + const task = setTaskStatus(context, taskId, 'in_progress', actor); + // Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened + try { + const kanbanStore = require('./kanbanStore.js'); + const state = kanbanStore.readKanbanState(context.paths, context.teamName); + if (state.tasks[task.id]) { + delete state.tasks[task.id]; + kanbanStore.writeKanbanState(context.paths, context.teamName, state); + } + } catch { + // Best-effort: task status already updated, kanban cleanup failure is non-fatal } - } catch { - // Best-effort: task status already updated, kanban cleanup failure is non-fatal - } - return task; + return task; } function completeTask(context, taskId, actor) { - return setTaskStatus(context, taskId, 'completed', actor); + return setTaskStatus(context, taskId, 'completed', actor); } function softDeleteTask(context, taskId, actor) { - return setTaskStatus(context, taskId, 'deleted', actor); + return setTaskStatus(context, taskId, 'deleted', actor); } function restoreTask(context, taskId, actor) { - return setTaskStatus(context, taskId, 'pending', actor || 'user'); + return setTaskStatus(context, taskId, 'pending', actor || 'user'); } function setTaskOwner(context, taskId, owner) { - const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true }); - const updatedTask = 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`, - }); - } + if ( + owner != null && + normalizeActorName(updatedTask.owner) && + !isSameMember(previousTask.owner, updatedTask.owner) + ) { + maybeNotifyAssignedOwner(context, updatedTask, { + summary: `Task #${updatedTask.displayId || updatedTask.id} assigned`, + }); + } - return updatedTask; + return updatedTask; } function updateTaskFields(context, taskId, fields) { - return taskStore.updateTaskFields(context.paths, taskId, fields); + return taskStore.updateTaskFields(context.paths, taskId, fields); } function addTaskComment(context, taskId, flags) { - const result = taskStore.addTaskComment(context.paths, taskId, flags.text, { - author: - typeof flags.from === 'string' && flags.from.trim() - ? flags.from.trim() - : runtimeHelpers.inferLeadName(context.paths), - ...(flags.id ? { id: flags.id } : {}), - ...(flags.createdAt ? { createdAt: flags.createdAt } : {}), - ...(flags.type ? { type: flags.type } : {}), - ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), - ...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}), - }); - - try { - maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, { - inserted: result.inserted, - notifyOwner: flags.notifyOwner, + const result = taskStore.addTaskComment(context.paths, taskId, flags.text, { + author: typeof flags.from === 'string' && flags.from.trim() ? + flags.from.trim() : runtimeHelpers.inferLeadName(context.paths), + ...(flags.id ? { id: flags.id } : {}), + ...(flags.createdAt ? { createdAt: flags.createdAt } : {}), + ...(flags.type ? { type: flags.type } : {}), + ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), + ...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}), }); - } catch (notifyError) { - // Best-effort: comment is already persisted, notification failure must not fail the call - if (typeof console !== 'undefined' && console.warn) { - console.warn( - `[tasks] owner notification failed for task ${taskId}: ${String(notifyError)}` - ); - } - } - return { - commentId: result.comment.id, - taskId: result.task.id, - subject: result.task.subject, - owner: result.task.owner, - task: result.task, - comment: result.comment, - }; + try { + maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, { + inserted: result.inserted, + notifyOwner: flags.notifyOwner, + }); + } catch (notifyError) { + // Best-effort: comment is already persisted, notification failure must not fail the call + if (typeof console !== 'undefined' && console.warn) { + console.warn( + `[tasks] owner notification failed for task ${taskId}: ${String(notifyError)}` + ); + } + } + + return { + commentId: result.comment.id, + taskId: result.task.id, + subject: result.task.subject, + owner: result.task.owner, + task: result.task, + comment: result.comment, + }; } function attachTaskFile(context, taskId, flags) { - const canonicalTaskId = resolveTaskId(context, taskId); - const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags); - const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta); - return { - ...saved.meta, - task, - }; + const canonicalTaskId = resolveTaskId(context, taskId); + const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags); + const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta); + return { + ...saved.meta, + task, + }; } function attachCommentFile(context, taskId, commentId, flags) { - const canonicalTaskId = resolveTaskId(context, taskId); - const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags); - const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta); - return { - ...saved.meta, - task, - }; + const canonicalTaskId = resolveTaskId(context, taskId); + const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags); + const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta); + return { + ...saved.meta, + task, + }; } function addTaskAttachmentMeta(context, taskId, meta) { - return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta); + return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta); } function removeTaskAttachment(context, taskId, attachmentId) { - return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId); + return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId); } function setNeedsClarification(context, taskId, value) { - return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value)); + return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value)); } function linkTask(context, taskId, targetId, linkType) { - return taskStore.linkTask(context.paths, taskId, targetId, String(linkType)); + return taskStore.linkTask(context.paths, taskId, targetId, String(linkType)); } function unlinkTask(context, taskId, targetId, linkType) { - return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType)); + return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType)); } async function taskBriefing(context, memberName) { - return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName)); + return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName)); } function getSystemLocale() { - const lang = typeof process.env.LANG === 'string' ? process.env.LANG.trim() : ''; - if (!lang) return 'en'; - return lang.split('.')[0].replace('_', '-'); + const lang = typeof process.env.LANG === 'string' ? process.env.LANG.trim() : ''; + if (!lang) return 'en'; + return lang.split('.')[0].replace('_', '-'); } function extractPrimaryLanguage(locale) { - const normalized = String(locale || '').trim(); - const dash = normalized.indexOf('-'); - return dash > 0 ? normalized.slice(0, dash) : normalized || 'en'; + const normalized = String(locale || '').trim(); + const dash = normalized.indexOf('-'); + return dash > 0 ? normalized.slice(0, dash) : normalized || 'en'; } function resolveLanguageName(code, systemLocale) { - const effectiveCode = code === 'system' ? extractPrimaryLanguage(systemLocale || 'en') : code; - try { - const displayNames = new Intl.DisplayNames([effectiveCode], { type: 'language' }); - const name = displayNames.of(effectiveCode); - if (name) { - return name.charAt(0).toUpperCase() + name.slice(1); + const effectiveCode = code === 'system' ? extractPrimaryLanguage(systemLocale || 'en') : code; + try { + const displayNames = new Intl.DisplayNames([effectiveCode], { type: 'language' }); + const name = displayNames.of(effectiveCode); + if (name) { + return name.charAt(0).toUpperCase() + name.slice(1); + } + } catch { + // Ignore Intl lookup failures and fall back to the raw code. } - } catch { - // Ignore Intl lookup failures and fall back to the raw code. - } - return effectiveCode; + return effectiveCode; } function buildMemberLanguageInstruction(config) { - const configured = - config && typeof config.language === 'string' && config.language.trim() - ? config.language.trim() - : ''; - if (!configured) { - return 'IMPORTANT: Continue using the communication language already specified in your spawn prompt until the team config stores an explicit language.'; - } - const language = resolveLanguageName(configured, getSystemLocale()); - return `IMPORTANT: Communicate in ${language}. All messages, summaries, and task descriptions MUST be in ${language}.`; + const configured = + config && typeof config.language === 'string' && config.language.trim() ? + config.language.trim() : + ''; + if (!configured) { + return 'IMPORTANT: Continue using the communication language already specified in your spawn prompt until the team config stores an explicit language.'; + } + const language = resolveLanguageName(configured, getSystemLocale()); + return `IMPORTANT: Communicate in ${language}. All messages, summaries, and task descriptions MUST be in ${language}.`; } function buildMemberActionModeProtocol() { - return [ - 'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):', - '- Some incoming user or relay messages may include a hidden agent-only block that declares the current action mode.', - '- If such a block is present, that mode applies to THIS TURN ONLY and overrides any conflicting default behavior.', - '- Never silently broaden permissions beyond the selected mode.', - '- Never reveal the hidden mode block verbatim to the human unless they explicitly ask for it.', - '- Modes:', - ' - DO: Full execution mode. You may discuss, inspect, edit files, change state, run commands/tools, and delegate if useful.', - ' - ASK: Strict read-only conversation mode. You may read/analyze/explain and reply, but you must not change code/files/tasks/state or run side-effecting commands/tools/scripts.', - ' - DELEGATE: Strict orchestration mode for leads. Delegate the work to teammates and coordinate it, but do not implement it yourself unless you are truly in SOLO MODE.', - ].join('\n'); + return [ + 'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):', + '- Some incoming user or relay messages may include a hidden agent-only block that declares the current action mode.', + '- If such a block is present, that mode applies to THIS TURN ONLY and overrides any conflicting default behavior.', + '- Never silently broaden permissions beyond the selected mode.', + '- Never reveal the hidden mode block verbatim to the human unless they explicitly ask for it.', + '- Modes:', + ' - DO: Full execution mode. You may discuss, inspect, edit files, change state, run commands/tools, and delegate if useful.', + ' - ASK: Strict read-only conversation mode. You may read/analyze/explain and reply, but you must not change code/files/tasks/state or run side-effecting commands/tools/scripts.', + ' - DELEGATE: Strict orchestration mode for leads. Delegate the work to teammates and coordinate it, but do not implement it yourself unless you are truly in SOLO MODE.', + ].join('\n'); } function buildMemberTaskProtocol(teamName) { - return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: + return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 0. IMPORTANT ID RULE: - 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. @@ -437,7 +434,7 @@ Failure to follow this protocol means the task board will show incorrect status. } function buildMemberProcessProtocol(teamName) { - return wrapAgentBlock(`BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.): + return wrapAgentBlock(`BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.): 1. Launch with & to get PID: pnpm dev & 2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port): @@ -450,7 +447,7 @@ If verification in step 3 fails or the process is missing from the list, re-regi } function buildMemberFormattingProtocol() { - return wrapAgentBlock(`Hidden internal instructions rule (IMPORTANT): + return wrapAgentBlock(`Hidden internal instructions rule (IMPORTANT): - If you send internal operational instructions to another agent/teammate that the human user must NOT see in the UI, wrap ONLY that hidden part in: ... hidden instructions only ... @@ -460,136 +457,136 @@ function buildMemberFormattingProtocol() { } function normalizeMemberName(value) { - return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : ''; + return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : ''; } async function memberBriefing(context, memberName) { - const requestedMemberName = String(memberName).trim(); - const requestedMemberKey = normalizeMemberName(requestedMemberName); - const resolved = runtimeHelpers.resolveTeamMembers(context.paths); - const config = resolved.config || {}; - if (!requestedMemberName) { - throw new Error('Missing member name'); - } - if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) { - throw new Error(`Member is removed from the team: ${requestedMemberName}`); - } - const member = - resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) || - null; - if (!member) { - throw new Error( - `Member not found in team metadata or inboxes: ${requestedMemberName}` - ); - } - const leadName = runtimeHelpers.inferLeadName(context.paths); - const effectiveMember = member; - - const role = - typeof effectiveMember.role === 'string' && effectiveMember.role.trim() - ? effectiveMember.role.trim() - : typeof effectiveMember.agentType === 'string' && effectiveMember.agentType.trim() - ? effectiveMember.agentType.trim() - : 'team member'; - const workflow = - typeof effectiveMember.workflow === 'string' && effectiveMember.workflow.trim() - ? effectiveMember.workflow.trim() - : ''; - const cwd = - typeof effectiveMember.cwd === 'string' && effectiveMember.cwd.trim() - ? effectiveMember.cwd.trim() - : typeof config.projectPath === 'string' && config.projectPath.trim() - ? config.projectPath.trim() - : ''; - - const activeProcesses = processStore - .listProcesses(context.paths) - .filter( - (entry) => - entry && - entry.alive && - normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName) - ); - - const taskQueue = await taskBriefing(context, requestedMemberName); - const lines = [ - `Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`, - `Role: ${role}.`, - `CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.`, - `CRITICAL: A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now. If it must wait because you are already finishing another task, blocked, or still need more context, leave a short task comment on the waiting task immediately with the reason and your best ETA or what you are waiting on, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.`, - `Team lead: ${leadName}.`, - buildMemberLanguageInstruction(config), - `You must NOT start work, claim tasks, or improvise task/process protocol before reading and following this briefing.`, - ]; - - if (workflow) { - lines.push('', 'Workflow:', workflow); - } - - if (cwd) { - lines.push('', `Working directory: ${cwd}`); - } - - lines.push( - '', - `Bootstrap flow:`, - `1. Use this briefing as your durable rules source.`, - `2. Use task_briefing as your compact queue view whenever you need to see assigned work.`, - `3. Before starting a pending or needs-fix task, call task_get for that specific task if you need the full context. A newly assigned task must not remain silently pending/TODO: if you are idle and the task is ready to start, start it now; if it must wait because another task is already active, because it is blocked, or because you still need more context, add a short task comment with the reason + ETA or what you are waiting on and keep it pending/TODO until you actually begin.`, - `4. If this briefing was requested during reconnect, resume in_progress work first, then needs-fix tasks, then pending tasks.`, - `5. If you cannot obtain the context you need, notify your team lead ("${leadName}") and wait instead of guessing.` - ); - - lines.push( - '', - buildMemberActionModeProtocol(), - '', - buildMemberFormattingProtocol(), - '', - buildMemberTaskProtocol(context.teamName), - '', - buildMemberProcessProtocol(context.teamName) - ); - - if (activeProcesses.length > 0) { - lines.push('', 'Active registered processes owned by you:'); - for (const entry of activeProcesses) { - const bits = [`- ${entry.label} (pid ${entry.pid})`]; - if (entry.port != null) bits.push(`port ${entry.port}`); - if (entry.url) bits.push(`url ${entry.url}`); - if (entry.command) bits.push(`command ${entry.command}`); - lines.push(bits.join(', ')); + const requestedMemberName = String(memberName).trim(); + const requestedMemberKey = normalizeMemberName(requestedMemberName); + const resolved = runtimeHelpers.resolveTeamMembers(context.paths); + const config = resolved.config || {}; + if (!requestedMemberName) { + throw new Error('Missing member name'); } - } + if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) { + throw new Error(`Member is removed from the team: ${requestedMemberName}`); + } + const member = + resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) || + null; + if (!member) { + throw new Error( + `Member not found in team metadata or inboxes: ${requestedMemberName}` + ); + } + const leadName = runtimeHelpers.inferLeadName(context.paths); + const effectiveMember = member; - lines.push('', taskQueue); - return lines.join('\n'); + const role = + typeof effectiveMember.role === 'string' && effectiveMember.role.trim() ? + effectiveMember.role.trim() : + typeof effectiveMember.agentType === 'string' && effectiveMember.agentType.trim() ? + effectiveMember.agentType.trim() : + 'team member'; + const workflow = + typeof effectiveMember.workflow === 'string' && effectiveMember.workflow.trim() ? + effectiveMember.workflow.trim() : + ''; + const cwd = + typeof effectiveMember.cwd === 'string' && effectiveMember.cwd.trim() ? + effectiveMember.cwd.trim() : + typeof config.projectPath === 'string' && config.projectPath.trim() ? + config.projectPath.trim() : + ''; + + const activeProcesses = processStore + .listProcesses(context.paths) + .filter( + (entry) => + entry && + entry.alive && + normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName) + ); + + const taskQueue = await taskBriefing(context, requestedMemberName); + const lines = [ + `Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`, + `Role: ${role}.`, + `CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.`, + `CRITICAL: A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now. If it must wait because you are already finishing another task, blocked, or still need more context, leave a short task comment on the waiting task immediately with the reason and your best ETA or what you are waiting on, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.`, + `Team lead: ${leadName}.`, + buildMemberLanguageInstruction(config), + `You must NOT start work, claim tasks, or improvise task/process protocol before reading and following this briefing.`, + ]; + + if (workflow) { + lines.push('', 'Workflow:', workflow); + } + + if (cwd) { + lines.push('', `Working directory: ${cwd}`); + } + + lines.push( + '', + `Bootstrap flow:`, + `1. Use this briefing as your durable rules source.`, + `2. Use task_briefing as your compact queue view whenever you need to see assigned work.`, + `3. Before starting a pending or needs-fix task, call task_get for that specific task if you need the full context. A newly assigned task must not remain silently pending/TODO: if you are idle and the task is ready to start, start it now; if it must wait because another task is already active, because it is blocked, or because you still need more context, add a short task comment with the reason + ETA or what you are waiting on and keep it pending/TODO until you actually begin.`, + `4. If this briefing was requested during reconnect, resume in_progress work first, then needs-fix tasks, then pending tasks.`, + `5. If you cannot obtain the context you need, notify your team lead ("${leadName}") and wait instead of guessing.` + ); + + lines.push( + '', + buildMemberActionModeProtocol(), + '', + buildMemberFormattingProtocol(), + '', + buildMemberTaskProtocol(context.teamName), + '', + buildMemberProcessProtocol(context.teamName) + ); + + if (activeProcesses.length > 0) { + lines.push('', 'Active registered processes owned by you:'); + for (const entry of activeProcesses) { + const bits = [`- ${entry.label} (pid ${entry.pid})`]; + if (entry.port != null) bits.push(`port ${entry.port}`); + if (entry.url) bits.push(`url ${entry.url}`); + if (entry.command) bits.push(`command ${entry.command}`); + lines.push(bits.join(', ')); + } + } + + lines.push('', taskQueue); + return lines.join('\n'); } module.exports = { - addTaskAttachmentMeta, - addTaskComment, - appendHistoryEvent: taskStore.appendHistoryEvent, - attachTaskFile, - attachCommentFile, - completeTask, - createTask, - getTask, - linkTask, - listDeletedTasks, - listTasks, - removeTaskAttachment, - resolveTaskId, - restoreTask, - setNeedsClarification, - setTaskOwner, - setTaskStatus, - softDeleteTask, - startTask, - memberBriefing, - taskBriefing, - unlinkTask, - updateTask: (context, taskRef, updater) => - taskStore.updateTask(context.paths, taskRef, updater), - updateTaskFields, -}; + addTaskAttachmentMeta, + addTaskComment, + appendHistoryEvent: taskStore.appendHistoryEvent, + attachTaskFile, + attachCommentFile, + completeTask, + createTask, + getTask, + linkTask, + listDeletedTasks, + listTasks, + removeTaskAttachment, + resolveTaskId, + restoreTask, + setNeedsClarification, + setTaskOwner, + setTaskStatus, + softDeleteTask, + startTask, + memberBriefing, + taskBriefing, + unlinkTask, + updateTask: (context, taskRef, updater) => + taskStore.updateTask(context.paths, taskRef, updater), + updateTaskFields, +}; \ No newline at end of file diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index c805da27..761e29cb 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -18,8 +18,11 @@ const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']); const USER_ORIGINATED_SOURCES = new Set(['user_sent']); /** - * Shared payload builder for both task_create and task_create_from_message. - * Keeps the canonical create-task shape in one place to avoid divergence. + * Shared payload builder for task_create and task_create_from_message. + * + * Both tools MUST stay semantically aligned — any new field added to task_create + * that also applies to message-derived tasks must be added here, not duplicated. + * Do not turn this into a repo-wide abstraction; keep it local to MCP tools. */ function buildCreateTaskPayload(params: { subject: string; @@ -99,6 +102,14 @@ export function registerTaskTools(server: Pick) { }, }); + /* + * task_create_from_message — creates a task from an exact persisted user message. + * + * This is NOT a heuristic "current context" resolver. It requires an exact messageId + * that points to a persisted row in sentMessages.json or an inbox file. + * Must reject relay copies, non-user sources, and ambiguous matches. + * Must not auto-generate subject or infer importState from attachments. + */ server.addTool({ name: 'task_create_from_message', description: diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 61a074f5..0ee8ab68 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -1518,9 +1518,12 @@ export class TeamMemberLogsFinder { .replace(/\\\\/g, '\\') .replace(/\s+/g, ' ') .trim(); + if (!raw) return null; return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw; } - // Fallback: top-level string content + // Fallback: top-level string content — skip lines with tool_use to avoid + // matching file content from Write/Edit tool inputs. + if (line.includes('"tool_use"')) return null; const contentMatch = /"content"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(line); if (contentMatch?.[1]) { const raw = contentMatch[1] @@ -1530,6 +1533,7 @@ export class TeamMemberLogsFinder { .replace(/\\\\/g, '\\') .replace(/\s+/g, ' ') .trim(); + if (!raw) return null; return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw; } return null; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e667a1ea..b593ee33 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -736,6 +736,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string ``, `Task board operations — use MCP tools directly:`, `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, + `- Create task from user message (preferred when you have a MessageId from a relayed inbox message): task_create_from_message { teamName: "${teamName}", messageId: "", subject: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, `- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: "" }`, `- Clear owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: null }`, `- Start task (preferred over set-status): task_start { teamName: "${teamName}", taskId: "" }`, @@ -3614,6 +3615,7 @@ export class TeamProvisioningService { return [ `${idx + 1}) From: ${m.from || 'unknown'}`, ` Timestamp: ${m.timestamp}`, + ` MessageId: ${m.messageId}`, ...(summaryLine ? [` ${summaryLine}`] : []), ...(typeof m.source === 'string' && m.source.trim() ? [` Source: ${m.source.trim()}`] @@ -3831,6 +3833,7 @@ export class TeamProvisioningService { `IMPORTANT: Your text response here is shown to the user. Always include a brief human-readable summary (e.g. "Delegated to carol." or "No action needed."). Do NOT respond with only an agent-only block.`, AGENT_BLOCK_OPEN, `Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`, + `When creating a task from a user message that has a MessageId field, prefer task_create_from_message with that exact messageId for reliable provenance. Only use task_create_from_message when you have an explicit MessageId — never guess or fabricate one.`, `If a message below is marked Source: system_notification and its summary looks like "Comment on #...", treat it as a task-comment notification that REQUIRES an on-task reply via task_add_comment. Do NOT treat a direct message as a sufficient substitute.`, `If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`, `NEVER set recipient="cross_team_send" or to="cross_team_send". "cross_team_send" is a tool name, not a teammate.`, @@ -3860,6 +3863,7 @@ export class TeamProvisioningService { return [ `${idx + 1}) From: ${m.from || 'unknown'}`, ` Timestamp: ${m.timestamp}`, + ` MessageId: ${m.messageId}`, ...(summaryLine ? [` ${summaryLine}`] : []), ...(typeof m.source === 'string' && m.source.trim() ? [` Source: ${m.source.trim()}`] diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 2ce587fc..8c33c49e 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -128,6 +128,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => ); expect(prompt).toContain('task_start'); expect(prompt).toContain('task_complete'); + expect(prompt).toContain('task_create_from_message'); expect(prompt).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):'); expect(prompt).toContain('ASK: Strict read-only conversation mode.'); expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.'); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 8d8acbe9..18a7d5cc 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -773,4 +773,90 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(relayed).toBe(0); expect(writeSpy).toHaveBeenCalledTimes(0); }); + + it('includes MessageId in lead inbox relay prompt for provenance', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedLeadInbox(teamName, [ + { + from: 'user', + text: 'Build the authentication module', + timestamp: '2026-02-23T14:00:00.000Z', + read: false, + summary: 'Auth module request', + messageId: 'msg-provenance-001', + source: 'user_sent', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'Creating task.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + await relayPromise; + + const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); + expect(payload).toContain('MessageId: msg-provenance-001'); + expect(payload).toContain('Build the authentication module'); + }); + + it('includes MessageId in member inbox relay prompt for provenance', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedMemberInbox(teamName, 'alice', [ + { + from: 'bob', + text: 'Please review my changes', + timestamp: '2026-02-23T15:00:00.000Z', + read: false, + summary: 'Review request', + messageId: 'msg-member-relay-001', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + await service.relayMemberInboxMessages(teamName, 'alice'); + + expect(writeSpy).toHaveBeenCalledTimes(1); + const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); + expect(payload).toContain('MessageId: msg-member-relay-001'); + expect(payload).toContain('Please review my changes'); + }); + + it('lead inbox relay prompt mentions task_create_from_message for user messages with messageId', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedLeadInbox(teamName, [ + { + from: 'user', + text: 'Implement dark mode', + timestamp: '2026-02-23T16:00:00.000Z', + read: false, + summary: 'Dark mode', + messageId: 'msg-task-pref-001', + source: 'user_sent', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'Got it.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + await relayPromise; + + const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); + expect(payload).toContain('task_create_from_message'); + expect(payload).toContain('MessageId'); + }); });