feat: enhance task comment notifications and owner wake-up logic
- Introduced a new function to notify task owners on comments from other members, improving communication and responsiveness. - Updated existing comment handling functions to include logic for notifying owners based on comment type and author. - Added tests to validate the new notification behavior, ensuring that owners are correctly alerted for relevant comments while avoiding unnecessary notifications for self-comments. - Refactored task management logic to streamline comment processing and notification handling.
This commit is contained in:
parent
9678d790cd
commit
9adffb2295
22 changed files with 449 additions and 119 deletions
|
|
@ -131,6 +131,7 @@ function approveReview(context, taskId, flags = {}) {
|
||||||
text: note,
|
text: note,
|
||||||
from,
|
from,
|
||||||
type: 'review_approved',
|
type: 'review_approved',
|
||||||
|
notifyOwner: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) {
|
if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) {
|
||||||
|
|
@ -184,6 +185,7 @@ function requestChanges(context, taskId, flags = {}) {
|
||||||
text: comment,
|
text: comment,
|
||||||
from,
|
from,
|
||||||
type: 'review_request',
|
type: 'review_request',
|
||||||
|
notifyOwner: false,
|
||||||
});
|
});
|
||||||
messages.sendMessage(context, {
|
messages.sendMessage(context, {
|
||||||
to: task.owner,
|
to: task.owner,
|
||||||
|
|
|
||||||
|
|
@ -413,6 +413,10 @@ function updateTaskFields(paths, taskRef, fields) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMemberName(value) {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
function addTaskComment(paths, taskRef, text, options = {}) {
|
function addTaskComment(paths, taskRef, text, options = {}) {
|
||||||
if (typeof text !== 'string' || !text.trim()) {
|
if (typeof text !== 'string' || !text.trim()) {
|
||||||
throw new Error('Missing comment text');
|
throw new Error('Missing comment text');
|
||||||
|
|
@ -435,21 +439,31 @@ function addTaskComment(paths, taskRef, text, options = {}) {
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let inserted = false;
|
||||||
|
let clarificationCleared = false;
|
||||||
const task = updateTask(paths, taskRef, (currentTask) => {
|
const task = updateTask(paths, taskRef, (currentTask) => {
|
||||||
const comments = Array.isArray(currentTask.comments) ? currentTask.comments : [];
|
const comments = Array.isArray(currentTask.comments) ? currentTask.comments : [];
|
||||||
if (comments.some((entry) => entry.id === comment.id)) {
|
if (comments.some((entry) => entry.id === comment.id)) {
|
||||||
return currentTask;
|
return currentTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentTask.needsClarification === 'lead' && comment.author !== currentTask.owner) {
|
const authorName = normalizeMemberName(comment.author);
|
||||||
|
const ownerName = normalizeMemberName(currentTask.owner);
|
||||||
|
if (currentTask.needsClarification === 'lead' && authorName && authorName !== ownerName) {
|
||||||
delete currentTask.needsClarification;
|
delete currentTask.needsClarification;
|
||||||
|
clarificationCleared = true;
|
||||||
|
}
|
||||||
|
if (currentTask.needsClarification === 'user' && authorName === 'user') {
|
||||||
|
delete currentTask.needsClarification;
|
||||||
|
clarificationCleared = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentTask.comments = comments.concat([comment]);
|
currentTask.comments = comments.concat([comment]);
|
||||||
|
inserted = true;
|
||||||
return currentTask;
|
return currentTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { comment, task };
|
return { comment, task, inserted, clarificationCleared };
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNeedsClarification(paths, taskRef, value) {
|
function setNeedsClarification(paths, taskRef, value) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,22 @@ 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildAssignmentMessage(context, task, options = {}) {
|
function buildAssignmentMessage(context, task, options = {}) {
|
||||||
const description =
|
const description =
|
||||||
typeof options.description === 'string' && options.description.trim()
|
typeof options.description === 'string' && options.description.trim()
|
||||||
|
|
@ -45,6 +61,18 @@ function buildAssignmentMessage(context, task, options = {}) {
|
||||||
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}":`,
|
||||||
|
``,
|
||||||
|
comment.text,
|
||||||
|
``,
|
||||||
|
wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
|
||||||
|
{ teamName: "${context.teamName}", taskId: "${task.id}", text: "<your reply>", from: "<your-name>" }`),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function maybeNotifyAssignedOwner(context, task, options = {}) {
|
function maybeNotifyAssignedOwner(context, task, options = {}) {
|
||||||
const owner = normalizeActorName(task.owner);
|
const owner = normalizeActorName(task.owner);
|
||||||
if (!owner || task.status === 'deleted') {
|
if (!owner || task.status === 'deleted') {
|
||||||
|
|
@ -69,6 +97,38 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = normalizeActorName(task.owner);
|
||||||
|
if (!owner) {
|
||||||
|
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),
|
||||||
|
summary: `Comment on #${task.displayId || task.id}`,
|
||||||
|
source: 'system_notification',
|
||||||
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createTask(context, input) {
|
function createTask(context, input) {
|
||||||
const task = taskStore.createTask(context.paths, input);
|
const task = taskStore.createTask(context.paths, input);
|
||||||
if (input && input.notifyOwner !== false) {
|
if (input && input.notifyOwner !== false) {
|
||||||
|
|
@ -152,6 +212,11 @@ function addTaskComment(context, taskId, flags) {
|
||||||
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
|
||||||
|
inserted: result.inserted,
|
||||||
|
notifyOwner: flags.notifyOwner,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commentId: result.comment.id,
|
commentId: result.comment.id,
|
||||||
taskId: result.task.id,
|
taskId: result.task.id,
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,77 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('wakes task owner on regular comment from another member', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const task = controller.tasks.createTask({ subject: 'Investigate', owner: 'bob', notifyOwner: false });
|
||||||
|
|
||||||
|
const commented = controller.tasks.addTaskComment(task.id, {
|
||||||
|
from: 'alice',
|
||||||
|
text: 'I found the root cause.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commented.task.comments.at(-1).text).toBe('I found the root cause.');
|
||||||
|
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||||
|
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].summary).toContain(`#${task.displayId}`);
|
||||||
|
expect(rows[0].text).toContain('I found the root cause.');
|
||||||
|
expect(rows[0].leadSessionId).toBe('lead-session-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not wake owner for self-comments and clears user clarification when user replies', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const task = controller.tasks.createTask({
|
||||||
|
subject: 'Need product input',
|
||||||
|
owner: 'bob',
|
||||||
|
needsClarification: 'user',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.tasks.addTaskComment(task.id, {
|
||||||
|
from: 'bob',
|
||||||
|
text: 'Starting to investigate.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||||
|
expect(fs.existsSync(ownerInboxPath)).toBe(false);
|
||||||
|
|
||||||
|
const replied = controller.tasks.addTaskComment(task.id, {
|
||||||
|
from: 'user',
|
||||||
|
text: 'Please use the safer option.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replied.task.needsClarification).toBeUndefined();
|
||||||
|
const reloaded = controller.tasks.getTask(task.id);
|
||||||
|
expect(reloaded.needsClarification).toBeUndefined();
|
||||||
|
const rows = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].text).toContain('Please use the safer option.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wakes lead owner on comment from another member', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const task = controller.tasks.createTask({
|
||||||
|
subject: 'Lead-owned task',
|
||||||
|
owner: 'team-lead',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.tasks.addTaskComment(task.id, {
|
||||||
|
from: 'alice',
|
||||||
|
text: 'Need your decision here.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
|
||||||
|
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].from).toBe('alice');
|
||||||
|
expect(rows[0].text).toContain('Need your decision here.');
|
||||||
|
});
|
||||||
|
|
||||||
it('moves review back to pending+needsFix and notifies owner on requestChanges', () => {
|
it('moves review back to pending+needsFix and notifies owner on requestChanges', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,11 @@ describe('agent-teams-mcp tools', () => {
|
||||||
const commentId = commented.commentId;
|
const commentId = commented.commentId;
|
||||||
expect(commentId).toBeTruthy();
|
expect(commentId).toBeTruthy();
|
||||||
|
|
||||||
|
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||||
|
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||||
|
expect(ownerInbox.at(-1).summary).toContain(`#${createdTask.displayId}`);
|
||||||
|
expect(ownerInbox.at(-1).text).toContain('Need one more check');
|
||||||
|
|
||||||
const attachment = parseJsonToolResult(
|
const attachment = parseJsonToolResult(
|
||||||
await getTool('task_attach_comment_file').execute({
|
await getTool('task_attach_comment_file').execute({
|
||||||
claudeDir,
|
claudeDir,
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,7 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
||||||
|
|
||||||
for (let i = 0; i < newMessages.length; i++) {
|
for (let i = 0; i < newMessages.length; i++) {
|
||||||
const msg = newMessages[i];
|
const msg = newMessages[i];
|
||||||
|
if ((msg.to ?? '').trim() !== 'user') continue;
|
||||||
// Skip messages sent from our own UI
|
// Skip messages sent from our own UI
|
||||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||||
// Skip internal coordination noise
|
// Skip internal coordination noise
|
||||||
|
|
@ -454,8 +455,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-relay ONLY lead-inbox changes into the live lead process.
|
// Relay inbox changes into active runtime recipients.
|
||||||
// (Relaying on *any* inbox change causes the lead to process irrelevant status noise.)
|
|
||||||
if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
|
if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
|
||||||
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
||||||
if (match && teamDataService) {
|
if (match && teamDataService) {
|
||||||
|
|
@ -463,8 +463,11 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
void teamDataService
|
void teamDataService
|
||||||
.getLeadMemberName(teamName)
|
.getLeadMemberName(teamName)
|
||||||
.then((leadName) => {
|
.then((leadName) => {
|
||||||
if (!leadName || inboxName !== leadName) return;
|
if (!leadName) return;
|
||||||
return teamProvisioningService.relayLeadInboxMessages(teamName);
|
if (inboxName === leadName) {
|
||||||
|
return teamProvisioningService.relayLeadInboxMessages(teamName);
|
||||||
|
}
|
||||||
|
return teamProvisioningService.relayMemberInboxMessages(teamName, inboxName);
|
||||||
})
|
})
|
||||||
.catch((e: unknown) =>
|
.catch((e: unknown) =>
|
||||||
logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`)
|
logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`)
|
||||||
|
|
|
||||||
|
|
@ -1143,13 +1143,12 @@ async function handleSendMessage(
|
||||||
from: payload.from,
|
from: payload.from,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Best-effort: if team is alive and recipient is a teammate (not lead),
|
// Best-effort live relay so active processes see the inbox row promptly.
|
||||||
// also forward via the live lead process so in-process teammates receive it.
|
|
||||||
if (!isLeadRecipient && isAlive) {
|
if (!isLeadRecipient && isAlive) {
|
||||||
try {
|
try {
|
||||||
await provisioning.forwardUserDmToTeammate(tn, memberName, baseText, payload.summary);
|
await provisioning.relayMemberInboxMessages(tn, memberName);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
logger.warn(`Failed to forward user DM to teammate "${memberName}" via lead: ${String(e)}`);
|
logger.warn(`Relay after sendMessage failed for teammate "${memberName}": ${String(e)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -954,58 +954,6 @@ export class TeamDataService {
|
||||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||||
} as TaskComment);
|
} as TaskComment);
|
||||||
|
|
||||||
try {
|
|
||||||
const [tasks, config] = await Promise.all([
|
|
||||||
this.taskReader.getTasks(teamName),
|
|
||||||
this.configReader.getConfig(teamName).catch(() => null),
|
|
||||||
]);
|
|
||||||
const task = addResult.task ?? tasks.find((t) => t.id === taskId);
|
|
||||||
const leadName = this.resolveLeadNameFromConfig(config);
|
|
||||||
const owner = task?.owner?.trim() || null;
|
|
||||||
// Auto-clear needsClarification: "user" on UI comment
|
|
||||||
// UI comments always have author "user" (TeamTaskWriter default)
|
|
||||||
if (task?.needsClarification === 'user') {
|
|
||||||
controller.tasks.setNeedsClarification(taskId, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task && owner && !this.isLeadOwner(owner, leadName)) {
|
|
||||||
// Notify non-lead task owner via inbox (lead → member message)
|
|
||||||
const parts = [
|
|
||||||
`Comment on task ${this.getTaskLabel(task)} "${task.subject}":\n\n${text}`,
|
|
||||||
`\n${AGENT_BLOCK_OPEN}`,
|
|
||||||
`Reply to this comment using MCP tool task_add_comment:`,
|
|
||||||
`{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "<your-name>" }`,
|
|
||||||
AGENT_BLOCK_CLOSE,
|
|
||||||
];
|
|
||||||
await this.sendMessage(teamName, {
|
|
||||||
member: owner,
|
|
||||||
from: leadName,
|
|
||||||
text: parts.join('\n'),
|
|
||||||
summary: `Comment on ${this.getTaskLabel(task)}`,
|
|
||||||
source: 'system_notification',
|
|
||||||
});
|
|
||||||
} else if (task && owner && this.isLeadOwner(owner, leadName)) {
|
|
||||||
// Notify lead about user's comment on their own task.
|
|
||||||
// Write to lead's inbox — relay delivers to stdin when process is alive.
|
|
||||||
const parts = [
|
|
||||||
`New comment from user on your task ${this.getTaskLabel(task)} "${task.subject}":\n\n${text}`,
|
|
||||||
`\n${AGENT_BLOCK_OPEN}`,
|
|
||||||
`Reply to this comment using MCP tool task_add_comment:`,
|
|
||||||
`{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "${leadName}" }`,
|
|
||||||
AGENT_BLOCK_CLOSE,
|
|
||||||
];
|
|
||||||
await this.sendMessage(teamName, {
|
|
||||||
member: leadName,
|
|
||||||
from: 'user',
|
|
||||||
text: parts.join('\n'),
|
|
||||||
summary: `Comment on ${this.getTaskLabel(task)}`,
|
|
||||||
source: 'system_notification',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Notification is best-effort — don't fail comment save
|
|
||||||
}
|
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -394,15 +394,17 @@ function buildTaskStatusProtocol(teamName: string): string {
|
||||||
a) Owner finishes work on #X -> task_complete #X
|
a) Owner finishes work on #X -> task_complete #X
|
||||||
b) Reviewer accepts -> review_approve #X
|
b) Reviewer accepts -> review_approve #X
|
||||||
11. CLARIFICATION PROTOCOL (CRITICAL — MANDATORY):
|
11. CLARIFICATION PROTOCOL (CRITICAL — MANDATORY):
|
||||||
When you are blocked and need information to continue a task, you MUST do BOTH steps below — skipping the MCP update breaks the task board:
|
When you are blocked and need information to continue a task, you MUST do ALL steps below — skipping the board update or comment breaks traceability:
|
||||||
a) STEP 1 — FIRST, set the clarification flag with MCP tool task_set_clarification:
|
a) STEP 1 — FIRST, set the clarification flag with MCP tool task_set_clarification:
|
||||||
{ teamName: "${teamName}", taskId: "<taskId>", value: "lead" }
|
{ teamName: "${teamName}", taskId: "<taskId>", value: "lead" }
|
||||||
b) STEP 2 — THEN, send a message to your team lead via SendMessage explaining what you need.
|
b) STEP 2 — THEN, add a task comment describing exactly what you need:
|
||||||
IMPORTANT: Always update the task board BEFORE sending the message. The flag is what makes the task board show "needs clarification" — without it, your request is invisible on the board.
|
{ teamName: "${teamName}", taskId: "<taskId>", text: "question / blocker / missing info", from: "<your-name>" }
|
||||||
c) The flag is auto-cleared when the lead adds a task comment on your task.
|
c) STEP 3 — THEN, send a message to your team lead via SendMessage so they notice it promptly.
|
||||||
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
|
IMPORTANT: Always update the task board BEFORE sending the message. The flag + task comment are what make the request durable and visible on the board.
|
||||||
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
|
d) The flag is auto-cleared when the lead adds a task comment on your task.
|
||||||
d) Do NOT set clarification to "user" yourself — only the team lead escalates to the user.
|
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
|
||||||
|
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
|
||||||
|
e) Do NOT set clarification to "user" yourself — only the team lead escalates to the user.
|
||||||
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.
|
||||||
|
|
@ -484,7 +486,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
||||||
`- Task assignment notifications are handled by the board runtime, so do NOT send a separate SendMessage for the same assignment unless you have extra context that is not already on the task.`,
|
`- Task assignment notifications are handled by the board runtime, so do NOT send a separate SendMessage for the same assignment unless you have extra context that is not already on the task.`,
|
||||||
``,
|
``,
|
||||||
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
|
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
|
||||||
`- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag) or SendMessage.`,
|
`- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag and wakes the owner) or SendMessage.`,
|
||||||
`- If you reply via SendMessage instead of task comment, also clear the flag manually:`,
|
`- If you reply via SendMessage instead of task comment, also clear the flag manually:`,
|
||||||
` task_set_clarification { teamName: "${teamName}", taskId: "<taskId>", value: "clear" }`,
|
` task_set_clarification { teamName: "${teamName}", taskId: "<taskId>", value: "clear" }`,
|
||||||
`- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`,
|
`- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`,
|
||||||
|
|
@ -1000,6 +1002,8 @@ export class TeamProvisioningService {
|
||||||
private readonly teamOpLocks = new Map<string, Promise<void>>();
|
private readonly teamOpLocks = new Map<string, Promise<void>>();
|
||||||
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
|
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
|
||||||
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
|
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
|
||||||
|
private readonly memberInboxRelayInFlight = new Map<string, Promise<number>>();
|
||||||
|
private readonly relayedMemberInboxMessageIds = new Map<string, Set<string>>();
|
||||||
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
|
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
|
||||||
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||||
private helpOutputCache: string | null = null;
|
private helpOutputCache: string | null = null;
|
||||||
|
|
@ -1151,6 +1155,23 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getMemberRelayKey(teamName: string, memberName: string): string {
|
||||||
|
return `${teamName}:${memberName.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private armSilentTeammateForward(run: ProvisioningRun, teammateName: string): void {
|
||||||
|
run.silentUserDmForward = { target: teammateName, startedAt: nowIso() };
|
||||||
|
if (run.silentUserDmForwardClearHandle) {
|
||||||
|
clearTimeout(run.silentUserDmForwardClearHandle);
|
||||||
|
run.silentUserDmForwardClearHandle = null;
|
||||||
|
}
|
||||||
|
run.silentUserDmForwardClearHandle = setTimeout(() => {
|
||||||
|
run.silentUserDmForward = null;
|
||||||
|
run.silentUserDmForwardClearHandle = null;
|
||||||
|
}, 60_000);
|
||||||
|
run.silentUserDmForwardClearHandle.unref();
|
||||||
|
}
|
||||||
|
|
||||||
private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null;
|
private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null;
|
||||||
|
|
||||||
setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void {
|
setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void {
|
||||||
|
|
@ -2379,17 +2400,7 @@ export class TeamProvisioningService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
run.silentUserDmForward = { target: teammateName, startedAt: nowIso() };
|
this.armSilentTeammateForward(run, teammateName);
|
||||||
if (run.silentUserDmForwardClearHandle) {
|
|
||||||
clearTimeout(run.silentUserDmForwardClearHandle);
|
|
||||||
run.silentUserDmForwardClearHandle = null;
|
|
||||||
}
|
|
||||||
// Safety valve: if the CLI never emits a result message, don't stay in "silent" mode forever.
|
|
||||||
run.silentUserDmForwardClearHandle = setTimeout(() => {
|
|
||||||
run.silentUserDmForward = null;
|
|
||||||
run.silentUserDmForwardClearHandle = null;
|
|
||||||
}, 60_000);
|
|
||||||
run.silentUserDmForwardClearHandle.unref();
|
|
||||||
|
|
||||||
const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null;
|
const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null;
|
||||||
const internal = wrapInAgentBlock(
|
const internal = wrapInAgentBlock(
|
||||||
|
|
@ -2412,6 +2423,112 @@ export class TeamProvisioningService {
|
||||||
await this.sendMessageToTeam(teamName, message);
|
await this.sendMessageToTeam(teamName, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> {
|
||||||
|
const relayKey = this.getMemberRelayKey(teamName, memberName);
|
||||||
|
const existing = this.memberInboxRelayInFlight.get(relayKey);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const work = (async (): Promise<number> => {
|
||||||
|
const runId = this.activeByTeam.get(teamName);
|
||||||
|
if (!runId) return 0;
|
||||||
|
const run = this.runs.get(runId);
|
||||||
|
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
|
||||||
|
if (!run.provisioningComplete) return 0;
|
||||||
|
|
||||||
|
const relayedIds = this.relayedMemberInboxMessageIds.get(relayKey) ?? new Set<string>();
|
||||||
|
|
||||||
|
let memberInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
|
||||||
|
try {
|
||||||
|
memberInboxMessages = await this.inboxReader.getMessagesFor(teamName, memberName);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unread = memberInboxMessages
|
||||||
|
.filter((m): m is InboxMessage & { messageId: string } => {
|
||||||
|
if (m.read) return false;
|
||||||
|
if (typeof m.text !== 'string' || m.text.trim().length === 0) return false;
|
||||||
|
if (!this.hasStableMessageId(m)) return false;
|
||||||
|
return !relayedIds.has(m.messageId);
|
||||||
|
})
|
||||||
|
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||||
|
|
||||||
|
if (unread.length === 0) return 0;
|
||||||
|
|
||||||
|
const noiseUnread = unread.filter((m) => isInboxNoiseMessage(m.text));
|
||||||
|
if (noiseUnread.length > 0) {
|
||||||
|
try {
|
||||||
|
await this.markInboxMessagesRead(teamName, memberName, noiseUnread);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionableUnread = unread.filter((m) => !isInboxNoiseMessage(m.text));
|
||||||
|
if (actionableUnread.length === 0) return 0;
|
||||||
|
|
||||||
|
const MAX_RELAY = 10;
|
||||||
|
const batch = actionableUnread.slice(0, MAX_RELAY);
|
||||||
|
|
||||||
|
this.armSilentTeammateForward(run, memberName);
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
`Relay inbox messages to teammate "${memberName}".`,
|
||||||
|
wrapInAgentBlock(
|
||||||
|
[
|
||||||
|
`Use the SendMessage tool with recipient="${memberName}".`,
|
||||||
|
`Forward each inbox item below as a teammate message, preserving task IDs and critical instructions.`,
|
||||||
|
`Do NOT send any message to recipient "user" for this relay turn.`,
|
||||||
|
`Do NOT add extra narration outside the SendMessage calls.`,
|
||||||
|
].join('\n')
|
||||||
|
),
|
||||||
|
``,
|
||||||
|
`Messages:`,
|
||||||
|
...batch.flatMap((m, idx) => {
|
||||||
|
const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null;
|
||||||
|
return [
|
||||||
|
`${idx + 1}) From: ${m.from || 'unknown'}`,
|
||||||
|
` Timestamp: ${m.timestamp}`,
|
||||||
|
...(summaryLine ? [` ${summaryLine}`] : []),
|
||||||
|
` Text:`,
|
||||||
|
...m.text.split('\n').map((line) => ` ${line}`),
|
||||||
|
``,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.sendMessageToTeam(teamName, message);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of batch) {
|
||||||
|
relayedIds.add(m.messageId);
|
||||||
|
}
|
||||||
|
this.relayedMemberInboxMessageIds.set(relayKey, this.trimRelayedSet(relayedIds));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.markInboxMessagesRead(teamName, memberName, batch);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: relay succeeded; marking read failed.
|
||||||
|
}
|
||||||
|
|
||||||
|
return batch.length;
|
||||||
|
})();
|
||||||
|
|
||||||
|
this.memberInboxRelayInFlight.set(relayKey, work);
|
||||||
|
try {
|
||||||
|
return await work;
|
||||||
|
} finally {
|
||||||
|
if (this.memberInboxRelayInFlight.get(relayKey) === work) {
|
||||||
|
this.memberInboxRelayInFlight.delete(relayKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relay unread inbox messages addressed to the team lead into the live lead process.
|
* Relay unread inbox messages addressed to the team lead into the live lead process.
|
||||||
*
|
*
|
||||||
|
|
@ -2763,24 +2880,22 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intercept SendMessage(to: "user") tool_use blocks from the lead's stream-json output.
|
* Intercept SendMessage tool_use blocks from the lead's stream-json output.
|
||||||
*
|
*
|
||||||
* Claude Code's internal teamContext may be lost after session resume (--resume), causing
|
* Claude Code's internal teamContext may be lost after session resume (--resume), causing
|
||||||
* SendMessage to route messages to ~/.claude/teams/default/ instead of the real team.
|
* SendMessage routing to drift away from our canonical team artifacts. By capturing tool_use
|
||||||
* By capturing tool_use calls directly from stdout, we persist them to sentMessages.json
|
* calls directly from stdout, we persist a durable message row under the correct team name so
|
||||||
* under the correct team name — ensuring the UI and OS notifications work correctly
|
* Messages stays accurate even if Claude's own routing is flaky.
|
||||||
* regardless of the internal teamContext state.
|
|
||||||
*/
|
*/
|
||||||
private captureSendMessageToUser(run: ProvisioningRun, content: Record<string, unknown>[]): void {
|
private captureSendMessages(run: ProvisioningRun, content: Record<string, unknown>[]): void {
|
||||||
for (const part of content) {
|
for (const part of content) {
|
||||||
if (part.type !== 'tool_use' || part.name !== 'SendMessage') continue;
|
if (part.type !== 'tool_use' || part.name !== 'SendMessage') continue;
|
||||||
const input = part.input;
|
const input = part.input;
|
||||||
if (!input || typeof input !== 'object') continue;
|
if (!input || typeof input !== 'object') continue;
|
||||||
const inp = input as Record<string, unknown>;
|
const inp = input as Record<string, unknown>;
|
||||||
|
|
||||||
// Only capture messages addressed to the human user
|
|
||||||
const recipient = typeof inp.recipient === 'string' ? inp.recipient : '';
|
const recipient = typeof inp.recipient === 'string' ? inp.recipient : '';
|
||||||
if (recipient !== 'user') continue;
|
if (!recipient.trim()) continue;
|
||||||
|
|
||||||
const msgContent = typeof inp.content === 'string' ? inp.content : '';
|
const msgContent = typeof inp.content === 'string' ? inp.content : '';
|
||||||
if (msgContent.trim().length === 0) continue;
|
if (msgContent.trim().length === 0) continue;
|
||||||
|
|
@ -2795,10 +2910,10 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const msg: InboxMessage = {
|
const msg: InboxMessage = {
|
||||||
from: leadName,
|
from: leadName,
|
||||||
to: 'user',
|
to: recipient,
|
||||||
text: cleanContent,
|
text: cleanContent,
|
||||||
timestamp: nowIso(),
|
timestamp: nowIso(),
|
||||||
read: false,
|
read: recipient !== 'user',
|
||||||
summary:
|
summary:
|
||||||
(summary || cleanContent).length > 60
|
(summary || cleanContent).length > 60
|
||||||
? (summary || cleanContent).slice(0, 57) + '...'
|
? (summary || cleanContent).slice(0, 57) + '...'
|
||||||
|
|
@ -2816,7 +2931,7 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[${run.teamName}] Captured SendMessage→user from stdout: ${cleanContent.slice(0, 100)}`
|
`[${run.teamName}] Captured SendMessage→${recipient} from stdout: ${cleanContent.slice(0, 100)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2947,12 +3062,13 @@ export class TeamProvisioningService {
|
||||||
return Array.isArray(inner) ? (inner as Record<string, unknown>[]) : null;
|
return Array.isArray(inner) ? (inner as Record<string, unknown>[]) : null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const hasSendMessageToUser = (content ?? []).some((part) => {
|
const hasCapturedSendMessage = (content ?? []).some((part) => {
|
||||||
if (!part || typeof part !== 'object') return false;
|
if (!part || typeof part !== 'object') return false;
|
||||||
if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false;
|
if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false;
|
||||||
const input = part.input;
|
const input = part.input;
|
||||||
if (!input || typeof input !== 'object') return false;
|
if (!input || typeof input !== 'object') return false;
|
||||||
return (input as Record<string, unknown>).recipient === 'user';
|
const recipient = (input as Record<string, unknown>).recipient;
|
||||||
|
return typeof recipient === 'string' && recipient.trim().length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const textParts = (content ?? [])
|
const textParts = (content ?? [])
|
||||||
|
|
@ -2988,12 +3104,12 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
} else if (run.provisioningComplete) {
|
} else if (run.provisioningComplete) {
|
||||||
// Push each assistant text block as a separate live message (per-message pattern).
|
// Push each assistant text block as a separate live message (per-message pattern).
|
||||||
// When the same assistant message includes SendMessage(to:"user"), skip text —
|
// When the same assistant message includes SendMessage(...), skip text —
|
||||||
// captureSendMessageToUser() handles it separately.
|
// captureSendMessages() handles the visible outbound message separately.
|
||||||
if (
|
if (
|
||||||
!run.silentUserDmForward &&
|
!run.silentUserDmForward &&
|
||||||
!run.suppressPostCompactReminderOutput &&
|
!run.suppressPostCompactReminderOutput &&
|
||||||
!hasSendMessageToUser
|
!hasCapturedSendMessage
|
||||||
) {
|
) {
|
||||||
const cleanText = stripAgentBlocks(text).trim();
|
const cleanText = stripAgentBlocks(text).trim();
|
||||||
if (cleanText.length > 0) {
|
if (cleanText.length > 0) {
|
||||||
|
|
@ -3003,7 +3119,7 @@ export class TeamProvisioningService {
|
||||||
} else {
|
} else {
|
||||||
// Pre-ready: also push to live cache so Messages shows early narration
|
// Pre-ready: also push to live cache so Messages shows early narration
|
||||||
// once team:getData becomes readable. The banner still uses provisioningOutputParts.
|
// once team:getData becomes readable. The banner still uses provisioningOutputParts.
|
||||||
if (!run.silentUserDmForward && !hasSendMessageToUser) {
|
if (!run.silentUserDmForward && !hasCapturedSendMessage) {
|
||||||
const cleanText = stripAgentBlocks(text).trim();
|
const cleanText = stripAgentBlocks(text).trim();
|
||||||
if (cleanText.length > 0) {
|
if (cleanText.length > 0) {
|
||||||
this.pushLiveLeadTextMessage(run, cleanText);
|
this.pushLiveLeadTextMessage(run, cleanText);
|
||||||
|
|
@ -3029,14 +3145,11 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture SendMessage(to: "user") tool_use blocks from assistant output.
|
// Capture SendMessage tool_use blocks from assistant output.
|
||||||
// Claude Code's internal teamContext may route to "default" instead of the real team
|
// Works in both pre-ready and post-ready phases so outbound runtime messages
|
||||||
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
|
// are visible in our team message artifacts even if Claude's own routing drifts.
|
||||||
// from stdout and persist them to sentMessages.json under the correct team name,
|
|
||||||
// ensuring the UI and notifications show the right team.
|
|
||||||
// Works in both pre-ready and post-ready phases so provisioning-time user DMs are captured.
|
|
||||||
if (!run.silentUserDmForward) {
|
if (!run.silentUserDmForward) {
|
||||||
this.captureSendMessageToUser(run, content ?? []);
|
this.captureSendMessages(run, content ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract context window usage from message.usage for real-time tracking.
|
// Extract context window usage from message.usage for real-time tracking.
|
||||||
|
|
@ -3947,6 +4060,16 @@ export class TeamProvisioningService {
|
||||||
this.activeByTeam.delete(run.teamName);
|
this.activeByTeam.delete(run.teamName);
|
||||||
this.leadInboxRelayInFlight.delete(run.teamName);
|
this.leadInboxRelayInFlight.delete(run.teamName);
|
||||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||||
|
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
||||||
|
if (key.startsWith(`${run.teamName}:`)) {
|
||||||
|
this.memberInboxRelayInFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) {
|
||||||
|
if (key.startsWith(`${run.teamName}:`)) {
|
||||||
|
this.relayedMemberInboxMessageIds.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
this.liveLeadProcessMessages.delete(run.teamName);
|
this.liveLeadProcessMessages.delete(run.teamName);
|
||||||
// Dismiss any pending tool approvals for this run
|
// Dismiss any pending tool approvals for this run
|
||||||
if (run.pendingApprovals.size > 0) {
|
if (run.pendingApprovals.size > 0) {
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@ export const UnreadCommentsBadge = ({
|
||||||
if (totalCount === 0) return null;
|
if (totalCount === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="relative inline-flex items-center gap-0.5 rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0 text-[10px] font-medium text-[var(--color-text-muted)]">
|
<span
|
||||||
|
className={`relative inline-flex items-center gap-0.5 rounded-full bg-[var(--color-surface-raised)] py-0 text-[10px] font-medium text-[var(--color-text-muted)] ${unreadCount > 0 ? 'mr-1 pl-1.5 pr-2' : 'px-1.5'}`}
|
||||||
|
>
|
||||||
<MessageSquare size={10} />
|
<MessageSquare size={10} />
|
||||||
{totalCount}
|
{totalCount}
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="absolute -right-2.5 -top-1.5 flex size-3.5 items-center justify-center rounded-full bg-blue-500 text-[8px] font-bold leading-none text-white">
|
<span className="absolute -top-1 right-0 flex h-3 min-w-[12px] translate-x-[calc(50%-4px)] items-center justify-center rounded-full bg-blue-500 px-0.5 text-[8px] font-bold leading-none text-white">
|
||||||
{unreadCount}
|
{unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,7 @@ export const ActivityTimeline = ({
|
||||||
sessionSeparator = (
|
sessionSeparator = (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3"
|
className="flex items-center gap-3"
|
||||||
style={{ paddingTop: 90, paddingBottom: 90 }}
|
style={{ paddingTop: 45, paddingBottom: 45 }}
|
||||||
>
|
>
|
||||||
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
||||||
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export interface LeadThoughtGroup {
|
||||||
* an official message (SendMessage, direct reply, inbox, etc.).
|
* an official message (SendMessage, direct reply, inbox, etc.).
|
||||||
*/
|
*/
|
||||||
export function isLeadThought(msg: InboxMessage): boolean {
|
export function isLeadThought(msg: InboxMessage): boolean {
|
||||||
|
if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false;
|
||||||
if (msg.source === 'lead_session') return true;
|
if (msg.source === 'lead_session') return true;
|
||||||
if (msg.source === 'lead_process') return true;
|
if (msg.source === 'lead_process') return true;
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -362,7 +363,10 @@ const LeadThoughtItem = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="group/thought relative flex text-[11px]">
|
<div className="group/thought relative flex text-[11px]">
|
||||||
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
|
<div
|
||||||
|
className="min-w-0 flex-1 [&>span>div>div>div]:py-2"
|
||||||
|
style={{ color: CARD_TEXT_LIGHT }}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
onClickCapture={
|
onClickCapture={
|
||||||
onTaskIdClick
|
onTaskIdClick
|
||||||
|
|
|
||||||
|
|
@ -352,7 +352,7 @@ export const KanbanTaskCard = ({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1"
|
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||||
aria-label={`Complete task ${task.id}`}
|
aria-label={`Complete task ${task.id}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -370,7 +370,7 @@ export const KanbanTaskCard = ({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1"
|
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||||
aria-label={`Complete task ${task.id}`}
|
aria-label={`Complete task ${task.id}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,12 @@ export const MessageComposer = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return filtered.map((m) => {
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
|
const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
|
||||||
|
const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
|
||||||
|
return bIsLead - aIsLead;
|
||||||
|
});
|
||||||
|
return sorted.map((m) => {
|
||||||
const resolvedColor = colorMap.get(m.name);
|
const resolvedColor = colorMap.get(m.name);
|
||||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||||
const isSelected = m.name === recipient;
|
const isSelected = m.name === recipient;
|
||||||
|
|
|
||||||
|
|
@ -140,9 +140,9 @@ const diffSpecificTheme = EditorView.theme({
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
lineHeight: '20px',
|
lineHeight: '20px',
|
||||||
color: 'var(--color-text)',
|
color: 'var(--diff-merge-undo-color)',
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
backgroundColor: 'var(--diff-merge-undo-bg)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--diff-merge-undo-border)',
|
||||||
'&:hover': { backgroundColor: 'var(--diff-merge-undo-hover-bg)' },
|
'&:hover': { backgroundColor: 'var(--diff-merge-undo-hover-bg)' },
|
||||||
'& kbd': { fontSize: '10px', color: 'var(--color-text-muted)', marginLeft: '4px' },
|
'& kbd': { fontSize: '10px', color: 'var(--color-text-muted)', marginLeft: '4px' },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ export const ReviewDiffContent = ({ file }: ReviewDiffContentProps) => {
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
{nonErrorSnippets.map((snippet, index) => (
|
{nonErrorSnippets.map((snippet, index) => (
|
||||||
<SnippetDiffView
|
<SnippetDiffView
|
||||||
key={snippet.toolUseId}
|
key={`${snippet.toolUseId}-${index}`}
|
||||||
snippet={snippet}
|
snippet={snippet}
|
||||||
index={index}
|
index={index}
|
||||||
fileName={file.relativePath}
|
fileName={file.relativePath}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,9 @@
|
||||||
--diff-cm-changed-bg: #1a3a1a;
|
--diff-cm-changed-bg: #1a3a1a;
|
||||||
--diff-cm-deleted-bg: #241517;
|
--diff-cm-deleted-bg: #241517;
|
||||||
/* CodeMirror merge buttons */
|
/* CodeMirror merge buttons */
|
||||||
|
--diff-merge-undo-color: var(--color-text);
|
||||||
|
--diff-merge-undo-bg: var(--color-surface-raised);
|
||||||
|
--diff-merge-undo-border: var(--color-border);
|
||||||
--diff-merge-undo-hover-bg: rgba(255, 255, 255, 0.1);
|
--diff-merge-undo-hover-bg: rgba(255, 255, 255, 0.1);
|
||||||
--diff-merge-keep-color: #3fb950;
|
--diff-merge-keep-color: #3fb950;
|
||||||
--diff-merge-keep-bg: rgba(46, 160, 67, 0.25);
|
--diff-merge-keep-bg: rgba(46, 160, 67, 0.25);
|
||||||
|
|
@ -335,7 +338,10 @@
|
||||||
--diff-cm-changed-bg: rgba(34, 197, 94, 0.14);
|
--diff-cm-changed-bg: rgba(34, 197, 94, 0.14);
|
||||||
--diff-cm-deleted-bg: rgba(239, 68, 68, 0.07);
|
--diff-cm-deleted-bg: rgba(239, 68, 68, 0.07);
|
||||||
/* CodeMirror merge buttons - Light mode */
|
/* CodeMirror merge buttons - Light mode */
|
||||||
--diff-merge-undo-hover-bg: rgba(0, 0, 0, 0.08);
|
--diff-merge-undo-color: #991b1b;
|
||||||
|
--diff-merge-undo-bg: rgba(239, 68, 68, 0.08);
|
||||||
|
--diff-merge-undo-border: rgba(239, 68, 68, 0.3);
|
||||||
|
--diff-merge-undo-hover-bg: rgba(239, 68, 68, 0.15);
|
||||||
--diff-merge-keep-color: #15803d;
|
--diff-merge-keep-color: #15803d;
|
||||||
--diff-merge-keep-bg: rgba(34, 197, 94, 0.15);
|
--diff-merge-keep-bg: rgba(34, 197, 94, 0.15);
|
||||||
--diff-merge-keep-border: rgba(34, 197, 94, 0.4);
|
--diff-merge-keep-border: rgba(34, 197, 94, 0.4);
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ describe('ipc teams handlers', () => {
|
||||||
sendMessageToTeam: vi.fn(async () => undefined),
|
sendMessageToTeam: vi.fn(async () => undefined),
|
||||||
isTeamAlive: vi.fn(() => true),
|
isTeamAlive: vi.fn(() => true),
|
||||||
relayLeadInboxMessages: vi.fn(async () => 0),
|
relayLeadInboxMessages: vi.fn(async () => 0),
|
||||||
|
relayMemberInboxMessages: vi.fn(async () => 0),
|
||||||
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
|
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
|
||||||
getAliveTeams: vi.fn(() => ['my-team']),
|
getAliveTeams: vi.fn(() => ['my-team']),
|
||||||
getLeadActivityState: vi.fn(() => 'idle'),
|
getLeadActivityState: vi.fn(() => 'idle'),
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,36 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
||||||
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
|
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('captures SendMessage(to:team-lead) without rendering duplicate assistant thought text', () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
seedConfig('my-team');
|
||||||
|
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
||||||
|
|
||||||
|
callHandleStreamJsonMessage(service, run, {
|
||||||
|
type: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Forwarding the clarification request now.' },
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
name: 'SendMessage',
|
||||||
|
input: {
|
||||||
|
type: 'message',
|
||||||
|
recipient: 'team-lead',
|
||||||
|
content: 'Need clarification on #abcd1234',
|
||||||
|
summary: 'Clarification request',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const live = service.getLiveLeadProcessMessages('my-team');
|
||||||
|
expect(live).toHaveLength(1);
|
||||||
|
expect(live[0].to).toBe('team-lead');
|
||||||
|
expect(live[0].text).toBe('Need clarification on #abcd1234');
|
||||||
|
expect(live[0].source).toBe('lead_process');
|
||||||
|
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('post-ready path also uses the unified helper', () => {
|
it('post-ready path also uses the unified helper', () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
seedConfig('my-team');
|
seedConfig('my-team');
|
||||||
|
|
|
||||||
|
|
@ -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('you MUST do ALL steps below');
|
||||||
|
expect(prompt).toContain('STEP 2 — THEN, add a task comment describing exactly what you need');
|
||||||
|
expect(prompt).toContain('STEP 3 — THEN, send a message to your team lead via SendMessage');
|
||||||
expect(prompt).toContain('use task_briefing as your compact queue view');
|
expect(prompt).toContain('use task_briefing as your compact queue view');
|
||||||
expect(prompt).toContain('Use task_get when you need the full task context before starting a pending/needsFix task');
|
expect(prompt).toContain('Use task_get when you need the full task context before starting a pending/needsFix task');
|
||||||
expect(prompt).toContain('Use task_briefing as a compact queue view of your assigned tasks.');
|
expect(prompt).toContain('Use task_briefing as a compact queue view of your assigned tasks.');
|
||||||
|
|
@ -278,6 +281,7 @@ 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('reply via task comment (preferred — auto-clears the flag and wakes the owner) or SendMessage');
|
||||||
expect(prompt).toContain('Your FIRST action: call MCP tool task_briefing');
|
expect(prompt).toContain('Your FIRST action: call MCP tool task_briefing');
|
||||||
expect(prompt).toContain('resume/finish those first');
|
expect(prompt).toContain('resume/finish those first');
|
||||||
expect(prompt).toContain('Call task_get only if you need more context than task_briefing already gave you');
|
expect(prompt).toContain('Call task_get only if you need more context than task_briefing already gave you');
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,10 @@ function seedLeadInbox(teamName: string, messages: unknown[]): void {
|
||||||
hoisted.files.set(`/mock/teams/${teamName}/inboxes/team-lead.json`, JSON.stringify(messages));
|
hoisted.files.set(`/mock/teams/${teamName}/inboxes/team-lead.json`, JSON.stringify(messages));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedMemberInbox(teamName: string, memberName: string, messages: unknown[]): void {
|
||||||
|
hoisted.files.set(`/mock/teams/${teamName}/inboxes/${memberName}.json`, JSON.stringify(messages));
|
||||||
|
}
|
||||||
|
|
||||||
function attachAliveRun(
|
function attachAliveRun(
|
||||||
service: TeamProvisioningService,
|
service: TeamProvisioningService,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -293,4 +297,30 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
expect(writeSpy).toHaveBeenCalledTimes(0);
|
expect(writeSpy).toHaveBeenCalledTimes(0);
|
||||||
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
|
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('relays unread teammate inbox messages through the live team process', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
seedConfig(teamName);
|
||||||
|
seedMemberInbox(teamName, 'alice', [
|
||||||
|
{
|
||||||
|
from: 'team-lead',
|
||||||
|
text: 'Comment on task #abcd1234 "Investigate":\n\nPlease retry with logging enabled.',
|
||||||
|
timestamp: '2026-02-23T10:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
summary: 'Comment on #abcd1234',
|
||||||
|
messageId: 'm-alice-1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { writeSpy } = attachAliveRun(service, teamName);
|
||||||
|
const relayed = await service.relayMemberInboxMessages(teamName, 'alice');
|
||||||
|
|
||||||
|
expect(relayed).toBe(1);
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||||
|
expect(payload).toContain('"type":"user"');
|
||||||
|
expect(payload).toContain('recipient=\\"alice\\"');
|
||||||
|
expect(payload).toContain('Please retry with logging enabled.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { isLeadThought } from '../../../../../src/renderer/components/team/activity/LeadThoughtsGroup';
|
||||||
|
|
||||||
|
describe('LeadThoughtsGroup', () => {
|
||||||
|
it('does not classify outbound runtime messages with recipients as lead thoughts', () => {
|
||||||
|
expect(
|
||||||
|
isLeadThought({
|
||||||
|
from: 'team-lead',
|
||||||
|
to: 'alice',
|
||||||
|
text: 'Please check task #abcd1234',
|
||||||
|
timestamp: '2026-03-08T00:00:00.000Z',
|
||||||
|
read: true,
|
||||||
|
source: 'lead_process',
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue