feat: improve task status handling and notification resilience

- Enhanced the startTask function to clear stale kanban entries when a task is reopened, ensuring accurate task tracking.
- Updated the addTaskComment function to handle notification failures gracefully, allowing comments to persist even if owner notifications fail.
- Added tests to validate the resilience of task comment notifications and ensure correct behavior under failure scenarios.
- Refactored related functions for improved clarity and maintainability in task management.
This commit is contained in:
iliya 2026-03-09 17:09:15 +02:00
parent 9adffb2295
commit 3f923c480e
24 changed files with 1215 additions and 721 deletions

View file

@ -164,7 +164,19 @@ function setTaskStatus(context, taskId, status, actor) {
} }
function startTask(context, taskId, 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) { function completeTask(context, taskId, actor) {
@ -212,10 +224,19 @@ 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, { try {
inserted: result.inserted, maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
notifyOwner: flags.notifyOwner, 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 { return {
commentId: result.comment.id, commentId: result.comment.id,

View file

@ -532,4 +532,32 @@ describe('agent-teams-controller API', () => {
controller.processes.unregisterProcess({ id: 'stale-entry' }); controller.processes.unregisterProcess({ id: 'stale-entry' });
expect(controller.processes.listProcesses()).toEqual([]); 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.'
);
});
}); });

View file

@ -260,9 +260,11 @@ describe('agent-teams-mcp tools', () => {
}) })
); );
expect(approved.reviewState).toBe('approved'); expect(approved.reviewState).toBe('approved');
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'); {
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8')); const approvedInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
expect(ownerInbox.at(-1).leadSessionId).toBe('session-review-1'); const approvedInbox = JSON.parse(fs.readFileSync(approvedInboxPath, 'utf8'));
expect(approvedInbox.at(-1).leadSessionId).toBe('session-review-1');
}
const kanbanState = parseJsonToolResult( const kanbanState = parseJsonToolResult(
await getTool('kanban_get').execute({ await getTool('kanban_get').execute({
@ -549,4 +551,48 @@ describe('agent-teams-mcp tools', () => {
}).success }).success
).toBe(false); ).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');
});
}); });

View file

@ -13,7 +13,7 @@ import {
AGENT_BLOCK_OPEN, AGENT_BLOCK_OPEN,
stripAgentBlocks, stripAgentBlocks,
} from '@shared/constants/agentBlocks'; } from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColorByName } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -622,7 +622,7 @@ export class TeamDataService {
role: request.role?.trim() || undefined, role: request.role?.trim() || undefined,
workflow: request.workflow?.trim() || undefined, workflow: request.workflow?.trim() || undefined,
agentType: 'general-purpose', agentType: 'general-purpose',
color: getMemberColor(members.filter((m) => !m.removedAt).length), color: getMemberColorByName(name),
joinedAt: Date.now(), joinedAt: Date.now(),
}; };
@ -662,7 +662,7 @@ export class TeamDataService {
const joinedAt = Date.now(); const joinedAt = Date.now();
const nextByName = new Set<string>(); const nextByName = new Set<string>();
const nextActive: TeamMember[] = request.members.map((member, index) => { const nextActive: TeamMember[] = request.members.map((member) => {
const name = member.name.trim(); const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty'); if (!name) throw new Error('Member name cannot be empty');
if (name.toLowerCase() === 'team-lead') { if (name.toLowerCase() === 'team-lead') {
@ -681,7 +681,7 @@ export class TeamDataService {
role: member.role?.trim() || undefined, role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined, workflow: member.workflow?.trim() || undefined,
agentType: prev?.agentType ?? 'general-purpose', agentType: prev?.agentType ?? 'general-purpose',
color: prev?.color ?? getMemberColor(index), color: prev?.color ?? getMemberColorByName(name),
joinedAt: prev?.joinedAt ?? joinedAt, joinedAt: prev?.joinedAt ?? joinedAt,
removedAt: undefined, removedAt: undefined,
}; };
@ -1113,7 +1113,7 @@ export class TeamDataService {
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
await this.membersMetaStore.writeMembers( await this.membersMetaStore.writeMembers(
request.teamName, request.teamName,
request.members.map((member, index) => ({ request.members.map((member) => ({
name: (() => { name: (() => {
const name = member.name.trim(); const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty'); if (!name) throw new Error('Member name cannot be empty');
@ -1129,7 +1129,7 @@ export class TeamDataService {
})(), })(),
role: member.role?.trim() || undefined, role: member.role?.trim() || undefined,
agentType: 'general-purpose', agentType: 'general-purpose',
color: getMemberColor(index), color: getMemberColorByName(member.name.trim()),
joinedAt, joinedAt,
})) }))
); );

View file

@ -93,7 +93,10 @@ export class TeamMemberResolver {
const ownedTasks = tasks.filter((task) => task.owner === name); const ownedTasks = tasks.filter((task) => task.owner === name);
const currentTask = const currentTask =
ownedTasks.find( ownedTasks.find(
(task) => task.status === 'in_progress' && task.kanbanColumn !== 'approved' (task) =>
task.status === 'in_progress' &&
task.reviewState !== 'approved' &&
task.kanbanColumn !== 'approved'
) ?? null; ) ?? null;
const memberMessages = messages.filter((message) => message.from === name); const memberMessages = messages.filter((message) => message.from === name);
const latestMessage = memberMessages[0] ?? null; const latestMessage = memberMessages[0] ?? null;

View file

@ -19,7 +19,7 @@ import {
AGENT_BLOCK_OPEN, AGENT_BLOCK_OPEN,
stripAgentBlocks, stripAgentBlocks,
} from '@shared/constants/agentBlocks'; } 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 { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { parseCliArgs } from '@shared/utils/cliArgsParser'; 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 <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). - Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
Message formatting: 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} ${agentBlockPolicy}
${membersFooter}`; ${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 { private getMemberRelayKey(teamName: string, memberName: string): string {
return `${teamName}:${memberName.trim()}`; return `${teamName}:${memberName.trim()}`;
} }
@ -2923,12 +2948,24 @@ export class TeamProvisioningService {
}; };
this.pushLiveLeadProcessMessage(run.teamName, msg); this.pushLiveLeadProcessMessage(run.teamName, msg);
this.persistSentMessage(run.teamName, msg);
this.teamChangeEmitter?.({ if (recipient === 'user') {
type: 'inbox', // User-directed messages go to sentMessages.json (canonical outbound store)
teamName: run.teamName, this.persistSentMessage(run.teamName, msg);
detail: 'sentMessages.json', 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( logger.debug(
`[${run.teamName}] Captured SendMessage→${recipient} from stdout: ${cleanContent.slice(0, 100)}` `[${run.teamName}] Captured SendMessage→${recipient} from stdout: ${cleanContent.slice(0, 100)}`
@ -5183,12 +5220,12 @@ export class TeamProvisioningService {
try { try {
await this.membersMetaStore.writeMembers( await this.membersMetaStore.writeMembers(
teamName, teamName,
teammateMembers.map((member, index) => ({ teammateMembers.map((member) => ({
name: member.name.trim(), name: member.name.trim(),
role: member.role?.trim() || undefined, role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined, workflow: member.workflow?.trim() || undefined,
agentType: 'general-purpose', agentType: 'general-purpose',
color: getMemberColor(index), color: getMemberColorByName(member.name.trim()),
joinedAt, joinedAt,
})) }))
); );

View file

@ -385,6 +385,12 @@ const AIChatGroupInner = ({
// Determine if there's content to toggle // Determine if there's content to toggle
const hasToggleContent = enhanced.displayItems.length > 0; 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 // Handle item click - toggle inline expansion using store action
const handleItemClick = (itemId: string): void => { const handleItemClick = (itemId: string): void => {
toggleDisplayItemExpansion(aiGroup.id, itemId); toggleDisplayItemExpansion(aiGroup.id, itemId);
@ -501,6 +507,11 @@ const AIChatGroupInner = ({
</div> </div>
)} )}
{/* Last thought preview in collapsed state */}
{hasToggleContent && !isExpanded && lastThought && (
<div className="truncate px-6 pb-1 text-xs text-text-muted">{lastThought}</div>
)}
{/* Expandable Content */} {/* Expandable Content */}
{hasToggleContent && isExpanded && ( {hasToggleContent && isExpanded && (
<div className="py-2 pl-2"> <div className="py-2 pl-2">

View file

@ -79,7 +79,7 @@ export const CollapsibleTeamSection = ({
> >
<button <button
type="button" type="button"
className={`absolute inset-0 z-0 cursor-pointer transition-colors ${isOpen ? 'rounded-t-md bg-white/[0.07] hover:bg-white/[0.1]' : 'rounded-md bg-white/[0.04] hover:bg-white/[0.08]'}`} className={`absolute inset-0 z-0 cursor-pointer transition-colors ${isOpen ? 'rounded-t-md bg-[var(--color-section-bg-open)] hover:bg-[var(--color-section-hover-open)]' : 'rounded-md bg-[var(--color-section-bg)] hover:bg-[var(--color-section-hover)]'}`}
onClick={() => setOpen((prev) => !prev)} onClick={() => setOpen((prev) => !prev)}
aria-label={isOpen ? 'Collapse section' : 'Expand section'} aria-label={isOpen ? 'Collapse section' : 'Expand section'}
/> />

View file

@ -23,7 +23,13 @@ export const ActiveTasksBlock = ({
const { isLight } = useTheme(); const { isLight } = useTheme();
const colorMap = buildMemberColorMap(members); const colorMap = buildMemberColorMap(members);
const taskMap = new Map(tasks.map((t) => [t.id, t])); const taskMap = new Map(tasks.map((t) => [t.id, t]));
const working = members.filter((m) => m.currentTaskId != null); const working = members.filter((m) => {
if (!m.currentTaskId) return false;
const task = taskMap.get(m.currentTaskId);
// Defense-in-depth: hide banner for approved/completed tasks even if currentTaskId is stale
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false;
return true;
});
if (working.length === 0) return null; if (working.length === 0) return null;
return ( return (

View file

@ -342,24 +342,10 @@ const LeadThoughtItem = ({
<div ref={wrapperRef}> <div ref={wrapperRef}>
<div ref={contentRef}> <div ref={contentRef}>
{showDivider && ( {showDivider && (
<div className="mx-auto flex w-2/5 items-center justify-center gap-[5px] py-px"> <div className="py-px text-center">
<hr <span className="font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
className="flex-1 border-0"
style={{
height: '1px',
backgroundColor: 'var(--color-border-emphasis)',
}}
/>
<span className="shrink-0 font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
{formatTimeWithSec(thought.timestamp)} {formatTimeWithSec(thought.timestamp)}
</span> </span>
<hr
className="flex-1 border-0"
style={{
height: '1px',
backgroundColor: 'var(--color-border-emphasis)',
}}
/>
</div> </div>
)} )}
<div className="group/thought relative flex text-[11px]"> <div className="group/thought relative flex text-[11px]">
@ -746,6 +732,20 @@ export const LeadThoughtsGroupRow = ({
)} )}
</div> </div>
{/* Last thought preview when body is collapsed */}
{!isBodyVisible && newest.text && (
<div
className="truncate border-t px-3 py-1 text-[11px]"
style={{
borderColor: 'var(--color-border-subtle)',
color: CARD_TEXT_LIGHT,
opacity: 0.7,
}}
>
{newest.text.slice(0, 200)}
</div>
)}
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */} {/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
{isBodyVisible ? ( {isBodyVisible ? (
<div <div

View file

@ -1,7 +1,7 @@
import 'yet-another-react-lightbox/styles.css'; import 'yet-another-react-lightbox/styles.css';
import 'yet-another-react-lightbox/plugins/counter.css'; import 'yet-another-react-lightbox/plugins/counter.css';
import { useMemo } from 'react'; import { createContext, useContext, useEffect, useRef, useMemo } from 'react';
import Lightbox from 'yet-another-react-lightbox'; import Lightbox from 'yet-another-react-lightbox';
import Counter from 'yet-another-react-lightbox/plugins/counter'; import Counter from 'yet-another-react-lightbox/plugins/counter';
@ -10,6 +10,21 @@ import Zoom from 'yet-another-react-lightbox/plugins/zoom';
import type { Plugin, Slide } from 'yet-another-react-lightbox'; import type { Plugin, Slide } from 'yet-another-react-lightbox';
// ---------------------------------------------------------------------------
// LightboxLock context — allows a parent (e.g. Dialog) to know when a
// lightbox is open so it can block dismiss events.
// ---------------------------------------------------------------------------
type LightboxLockCallback = (open: boolean) => void;
const LightboxLockContext = createContext<LightboxLockCallback | null>(null);
/**
* Wrap a Dialog (or any dismissable container) with this provider and pass a
* callback that receives `true` when a lightbox opens and `false` when it closes.
*/
export const LightboxLockProvider = LightboxLockContext.Provider;
export interface ImageLightboxSlide { export interface ImageLightboxSlide {
src: string; src: string;
alt?: string; alt?: string;
@ -30,6 +45,8 @@ interface ImageLightboxProps {
enableZoom?: boolean; enableZoom?: boolean;
enableFullscreen?: boolean; enableFullscreen?: boolean;
showCounter?: boolean; showCounter?: boolean;
/** Called when lightbox open state changes (useful for parent components to block dismiss). */
onOpenChange?: (open: boolean) => void;
} }
export const ImageLightbox = ({ export const ImageLightbox = ({
@ -42,6 +59,7 @@ export const ImageLightbox = ({
enableZoom = true, enableZoom = true,
enableFullscreen = true, enableFullscreen = true,
showCounter, showCounter,
onOpenChange,
}: ImageLightboxProps): React.JSX.Element | null => { }: ImageLightboxProps): React.JSX.Element | null => {
const slides = useMemo<Slide[]>(() => { const slides = useMemo<Slide[]>(() => {
if (slidesProp && slidesProp.length > 0) { if (slidesProp && slidesProp.length > 0) {
@ -63,6 +81,29 @@ export const ImageLightbox = ({
return list; return list;
}, [enableZoom, enableFullscreen, showCounter, slides.length]); }, [enableZoom, enableFullscreen, showCounter, slides.length]);
// Resolve the lightbox lock callback: explicit prop takes priority, then context.
const contextLock = useContext(LightboxLockContext);
const lockCallback = onOpenChange ?? contextLock;
const lockCallbackRef = useRef(lockCallback);
lockCallbackRef.current = lockCallback;
// Track our notified state to avoid double-calling.
const notifiedOpenRef = useRef(false);
// Notify parent on mount when open=true and on unmount.
useEffect(() => {
if (open && !notifiedOpenRef.current) {
notifiedOpenRef.current = true;
lockCallbackRef.current?.(true);
}
return () => {
if (notifiedOpenRef.current) {
notifiedOpenRef.current = false;
lockCallbackRef.current?.(false);
}
};
}, [open]);
if (!open || slides.length === 0) return null; if (!open || slides.length === 0) return null;
return ( return (

View file

@ -28,7 +28,7 @@ import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColorByName } from '@shared/constants/memberColors';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection'; import { AdvancedCliSection } from './AdvancedCliSection';
@ -254,7 +254,8 @@ export const CreateTeamDialog = ({
// Re-read localStorage when advancedKey changes // Re-read localStorage when advancedKey changes
useEffect(() => { useEffect(() => {
const storedEnabled = localStorage.getItem(`team:lastWorktreeEnabled:${advancedKey}`) === 'true'; const storedEnabled =
localStorage.getItem(`team:lastWorktreeEnabled:${advancedKey}`) === 'true';
const storedName = localStorage.getItem(`team:lastWorktreeName:${advancedKey}`) ?? ''; const storedName = localStorage.getItem(`team:lastWorktreeName:${advancedKey}`) ?? '';
setWorktreeEnabledRaw(storedEnabled && Boolean(storedName)); setWorktreeEnabledRaw(storedEnabled && Boolean(storedName));
setWorktreeNameRaw(storedName); setWorktreeNameRaw(storedName);
@ -497,7 +498,7 @@ export const CreateTeamDialog = ({
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }] ? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
: members : members
.filter((m) => m.name.trim()) .filter((m) => m.name.trim())
.map((m, index) => ({ .map((m) => ({
id: m.id, id: m.id,
name: m.name.trim(), name: m.name.trim(),
subtitle: subtitle:
@ -506,7 +507,7 @@ export const CreateTeamDialog = ({
: m.roleSelection && m.roleSelection !== NO_ROLE : m.roleSelection && m.roleSelection !== NO_ROLE
? m.roleSelection ? m.roleSelection
: undefined, : undefined,
color: getMemberColor(index), color: getMemberColorByName(m.name.trim()),
})), })),
[members, soloTeam] [members, soloTeam]
); );

View file

@ -1,3 +1,4 @@
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { import {
@ -5,15 +6,17 @@ import {
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
TASK_STATUS_STYLES, TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { ArrowRight, Eye, MessageSquareX, Play, Plus, ShieldCheck } from 'lucide-react'; import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck } from 'lucide-react';
import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types'; import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types';
interface WorkflowTimelineProps { interface WorkflowTimelineProps {
events: TaskHistoryEvent[]; events: TaskHistoryEvent[];
/** Map of member name → color name for colored badges. */
memberColorMap?: Map<string, string>;
} }
export const WorkflowTimeline = ({ events }: WorkflowTimelineProps) => { export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelineProps) => {
if (events.length === 0) { if (events.length === 0) {
return ( return (
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]"> <div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
@ -43,10 +46,14 @@ export const WorkflowTimeline = ({ events }: WorkflowTimelineProps) => {
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]"> <span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
{time} {time}
</span> </span>
<EventContent event={event} /> <EventContent event={event} memberColorMap={memberColorMap} />
{event.actor ? ( {event.actor ? (
<span className="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]"> <span className="ml-auto shrink-0">
by {event.actor} <MemberBadge
name={event.actor}
color={memberColorMap?.get(event.actor)}
size="sm"
/>
</span> </span>
) : null} ) : null}
</div> </div>
@ -65,7 +72,13 @@ export const WorkflowTimeline = ({ events }: WorkflowTimelineProps) => {
/** Keep old name as re-export for backwards compatibility during migration. */ /** Keep old name as re-export for backwards compatibility during migration. */
export const StatusHistoryTimeline = WorkflowTimeline; export const StatusHistoryTimeline = WorkflowTimeline;
const EventContent = ({ event }: { event: TaskHistoryEvent }) => { const EventContent = ({
event,
memberColorMap,
}: {
event: TaskHistoryEvent;
memberColorMap?: Map<string, string>;
}) => {
switch (event.type) { switch (event.type) {
case 'task_created': case 'task_created':
return ( return (
@ -89,7 +102,12 @@ const EventContent = ({ event }: { event: TaskHistoryEvent }) => {
<Eye size={10} className="text-purple-400" /> <Eye size={10} className="text-purple-400" />
Review requested Review requested
{event.reviewer ? ( {event.reviewer ? (
<span className="text-[10px] text-[var(--color-text-muted)]">({event.reviewer})</span> <MemberBadge
name={event.reviewer}
color={memberColorMap?.get(event.reviewer)}
size="sm"
hideAvatar
/>
) : null} ) : null}
</span> </span>
); );

View file

@ -53,6 +53,8 @@ interface TaskCommentsSectionProps {
onTaskIdClick?: (taskId: string) => void; onTaskIdClick?: (taskId: string) => void;
/** Extra className on the outer comments container (e.g. negative margins for edge-to-edge). */ /** Extra className on the outer comments container (e.g. negative margins for edge-to-edge). */
containerClassName?: string; containerClassName?: string;
/** Snapshot of unread comment IDs captured when the dialog opened. Blue dot is shown for these. */
unreadCommentIds?: Set<string>;
} }
/** Convert `#<task-display-id>` in plain text to markdown links with task:// protocol. */ /** Convert `#<task-display-id>` in plain text to markdown links with task:// protocol. */
@ -83,6 +85,7 @@ export const TaskCommentsSection = ({
onReply, onReply,
onTaskIdClick, onTaskIdClick,
containerClassName, containerClassName,
unreadCommentIds,
}: TaskCommentsSectionProps): React.JSX.Element => { }: TaskCommentsSectionProps): React.JSX.Element => {
const addTaskComment = useStore((s) => s.addTaskComment); const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment); const addingComment = useStore((s) => s.addingComment);
@ -207,6 +210,9 @@ export const TaskCommentsSection = ({
} }
> >
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]"> <div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{unreadCommentIds?.has(comment.id) ? (
<span className="size-2 shrink-0 rounded-full bg-blue-500" />
) : null}
<MemberBadge <MemberBadge
name={comment.author} name={comment.author}
color={colorMap.get(comment.author)} color={colorMap.get(comment.author)}

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColorByName } from '@shared/constants/memberColors';
import { ChevronDown, ChevronRight, Info } from 'lucide-react'; import { ChevronDown, ChevronRight, Info } from 'lucide-react';
import type { MemberDraft } from './membersEditorTypes'; import type { MemberDraft } from './membersEditorTypes';
@ -47,7 +47,9 @@ export const MemberDraftRow = ({
projectPath, projectPath,
mentionSuggestions = [], mentionSuggestions = [],
}: MemberDraftRowProps): React.JSX.Element => { }: MemberDraftRowProps): React.JSX.Element => {
const memberColorSet = getTeamColorSet(getMemberColor(index)); const memberColorSet = getTeamColorSet(
getMemberColorByName(member.name.trim() || `member-${index}`)
);
const [workflowExpanded, setWorkflowExpanded] = useState(false); const [workflowExpanded, setWorkflowExpanded] = useState(false);
const [modelExpanded, setModelExpanded] = useState(false); const [modelExpanded, setModelExpanded] = useState(false);

View file

@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColorByName } from '@shared/constants/memberColors';
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor'; import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
@ -154,7 +154,7 @@ export const MembersEditorSection = ({
() => () =>
members members
.filter((m) => m.name.trim()) .filter((m) => m.name.trim())
.map((m, i) => ({ .map((m) => ({
id: m.id, id: m.id,
name: m.name.trim(), name: m.name.trim(),
subtitle: subtitle:
@ -163,7 +163,7 @@ export const MembersEditorSection = ({
: m.roleSelection && m.roleSelection !== NO_ROLE : m.roleSelection && m.roleSelection !== NO_ROLE
? m.roleSelection ? m.roleSelection
: undefined, : undefined,
color: getMemberColor(i), color: getMemberColorByName(m.name.trim()),
})), })),
[members] [members]
); );

View file

@ -243,6 +243,12 @@
--step-warning-text: #fde68a; --step-warning-text: #fde68a;
--step-warning-border: rgba(245, 158, 11, 0.4); --step-warning-border: rgba(245, 158, 11, 0.4);
--step-warning-bg: rgba(245, 158, 11, 0.1); --step-warning-bg: rgba(245, 158, 11, 0.1);
/* Collapsible section backgrounds (sidebar) */
--color-section-bg: rgba(255, 255, 255, 0.04);
--color-section-bg-open: rgba(255, 255, 255, 0.07);
--color-section-hover: rgba(255, 255, 255, 0.08);
--color-section-hover-open: rgba(255, 255, 255, 0.1);
} }
/* File icon glow — halo so dark icons stay visible on dark backgrounds */ /* File icon glow — halo so dark icons stay visible on dark backgrounds */
@ -490,6 +496,12 @@
--step-warning-text: #b45309; --step-warning-text: #b45309;
--step-warning-border: rgba(180, 83, 9, 0.4); --step-warning-border: rgba(180, 83, 9, 0.4);
--step-warning-bg: rgba(245, 158, 11, 0.1); --step-warning-bg: rgba(245, 158, 11, 0.1);
/* Collapsible section backgrounds (sidebar) */
--color-section-bg: rgba(0, 0, 0, 0.04);
--color-section-bg-open: rgba(0, 0, 0, 0.07);
--color-section-hover: rgba(0, 0, 0, 0.08);
--color-section-hover-open: rgba(0, 0, 0, 0.1);
} }
/* rehype-highlight (highlight.js) — map hljs classes to app theme variables */ /* rehype-highlight (highlight.js) — map hljs classes to app theme variables */

View file

@ -7,8 +7,32 @@ const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
type ReadState = Record<string, number>; // key = "teamName/taskId", value = timestamp type ReadState = Record<string, number>; // key = "teamName/taskId", value = timestamp
let cache: ReadState = {}; // --- localStorage fallback ---
let loaded = false; function lsLoad(): ReadState | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as ReadState)
: null;
} catch {
return null;
}
}
function lsSave(state: ReadState): void {
try {
localStorage.setItem(LS_KEY, JSON.stringify(state));
} catch {
// localStorage full or unavailable — silently ignore
}
}
// Synchronous init from localStorage — guarantees first render sees read state
const lsInitial = lsLoad();
let cache: ReadState = lsInitial ?? {};
let loaded = lsInitial !== null && Object.keys(lsInitial).length > 0;
let idbAvailable = true; // flips to false on first IndexedDB failure let idbAvailable = true; // flips to false on first IndexedDB failure
let saveTimer: ReturnType<typeof setTimeout> | null = null; let saveTimer: ReturnType<typeof setTimeout> | null = null;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@ -48,26 +72,10 @@ export function getUnreadCount(
return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length; return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length;
} }
// --- localStorage fallback --- /** Return the last-read timestamp for a team/task pair (0 if never read). */
function lsLoad(): ReadState | null { export function getLastReadTimestamp(teamName: string, taskId: string): number {
try { const key = `${teamName}/${taskId}`;
const raw = localStorage.getItem(LS_KEY); return cache[key] ?? 0;
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as ReadState)
: null;
} catch {
return null;
}
}
function lsSave(state: ReadState): void {
try {
localStorage.setItem(LS_KEY, JSON.stringify(state));
} catch {
// localStorage full or unavailable — silently ignore
}
} }
// --- Internal --- // --- Internal ---
@ -90,41 +98,38 @@ function scheduleSave(): void {
async function load(): Promise<void> { async function load(): Promise<void> {
if (loaded) return; if (loaded) return;
// Try IndexedDB first // IDB may have fresher data — merge with max timestamp per key
if (hasIndexedDB() && idbAvailable) { if (hasIndexedDB() && idbAvailable) {
try { try {
const stored = await get<ReadState>(IDB_KEY); const stored = await get<ReadState>(IDB_KEY);
if (stored && typeof stored === 'object') { if (stored && typeof stored === 'object') {
cache = { ...stored, ...cache }; const merged = { ...cache };
for (const [k, v] of Object.entries(stored)) {
merged[k] = Math.max(merged[k] ?? 0, v);
}
cache = merged;
notify(); notify();
} }
loaded = true;
return;
} catch { } catch {
// IndexedDB broken — fall back to localStorage silently
idbAvailable = false; idbAvailable = false;
} }
} }
// Fallback: localStorage
const stored = lsLoad();
if (stored) {
cache = { ...stored, ...cache };
notify();
}
loaded = true; loaded = true;
} }
async function save(): Promise<void> { async function save(): Promise<void> {
// Always write to localStorage (sync, reliable)
lsSave(cache);
// Also write to IndexedDB (async, primary)
if (idbAvailable && hasIndexedDB()) { if (idbAvailable && hasIndexedDB()) {
try { try {
await set(IDB_KEY, cache); await set(IDB_KEY, cache);
return;
} catch { } catch {
idbAvailable = false; idbAvailable = false;
} }
} }
lsSave(cache);
} }
export async function cleanupStale(): Promise<void> { export async function cleanupStale(): Promise<void> {
@ -142,20 +147,20 @@ export async function cleanupStale(): Promise<void> {
return { cleaned: result, changed }; return { cleaned: result, changed };
}; };
const { cleaned, changed } = clean(cache);
if (!changed) return;
// Update in-memory cache
cache = cleaned;
notify();
// Persist to both storages
lsSave(cleaned);
if (idbAvailable && hasIndexedDB()) { if (idbAvailable && hasIndexedDB()) {
try { try {
const stored = await get<ReadState>(IDB_KEY); await set(IDB_KEY, cleaned);
if (!stored) return;
const { cleaned, changed } = clean(stored);
if (changed) await set(IDB_KEY, cleaned);
return;
} catch { } catch {
idbAvailable = false; idbAvailable = false;
} }
} }
const stored = lsLoad();
if (!stored) return;
const { cleaned, changed } = clean(stored);
if (changed) lsSave(cleaned);
} }

View file

@ -1,4 +1,4 @@
import { getMemberColor, MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors'; import { getMemberColorByName, MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors';
import type { import type {
LeadActivityState, LeadActivityState,
@ -95,29 +95,31 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
const usedColors = new Set<string>(); const usedColors = new Set<string>();
const paletteSize = MEMBER_COLOR_PALETTE.length; const paletteSize = MEMBER_COLOR_PALETTE.length;
let nextFallback = 0;
for (const member of active) { for (const member of active) {
let color = member.color; let color = member.color;
if (!color || usedColors.has(color)) { if (!color || usedColors.has(color)) {
// Search for an unused palette color, but cap iterations to avoid // Deterministic fallback: hash the member name to a palette color.
// an infinite loop when there are more members than palette colors. // If that color is already taken, linear-probe for the next free one.
const searchStart = nextFallback; color = getMemberColorByName(member.name);
while (usedColors.has(getMemberColor(nextFallback))) { if (usedColors.has(color)) {
nextFallback++; const startIdx = MEMBER_COLOR_PALETTE.indexOf(
if (nextFallback - searchStart >= paletteSize) { color as (typeof MEMBER_COLOR_PALETTE)[number]
// All palette colors exhausted — reuse by cycling );
break; for (let offset = 1; offset < paletteSize; offset++) {
const candidate = MEMBER_COLOR_PALETTE[(startIdx + offset) % paletteSize];
if (!usedColors.has(candidate)) {
color = candidate;
break;
}
} }
} }
color = getMemberColor(nextFallback);
nextFallback++;
} }
map.set(member.name, color); map.set(member.name, color);
usedColors.add(color); usedColors.add(color);
} }
for (let i = 0; i < removed.length; i++) { for (let i = 0; i < removed.length; i++) {
map.set(removed[i].name, removed[i].color ?? getMemberColor(active.length + i)); map.set(removed[i].name, removed[i].color ?? getMemberColorByName(removed[i].name));
} }
map.set('user', 'user'); map.set('user', 'user');

View file

@ -90,3 +90,24 @@ export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
export function getMemberColor(index: number): string { export function getMemberColor(index: number): string {
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length]; return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
} }
/**
* Simple deterministic hash for a string non-negative integer.
* Uses djb2 algorithm for good distribution across the palette.
*/
function hashStringToIndex(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
/**
* Get a stable color for a member name.
* The color is deterministic same name always maps to the same palette entry,
* regardless of member order or team size.
*/
export function getMemberColorByName(name: string): string {
return MEMBER_COLOR_PALETTE[hashStringToIndex(name) % MEMBER_COLOR_PALETTE.length];
}

View file

@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver'; import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
import type { InboxMessage, TeamConfig, TeamTask } from '../../../../src/shared/types/team'; import type {
InboxMessage,
TeamConfig,
TeamTask,
TeamTaskWithKanban,
} from '../../../../src/shared/types/team';
describe('TeamMemberResolver', () => { describe('TeamMemberResolver', () => {
it('builds roster from config + meta + inbox only', () => { it('builds roster from config + meta + inbox only', () => {
@ -65,4 +70,74 @@ describe('TeamMemberResolver', () => {
expect(names).toContain('team-lead'); expect(names).toContain('team-lead');
expect(names).toContain('alice'); expect(names).toContain('alice');
}); });
it('sets currentTaskId for in_progress task', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [{ name: 'bob', agentType: 'general-purpose' }],
};
const tasks: TeamTaskWithKanban[] = [
{ id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' },
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBe('t1');
});
it('clears currentTaskId when task is approved via kanbanColumn', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [{ name: 'bob', agentType: 'general-purpose' }],
};
const tasks: TeamTaskWithKanban[] = [
{
id: 't1',
subject: 'Work',
status: 'in_progress',
owner: 'bob',
reviewState: 'approved',
kanbanColumn: 'approved',
},
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull();
});
it('clears currentTaskId when task reviewState is approved even without kanbanColumn', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [{ name: 'bob', agentType: 'general-purpose' }],
};
const tasks: TeamTaskWithKanban[] = [
{
id: 't1',
subject: 'Work',
status: 'in_progress',
owner: 'bob',
reviewState: 'approved',
// kanbanColumn not set — stale data scenario
},
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull();
});
it('clears currentTaskId when task status is completed', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [{ name: 'bob', agentType: 'general-purpose' }],
};
const tasks: TeamTaskWithKanban[] = [
{ id: 't1', subject: 'Work', status: 'completed', owner: 'bob' },
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull();
});
}); });

View file

@ -42,6 +42,22 @@ const hoisted = vi.hoisted(() => {
files.set(p, JSON.stringify(rows)); files.set(p, JSON.stringify(rows));
return message; return message;
}), }),
sendInboxMessage: vi.fn(
(teamName: string, message: Record<string, unknown>) => {
const member =
typeof message.member === 'string'
? message.member
: typeof message.to === 'string'
? message.to
: 'unknown';
const p = `/mock/teams/${teamName}/inboxes/${member}.json`;
const current = files.get(p);
const rows = current ? (JSON.parse(current) as unknown[]) : [];
rows.push(message);
files.set(p, JSON.stringify(rows));
return { deliveredToInbox: true, messageId: 'mock-id', message };
}
),
}; };
}); });
@ -67,6 +83,8 @@ vi.mock('agent-teams-controller', () => ({
messages: { messages: {
appendSentMessage: (message: Record<string, unknown>) => appendSentMessage: (message: Record<string, unknown>) =>
hoisted.appendSentMessage(teamName, message), hoisted.appendSentMessage(teamName, message),
sendMessage: (message: Record<string, unknown>) =>
hoisted.sendInboxMessage(teamName, message),
}, },
}), }),
})); }));
@ -146,6 +164,7 @@ describe('TeamProvisioningService pre-ready live messages', () => {
beforeEach(() => { beforeEach(() => {
hoisted.files.clear(); hoisted.files.clear();
hoisted.appendSentMessage.mockClear(); hoisted.appendSentMessage.mockClear();
hoisted.sendInboxMessage.mockClear();
}); });
it('pre-ready assistant text is added to liveLeadProcessMessages', () => { it('pre-ready assistant text is added to liveLeadProcessMessages', () => {
@ -301,7 +320,9 @@ describe('TeamProvisioningService pre-ready live messages', () => {
expect(live[0].to).toBe('team-lead'); expect(live[0].to).toBe('team-lead');
expect(live[0].text).toBe('Need clarification on #abcd1234'); expect(live[0].text).toBe('Need clarification on #abcd1234');
expect(live[0].source).toBe('lead_process'); expect(live[0].source).toBe('lead_process');
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1); // Non-user recipient → delivered to inbox, not sentMessages
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
}); });
it('post-ready path also uses the unified helper', () => { it('post-ready path also uses the unified helper', () => {
@ -325,4 +346,80 @@ describe('TeamProvisioningService pre-ready live messages', () => {
expect.objectContaining({ type: 'lead-message', teamName: 'my-team' }) expect.objectContaining({ type: 'lead-message', teamName: 'my-team' })
); );
}); });
it('SendMessage(to:teammate) creates inbox row and emits inbox detail for recipient', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
service.setTeamChangeEmitter(emitter);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'alice',
content: 'Please check the migration.',
summary: 'Migration check',
},
},
],
});
// Delivered to recipient inbox, not sentMessages
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
expect(hoisted.sendInboxMessage).toHaveBeenCalledWith(
'my-team',
expect.objectContaining({ member: 'alice' })
);
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
// Emits inbox event for the specific recipient
expect(emitter).toHaveBeenCalledWith(
expect.objectContaining({
type: 'inbox',
teamName: 'my-team',
detail: 'inboxes/alice.json',
})
);
});
it('SendMessage(to:user) still persists to sentMessages.json', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
service.setTeamChangeEmitter(emitter);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'user',
content: 'Task completed!',
summary: 'Done',
},
},
],
});
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
expect(emitter).toHaveBeenCalledWith(
expect.objectContaining({
type: 'inbox',
teamName: 'my-team',
detail: 'sentMessages.json',
})
);
});
}); });

View file

@ -50,6 +50,22 @@ const hoisted = vi.hoisted(() => {
files.set(sentMessagesPath, JSON.stringify(rows)); files.set(sentMessagesPath, JSON.stringify(rows));
return message; return message;
}), }),
sendInboxMessage: vi.fn(
(teamName: string, message: Record<string, unknown>) => {
const member =
typeof message.member === 'string'
? message.member
: typeof message.to === 'string'
? message.to
: 'unknown';
const p = `/mock/teams/${teamName}/inboxes/${member}.json`;
const current = files.get(p);
const rows = current ? (JSON.parse(current) as unknown[]) : [];
rows.push(message);
files.set(p, JSON.stringify(rows));
return { deliveredToInbox: true, messageId: 'mock-id', message };
}
),
setAtomicWriteShouldFail: (next: boolean) => { setAtomicWriteShouldFail: (next: boolean) => {
atomicWriteShouldFail = next; atomicWriteShouldFail = next;
}, },
@ -85,6 +101,8 @@ vi.mock('agent-teams-controller', () => ({
messages: { messages: {
appendSentMessage: (message: Record<string, unknown>) => appendSentMessage: (message: Record<string, unknown>) =>
hoisted.appendSentMessage(teamName, message), hoisted.appendSentMessage(teamName, message),
sendMessage: (message: Record<string, unknown>) =>
hoisted.sendInboxMessage(teamName, message),
}, },
}), }),
})); }));