diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index f18ed959..67f61eab 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -164,7 +164,19 @@ function setTaskStatus(context, taskId, status, actor) { } function startTask(context, taskId, actor) { - return setTaskStatus(context, taskId, 'in_progress', 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); + } + } catch { + // Best-effort: task status already updated, kanban cleanup failure is non-fatal + } + return task; } function completeTask(context, taskId, actor) { @@ -212,10 +224,19 @@ function addTaskComment(context, taskId, flags) { ...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}), }); - maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, { - inserted: result.inserted, - notifyOwner: flags.notifyOwner, - }); + 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, diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 82d6adb7..6f03a1b5 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -532,4 +532,32 @@ describe('agent-teams-controller API', () => { controller.processes.unregisterProcess({ id: 'stale-entry' }); expect(controller.processes.listProcesses()).toEqual([]); }); + + it('task_add_comment succeeds even when owner notification write fails', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ + subject: 'Comment resilience', + owner: 'bob', + notifyOwner: false, + }); + + // Make inboxes directory read-only to force notification write failure + const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes'); + fs.mkdirSync(inboxDir, { recursive: true }); + // Write a broken file that will cause JSON parse failure on append + fs.writeFileSync(path.join(inboxDir, 'bob.json'), 'NOT VALID JSON'); + + // Comment should still succeed despite notification failure + const commented = controller.tasks.addTaskComment(task.id, { + from: 'alice', + text: 'This should persist despite notification failure.', + }); + + expect(commented.commentId).toBeTruthy(); + expect(commented.task.comments).toHaveLength(1); + expect(commented.task.comments[0].text).toBe( + 'This should persist despite notification failure.' + ); + }); }); diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 82bf5cae..0499ee0d 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -260,9 +260,11 @@ describe('agent-teams-mcp tools', () => { }) ); expect(approved.reviewState).toBe('approved'); - const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'); - const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8')); - expect(ownerInbox.at(-1).leadSessionId).toBe('session-review-1'); + { + const approvedInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'); + const approvedInbox = JSON.parse(fs.readFileSync(approvedInboxPath, 'utf8')); + expect(approvedInbox.at(-1).leadSessionId).toBe('session-review-1'); + } const kanbanState = parseJsonToolResult( await getTool('kanban_get').execute({ @@ -549,4 +551,48 @@ describe('agent-teams-mcp tools', () => { }).success ).toBe(false); }); + + it('task_add_comment succeeds even when owner inbox write fails', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'resilience'; + + const task = parseJsonToolResult( + await getTool('task_create').execute({ + claudeDir, + teamName, + subject: 'Comment resilience test', + owner: 'alice', + notifyOwner: false, + }) + ); + + // Corrupt the inbox file to force notification failure + const inboxDir = path.join(claudeDir, 'teams', teamName, 'inboxes'); + fs.mkdirSync(inboxDir, { recursive: true }); + fs.writeFileSync(path.join(inboxDir, 'alice.json'), 'BROKEN JSON'); + + const commented = parseJsonToolResult( + await getTool('task_add_comment').execute({ + claudeDir, + teamName, + taskId: task.id, + text: 'Comment should persist despite broken inbox', + from: 'bob', + }) + ); + + expect(commented.commentId).toBeTruthy(); + + // Verify the comment is actually persisted on the task + const reloaded = parseJsonToolResult( + await getTool('task_get').execute({ + claudeDir, + teamName, + taskId: task.id, + }) + ); + + expect(reloaded.comments).toHaveLength(1); + expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox'); + }); }); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 9bcaa825..fc5a3ce1 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -13,7 +13,7 @@ import { AGENT_BLOCK_OPEN, stripAgentBlocks, } from '@shared/constants/agentBlocks'; -import { getMemberColor } from '@shared/constants/memberColors'; +import { getMemberColorByName } from '@shared/constants/memberColors'; import { createLogger } from '@shared/utils/logger'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -622,7 +622,7 @@ export class TeamDataService { role: request.role?.trim() || undefined, workflow: request.workflow?.trim() || undefined, agentType: 'general-purpose', - color: getMemberColor(members.filter((m) => !m.removedAt).length), + color: getMemberColorByName(name), joinedAt: Date.now(), }; @@ -662,7 +662,7 @@ export class TeamDataService { const joinedAt = Date.now(); const nextByName = new Set(); - const nextActive: TeamMember[] = request.members.map((member, index) => { + const nextActive: TeamMember[] = request.members.map((member) => { const name = member.name.trim(); if (!name) throw new Error('Member name cannot be empty'); if (name.toLowerCase() === 'team-lead') { @@ -681,7 +681,7 @@ export class TeamDataService { role: member.role?.trim() || undefined, workflow: member.workflow?.trim() || undefined, agentType: prev?.agentType ?? 'general-purpose', - color: prev?.color ?? getMemberColor(index), + color: prev?.color ?? getMemberColorByName(name), joinedAt: prev?.joinedAt ?? joinedAt, removedAt: undefined, }; @@ -1113,7 +1113,7 @@ export class TeamDataService { await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); await this.membersMetaStore.writeMembers( request.teamName, - request.members.map((member, index) => ({ + request.members.map((member) => ({ name: (() => { const name = member.name.trim(); if (!name) throw new Error('Member name cannot be empty'); @@ -1129,7 +1129,7 @@ export class TeamDataService { })(), role: member.role?.trim() || undefined, agentType: 'general-purpose', - color: getMemberColor(index), + color: getMemberColorByName(member.name.trim()), joinedAt, })) ); diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 34c2096e..b011af30 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -93,7 +93,10 @@ export class TeamMemberResolver { const ownedTasks = tasks.filter((task) => task.owner === name); const currentTask = ownedTasks.find( - (task) => task.status === 'in_progress' && task.kanbanColumn !== 'approved' + (task) => + task.status === 'in_progress' && + task.reviewState !== 'approved' && + task.kanbanColumn !== 'approved' ) ?? null; const memberMessages = messages.filter((message) => message.from === name); const latestMessage = memberMessages[0] ?? null; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a2c94df6..b508e867 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -19,7 +19,7 @@ import { AGENT_BLOCK_OPEN, stripAgentBlocks, } from '@shared/constants/agentBlocks'; -import { getMemberColor } from '@shared/constants/memberColors'; +import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; @@ -566,6 +566,7 @@ Communication protocol (CRITICAL — you are running headless, no one sees your - Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). Message formatting: +- When mentioning teammates by name in messages and text output, always use @ prefix (e.g. @alice, @bob) for UI highlighting. Do NOT use @ in tool parameters (recipient, owner, etc.) — those require plain names. ${agentBlockPolicy} ${membersFooter}`; @@ -1155,6 +1156,30 @@ export class TeamProvisioningService { } } + private persistInboxMessage(teamName: string, recipient: string, message: InboxMessage): void { + try { + createController({ + teamName, + claudeDir: getClaudeBasePath(), + }).messages.sendMessage({ + member: recipient, + from: message.from, + text: message.text, + timestamp: message.timestamp, + summary: message.summary, + messageId: message.messageId, + source: message.source, + leadSessionId: message.leadSessionId, + attachments: message.attachments, + color: message.color, + toolSummary: message.toolSummary, + toolCalls: message.toolCalls, + }); + } catch (error) { + logger.warn(`[${teamName}] inbox-message persist for ${recipient} failed: ${String(error)}`); + } + } + private getMemberRelayKey(teamName: string, memberName: string): string { return `${teamName}:${memberName.trim()}`; } @@ -2923,12 +2948,24 @@ export class TeamProvisioningService { }; this.pushLiveLeadProcessMessage(run.teamName, msg); - this.persistSentMessage(run.teamName, msg); - this.teamChangeEmitter?.({ - type: 'inbox', - teamName: run.teamName, - detail: 'sentMessages.json', - }); + + if (recipient === 'user') { + // User-directed messages go to sentMessages.json (canonical outbound store) + this.persistSentMessage(run.teamName, msg); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: 'sentMessages.json', + }); + } else { + // Non-user messages go to canonical recipient inbox for relay delivery + this.persistInboxMessage(run.teamName, recipient, msg); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: `inboxes/${recipient}.json`, + }); + } logger.debug( `[${run.teamName}] Captured SendMessage→${recipient} from stdout: ${cleanContent.slice(0, 100)}` @@ -5183,12 +5220,12 @@ export class TeamProvisioningService { try { await this.membersMetaStore.writeMembers( teamName, - teammateMembers.map((member, index) => ({ + teammateMembers.map((member) => ({ name: member.name.trim(), role: member.role?.trim() || undefined, workflow: member.workflow?.trim() || undefined, agentType: 'general-purpose', - color: getMemberColor(index), + color: getMemberColorByName(member.name.trim()), joinedAt, })) ); diff --git a/src/renderer/components/chat/AIChatGroup.tsx b/src/renderer/components/chat/AIChatGroup.tsx index fb522b3b..c0ee3a45 100644 --- a/src/renderer/components/chat/AIChatGroup.tsx +++ b/src/renderer/components/chat/AIChatGroup.tsx @@ -385,6 +385,12 @@ const AIChatGroupInner = ({ // Determine if there's content to toggle const hasToggleContent = enhanced.displayItems.length > 0; + // Last thinking text for collapsed preview + const lastThought = useMemo(() => { + const thinkingItems = enhanced.displayItems.filter((d) => d.type === 'thinking'); + return thinkingItems.at(-1)?.content?.slice(0, 200) ?? null; + }, [enhanced.displayItems]); + // Handle item click - toggle inline expansion using store action const handleItemClick = (itemId: string): void => { toggleDisplayItemExpansion(aiGroup.id, itemId); @@ -501,6 +507,11 @@ const AIChatGroupInner = ({ )} + {/* Last thought preview in collapsed state */} + {hasToggleContent && !isExpanded && lastThought && ( +
{lastThought}
+ )} + {/* Expandable Content */} {hasToggleContent && isExpanded && (
diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index fb78dd64..83f95343 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -79,7 +79,7 @@ export const CollapsibleTeamSection = ({ > - ) : null} -
- - {/* Clarification banner */} - {currentTask.needsClarification ? ( -
- - - {currentTask.needsClarification === 'user' - ? 'Awaiting clarification from you' - : 'Awaiting clarification from team lead'} - - -
- ) : null} - - {/* Description */} - } - contentClassName="pl-2.5" - headerClassName="-mx-6 w-[calc(100%+3rem)]" - headerContentClassName="pl-6" - defaultOpen - > - {editingDescription ? ( -
-
- - -
- {descriptionPreview ? ( -
- {descriptionDraft.trim() ? ( - - ) : ( -

Nothing to preview

- )} -
- ) : ( -