fix(team): align idle inbox semantics across app

This commit is contained in:
iliya 2026-04-08 17:45:09 +03:00
parent 2c9926c734
commit 78d4c2826b
23 changed files with 1646 additions and 57 deletions

View file

@ -51,7 +51,8 @@ import {
getTrafficLightPositionForZoom,
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
} from '@shared/constants';
import { isInboxNoiseMessage, parseInboxJson } from '@shared/utils/inboxNoise';
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
import { parseInboxJson } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger';
import { app, BrowserWindow } from 'electron';
import { existsSync } from 'fs';
@ -274,7 +275,7 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
// Skip messages sent from our own UI
if (msg.source && suppressedSources.has(msg.source)) continue;
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
if (isInboxNoiseMessage(msg.text)) continue;
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
const fromLabel = msg.from || 'Unknown';
const extracted = extractNotificationContent(msg.text);
@ -345,7 +346,7 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
// Skip messages sent from our own UI
if (msg.source && suppressedSources.has(msg.source)) continue;
// Skip internal coordination noise
if (isInboxNoiseMessage(msg.text)) continue;
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
const fromLabel = msg.from || 'team-lead';
const extracted = extractNotificationContent(msg.text);

View file

@ -1,9 +1,10 @@
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
import type { InboxMessage } from '@shared/types';
const MAX_INBOX_FILE_BYTES = 10 * 1024 * 1024; // 10MB — skip corrupt/oversized inbox files
@ -95,10 +96,10 @@ export class TeamInboxReader {
// often lack messageId because Claude Code CLI doesn't generate one.
// We produce a deterministic hash so the same message always gets the same ID
// across reads — important for React keys, dedup, and message tracking.
const messageId =
typeof row.messageId === 'string' && row.messageId.trim().length > 0
? row.messageId
: `inbox-${createHash('sha256').update(`${row.from}\n${row.timestamp}\n${row.text}`).digest('hex').slice(0, 16)}`;
const messageId = getEffectiveInboxMessageId(row);
if (!messageId) {
continue;
}
messages.push({
from: row.from,
to: typeof row.to === 'string' ? row.to : undefined,

View file

@ -65,6 +65,7 @@ import { buildActionModeProtocol } from './actionModeInstructions';
import { atomicWriteAsync } from './atomicWrite';
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
import { withFileLock } from './fileLock';
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
import { withInboxLock } from './inboxLock';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
@ -96,6 +97,10 @@ import {
resolveGeminiRuntimeAuth,
type GeminiRuntimeAuthState,
} from '../runtime/geminiRuntimeAuth';
import {
classifyIdleNotificationForMainProcess,
type ClassifiedMainProcessIdle,
} from './idleNotificationMainProcessSemantics';
/**
* Kill a team CLI process using SIGKILL (uncatchable).
@ -110,6 +115,18 @@ function killTeamProcess(child: ChildProcess | null | undefined): void {
killProcessTree(child, 'SIGKILL');
}
function buildRelayInboxView(messages: RelayInboxMessage[]): RelayInboxMessageView[] {
return messages.map((message) => {
const isCrossTeamLike =
message.source === CROSS_TEAM_SOURCE || message.source === CROSS_TEAM_SENT_SOURCE;
return {
message,
idle: isCrossTeamLike ? null : classifyIdleNotificationForMainProcess(message.text),
isCoarseNoise: isCrossTeamLike ? false : isInboxNoiseMessage(message.text),
};
});
}
interface PersistedRuntimeMemberLike {
name?: string;
agentId?: string;
@ -117,6 +134,14 @@ interface PersistedRuntimeMemberLike {
backendType?: string;
}
type RelayInboxMessage = InboxMessage & { messageId: string };
type RelayInboxMessageView = {
message: RelayInboxMessage;
idle: ClassifiedMainProcessIdle | null;
isCoarseNoise: boolean;
};
import type {
ActiveToolCall,
CrossTeamSendResult,
@ -1578,6 +1603,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(${buildCanonicalSendMessageExample({ to: 'alice', summary: 'short reply', message: 'your reply' })}).
- Example: if alice asks "Сколько времени осталось?" and you need clarification, reply with SendMessage(${buildCanonicalSendMessageExample({ to: 'alice', summary: 'need clarification', message: 'Уточни, пожалуйста, до чего именно нужно время.' })}) instead of asking that question in plain assistant text.
- Do NOT reply to low-value acknowledgements or presence pings such as "ready", "online", "status accepted", "awaiting task", or "received" unless you need to give the teammate a concrete next action.
- Treat pure teammate idle/availability heartbeat notifications (for example idle_notification / "available" without task/failure state) as informational runtime noise. Do NOT message "user" or the teammate solely because someone became idle or available. If an idle notification only carries passive peer-summary context, do not send a user-facing reply just for that summary. Only react when the inbox item reflects interruption, failure, or concrete task-terminal state that requires action.
- Cross-team communication: when work needs expertise, coordination, review, or a decision from ANOTHER team, CALL the MCP tool named "cross_team_send" with teamName: "${teamName}" and a focused actionable message.
- Before sending cross-team, use MCP tool "cross_team_list_targets" with teamName: "${teamName}" to discover valid target teams.
- To review messages your team already sent to other teams, use MCP tool "cross_team_get_outbox" with teamName: "${teamName}".
@ -5382,16 +5408,42 @@ export class TeamProvisioningService {
if (unread.length === 0) return 0;
const noiseUnread = unread.filter((m) => isInboxNoiseMessage(m.text));
if (noiseUnread.length > 0) {
const relayView = buildRelayInboxView(unread);
const silentNoiseUnread = relayView
.filter(({ idle, isCoarseNoise }) => {
if (idle) return idle.handling === 'silent_noise';
return isCoarseNoise;
})
.map(({ message }) => message);
const passiveIdleUnread = relayView
.filter(({ idle }) => idle?.handling === 'passive_activity')
.map(({ message }) => message);
const actionableUnread = relayView
.filter(({ idle, isCoarseNoise }) => {
if (idle) return idle.handling === 'visible_actionable';
return !isCoarseNoise;
})
.map(({ message }) => message);
const readOnlyIgnoredUnread = [...silentNoiseUnread, ...passiveIdleUnread];
if (readOnlyIgnoredUnread.length > 0) {
try {
await this.markInboxMessagesRead(teamName, memberName, noiseUnread);
} catch {
// best-effort
await this.markInboxMessagesRead(teamName, memberName, readOnlyIgnoredUnread);
if (passiveIdleUnread.length > 0) {
logger.debug(
`[${teamName}] member relay marked ${passiveIdleUnread.length} passive idle message(s) read without relay for ${memberName}`
);
}
} catch (error) {
logger.debug(
`[${teamName}] member relay failed to mark ${readOnlyIgnoredUnread.length} ignored inbox message(s) read for ${memberName}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
const actionableUnread = unread.filter((m) => !isInboxNoiseMessage(m.text));
if (actionableUnread.length === 0) return 0;
const MAX_RELAY = 10;
@ -5584,6 +5636,23 @@ export class TeamProvisioningService {
if (unread.length === 0) return 0;
const relayView = buildRelayInboxView(unread);
const silentIdleIds = new Set(
relayView
.filter(({ idle }) => idle?.handling === 'silent_noise')
.map(({ message }) => message.messageId)
);
const passiveIdleIds = new Set(
relayView
.filter(({ idle }) => idle?.handling === 'passive_activity')
.map(({ message }) => message.messageId)
);
const coarseNonIdleNoiseIds = new Set(
relayView
.filter(({ idle, isCoarseNoise }) => idle === null && isCoarseNoise)
.map(({ message }) => message.messageId)
);
const latestOutboundByConversation = new Map<string, number>();
const latestReadInboundByConversation = new Map<string, number>();
for (const message of leadInboxMessages) {
@ -5651,7 +5720,8 @@ export class TeamProvisioningService {
// Includes noise (idle/shutdown), cross-team sender copies, cross-team reply dedup.
const permanentlyIgnored = unread.filter(
(m) =>
isInboxNoiseMessage(m.text) ||
silentIdleIds.has(m.messageId) ||
coarseNonIdleNoiseIds.has(m.messageId) ||
m.source === CROSS_TEAM_SENT_SOURCE ||
isCrossTeamReplyToOwnOutbound(m) ||
wasRecentlyDeliveredCrossTeam(m)
@ -5670,17 +5740,37 @@ export class TeamProvisioningService {
}
}
const passiveIdleUnread = unread.filter((m) => passiveIdleIds.has(m.messageId));
if (passiveIdleUnread.length > 0) {
try {
await this.markInboxMessagesRead(teamName, leadName, passiveIdleUnread);
logger.debug(
`[${teamName}] lead relay marked ${passiveIdleUnread.length} passive idle message(s) read without relay`
);
} catch (error) {
logger.debug(
`[${teamName}] lead relay failed to mark ${passiveIdleUnread.length} passive idle message(s) read: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
const readOnlyIgnoredIds = new Set([
...permanentlyIgnored.map((m) => m.messageId),
...passiveIdleUnread.map((m) => m.messageId),
]);
const remainingUnread = unread.filter((m) => !readOnlyIgnoredIds.has(m.messageId));
// Category 2: same-team native delivery confirmation (one-to-one pairing).
const { nativeMatchedMessageIds, persisted: sameTeamPersisted } =
await this.confirmSameTeamNativeMatches(teamName, leadName, unread);
await this.confirmSameTeamNativeMatches(teamName, leadName, remainingUnread);
// Category 3: deferred by age — source-less messages within grace window of CURRENT run.
// NOT marked read (crash safety: if native delivery fails, retry will relay).
const runStartedAtMs = Date.parse(run.startedAt);
const permanentlyIgnoredIds = new Set(permanentlyIgnored.map((m) => m.messageId));
const deferredByAge = unread.filter(
const deferredByAge = remainingUnread.filter(
(m) =>
!permanentlyIgnoredIds.has(m.messageId) &&
!nativeMatchedMessageIds.has(m.messageId) &&
this.shouldDeferSameTeamMessage(m, leadName, runStartedAtMs)
);
@ -5690,24 +5780,17 @@ export class TeamProvisioningService {
// NOT relayed to the lead. The actual interception + ToolApprovalRequest emission
// is handled by the early scan above (which checks processedPermissionRequestIds).
const permissionRequestIds = new Set(
unread
.filter(
(m) =>
!permanentlyIgnoredIds.has(m.messageId) &&
!deferredIds.has(m.messageId) &&
parsePermissionRequest(m.text) !== null
)
remainingUnread
.filter((m) => !deferredIds.has(m.messageId) && parsePermissionRequest(m.text) !== null)
.map((m) => m.messageId)
);
// Actionable: everything not in any category.
const actionableUnread = unread.filter(
const actionableUnread = remainingUnread.filter(
(m) =>
!permanentlyIgnoredIds.has(m.messageId) &&
!nativeMatchedMessageIds.has(m.messageId) &&
!deferredIds.has(m.messageId) &&
!permissionRequestIds.has(m.messageId) &&
!isInboxNoiseMessage(m.text)
!permissionRequestIds.has(m.messageId)
);
// Layer 3: schedule retry timers.
@ -6038,7 +6121,7 @@ export class TeamProvisioningService {
for (const item of parsed) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const msgId = typeof row.messageId === 'string' ? row.messageId : null;
const msgId = getEffectiveInboxMessageId(row);
if (!msgId || !ids.has(msgId)) continue;
if (row.read !== true) {

View file

@ -0,0 +1,34 @@
import {
classifyIdleNotificationText,
type IdleNotificationPrimaryKind as MainProcessIdlePrimaryKind,
} from '@shared/utils/idleNotificationSemantics';
export type MainProcessIdleHandling = 'silent_noise' | 'passive_activity' | 'visible_actionable';
export type ClassifiedMainProcessIdle = {
primaryKind: MainProcessIdlePrimaryKind;
hasPeerSummary: boolean;
peerSummary: string | null;
handling: MainProcessIdleHandling;
};
export function classifyIdleNotificationForMainProcess(
text: string
): ClassifiedMainProcessIdle | null {
const classified = classifyIdleNotificationText(text);
if (!classified) return null;
const handling: MainProcessIdleHandling =
classified.primaryKind === 'heartbeat'
? classified.hasPeerSummary
? 'passive_activity'
: 'silent_noise'
: 'visible_actionable';
return {
primaryKind: classified.primaryKind,
hasPeerSummary: classified.hasPeerSummary,
peerSummary: classified.peerSummary,
handling,
};
}

View file

@ -0,0 +1,26 @@
import { createHash } from 'crypto';
type InboxIdentityLike = {
messageId?: unknown;
from?: unknown;
timestamp?: unknown;
text?: unknown;
};
export function buildLegacyInboxMessageId(from: string, timestamp: string, text: string): string {
return `inbox-${createHash('sha256').update(`${from}\n${timestamp}\n${text}`).digest('hex').slice(0, 16)}`;
}
export function getEffectiveInboxMessageId(row: InboxIdentityLike): string | null {
if (typeof row.messageId === 'string' && row.messageId.trim().length > 0) {
return row.messageId;
}
if (
typeof row.from !== 'string' ||
typeof row.timestamp !== 'string' ||
typeof row.text !== 'string'
) {
return null;
}
return buildLegacyInboxMessageId(row.from, row.timestamp, row.text);
}

View file

@ -791,6 +791,24 @@ export const TeamDetailView = ({
return () => window.clearInterval(id);
}, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]);
// Keep team message state fresh while we are explicitly waiting for a reply.
// Direct lead replies land in the lead session JSONL first; without a light
// refresh loop here, the Messages panel can keep showing stale "awaiting reply"
// state even though the lead has already answered.
useEffect(() => {
if (!isThisTabActive) return;
if (!data) return;
if (Object.keys(pendingRepliesByMember).length === 0) return;
void refreshTeamData(teamName);
const id = window.setInterval(() => {
void refreshTeamData(teamName);
}, 2_000);
return () => window.clearInterval(id);
}, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]);
useEffect(() => {
if (!projectId) return;

View file

@ -30,6 +30,10 @@ import {
getSanitizedInboxMessageText,
} from '@renderer/utils/bootstrapPromptSanitizer';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
classifyIdleNotification,
getIdleNoiseLabel,
} from '@renderer/utils/idleNotificationSemantics';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import {
areInboxMessagesEquivalentForRender,
@ -223,16 +227,20 @@ function getStringField(obj: StructuredMessage, key: string): string | null {
/** Check if a message renders as a compact noise row (idle, shutdown, etc.). */
export function isNoiseMessage(text: string): boolean {
const parsed = parseStructuredAgentMessage(text);
return parsed !== null && getNoiseLabel(parsed) !== null;
return (
getIdleNoiseLabel(text) !== null ||
((): boolean => {
const parsed = parseStructuredAgentMessage(text);
return parsed !== null && getNoiseLabel(parsed) !== null;
})()
);
}
function getNoiseLabel(parsed: StructuredMessage): string | null {
const type = getStringField(parsed, 'type');
if (type === 'idle_notification') {
const reason = getStringField(parsed, 'idleReason');
return reason ? `Idle (${reason})` : 'Idle';
return getIdleNoiseLabel(parsed);
}
if (type === 'shutdown_response') {
@ -545,6 +553,7 @@ export const ActivityItem = memo(
const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text));
// Never collapse rate limit messages as noise — they must be visible
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
const idleSemantic = useMemo(() => classifyIdleNotification(message), [message]);
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
const isManaged = collapseMode === 'managed';
@ -656,6 +665,9 @@ export const ActivityItem = memo(
}, [isCrossTeamAny, strippedText]);
const rawSummary = useMemo(() => {
if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) {
return idleSemantic.peerSummary;
}
if (isSlashCommandResult && message.commandOutput) {
return message.summary || getCommandOutputSummary(message.text);
}
@ -683,6 +695,7 @@ export const ActivityItem = memo(
isSlashCommandResult,
message.commandOutput,
message,
idleSemantic,
slashCommandMeta,
structured,
]);

View file

@ -9,7 +9,7 @@ import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
import {
CheckCheck,
ChevronsDownUp,
@ -190,12 +190,21 @@ export const MessagesPanel = memo(function MessagesPanel({
});
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
const activityTimelineMessages = useMemo(() => {
return filterTeamMessages(messages, {
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
timeWindow,
filter: messagesFilter,
searchQuery: messagesSearchQuery,
});
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
const replyCandidateMessages = useMemo(
() =>
messages.filter(
(m) =>
m.messageKind !== 'task_comment_notification' &&
!isInboxNoiseMessage(typeof m.text === 'string' ? m.text : '')
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
),
[messages]
);
@ -204,17 +213,17 @@ export const MessagesPanel = memo(function MessagesPanel({
const expandedItem = useMemo<TimelineItem | null>(() => {
if (!expandedItemKey) return null;
if (!expandedItemKey.startsWith('thoughts-')) {
const msg = filteredMessages.find((m) => toMessageKey(m) === expandedItemKey);
const msg = activityTimelineMessages.find((m) => toMessageKey(m) === expandedItemKey);
return msg ? { type: 'message', message: msg } : null;
}
const allItems = groupTimelineItems(filteredMessages);
const allItems = groupTimelineItems(activityTimelineMessages);
return (
allItems.find(
(item) =>
item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey
) ?? null
);
}, [expandedItemKey, filteredMessages]);
}, [expandedItemKey, activityTimelineMessages]);
// Auto-clear stale expanded key
useEffect(() => {
@ -408,7 +417,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onTaskClick={onTaskClick}
/>
<ActivityTimeline
messages={filteredMessages}
messages={activityTimelineMessages}
teamName={teamName}
members={members}
readState={readState}
@ -574,7 +583,7 @@ export const MessagesPanel = memo(function MessagesPanel({
/>{' '}
</div>
<ActivityTimeline
messages={filteredMessages}
messages={activityTimelineMessages}
teamName={teamName}
members={members}
readState={readState}

View file

@ -10,7 +10,11 @@
import { getUnreadCount } from '@renderer/services/commentReadStorage';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
import { getInboxJsonType, isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import {
getIdleGraphLabel,
classifyIdleNotificationText,
} from '@shared/utils/idleNotificationSemantics';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isLeadMember } from '@shared/utils/leadDetection';
import type {
@ -565,12 +569,10 @@ export class TeamGraphAdapter {
// Skip comment notifications — #buildCommentParticles handles them with real text
if (msg.summary?.startsWith('Comment on ')) continue;
// Handle noise messages: idle shows as "idle", others (shutdown, terminated) skip entirely
// Handle noise messages: idle uses semantic label, others (shutdown, terminated) skip entirely
const msgText = msg.text ?? '';
const noiseType = getInboxJsonType(msgText);
if (noiseType === 'idle_notification') {
// Show idle as a simple label, don't skip
} else if (isInboxNoiseMessage(msgText)) {
const idleSemantic = classifyIdleNotificationText(msgText);
if (!idleSemantic && isInboxNoiseMessage(msgText)) {
continue; // skip shutdown_approved, teammate_terminated, shutdown_request
}
@ -623,11 +625,9 @@ export class TeamGraphAdapter {
);
const isFromTeammate = fromId !== leadId;
// For idle notifications, show a clean "idle" label instead of raw JSON
const particleLabel =
noiseType === 'idle_notification'
? 'idle'
: TeamGraphAdapter.#buildParticleLabel(msg.summary ?? msg.text, 'inbox');
getIdleGraphLabel(msgText) ??
TeamGraphAdapter.#buildParticleLabel(msg.summary ?? msg.text, 'inbox');
particles.push({
id: `particle:msg:${teamName}:${msgKey}`,

View file

@ -0,0 +1,76 @@
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
import type { InboxMessage } from '@shared/types';
import type {
ClassifiedIdleNotification as SharedClassifiedIdleNotification,
IdleNotificationPayload,
IdleNotificationPrimaryKind,
} from '@shared/utils/idleNotificationSemantics';
export type ClassifiedIdleNotification = {
payload: IdleNotificationPayload;
primaryKind: IdleNotificationPrimaryKind;
hasPeerSummary: boolean;
peerSummary: string | null;
countsAsBootstrapConfirmation: boolean;
liveDelivery: 'silent_finalize' | 'passive_activity' | 'visible_actionable';
uiPresentation: 'heartbeat' | 'peer_summary' | 'interrupted' | 'task_terminal' | 'failure';
};
export function classifyIdleNotification(
value: string | Pick<InboxMessage, 'text'> | Record<string, unknown> | IdleNotificationPayload
): ClassifiedIdleNotification | null {
const text =
typeof value === 'string'
? value
: 'text' in value && typeof value.text === 'string'
? value.text
: JSON.stringify(value);
const shared = classifyIdleNotificationText(text);
if (!shared) return null;
const liveDelivery =
shared.primaryKind === 'heartbeat'
? shared.hasPeerSummary
? 'passive_activity'
: 'silent_finalize'
: 'visible_actionable';
const uiPresentation =
shared.primaryKind === 'heartbeat'
? shared.hasPeerSummary
? 'peer_summary'
: 'heartbeat'
: shared.primaryKind;
return {
...(shared as SharedClassifiedIdleNotification),
liveDelivery,
uiPresentation,
};
}
export function shouldKeepIdleMessageInActivityWhenNoiseHidden(
value: string | Pick<InboxMessage, 'text'> | Record<string, unknown> | IdleNotificationPayload
): boolean {
const classified = classifyIdleNotification(value);
return classified?.liveDelivery === 'passive_activity';
}
export function getIdleNoiseLabel(
value: string | Pick<InboxMessage, 'text'> | Record<string, unknown> | IdleNotificationPayload
): string | null {
const classified = classifyIdleNotification(value);
if (!classified) return null;
if (classified.uiPresentation !== 'heartbeat') {
return null;
}
const reason =
typeof classified.payload.idleReason === 'string' &&
classified.payload.idleReason.trim().length > 0
? classified.payload.idleReason.trim()
: null;
return reason ? `Idle (${reason})` : 'Idle';
}

View file

@ -2,6 +2,7 @@ import {
getSanitizedInboxMessageSummary,
getSanitizedInboxMessageText,
} from '@renderer/utils/bootstrapPromptSanitizer';
import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import type { InboxMessage } from '@shared/types';
@ -15,12 +16,18 @@ export interface TeamMessagesFilter {
export function filterTeamMessages(
messages: InboxMessage[],
options: {
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
timeWindow?: { start: number; end: number } | null;
filter: TeamMessagesFilter;
searchQuery: string;
}
): InboxMessage[] {
const { timeWindow, filter, searchQuery } = options;
const {
includePassiveIdlePeerSummariesWhenNoiseHidden = false,
timeWindow,
filter,
searchQuery,
} = options;
let list = messages.filter((m) => m.messageKind !== 'task_comment_notification');
if (timeWindow) {
@ -30,7 +37,14 @@ export function filterTeamMessages(
});
}
if (!filter.showNoise) {
list = list.filter((m) => !isInboxNoiseMessage(typeof m.text === 'string' ? m.text : ''));
list = list.filter((m) => {
const text = typeof m.text === 'string' ? m.text : '';
if (!isInboxNoiseMessage(text)) return true;
return (
includePassiveIdlePeerSummariesWhenNoiseHidden &&
shouldKeepIdleMessageInActivityWhenNoiseHidden(text)
);
});
}
const hasFrom = filter.from.size > 0;

View file

@ -0,0 +1,86 @@
import { isInboxNoiseMessage, parseInboxJson } from './inboxNoise';
export type IdleNotificationPayload = {
type: 'idle_notification';
from?: string;
timestamp?: string;
idleReason?: 'available' | 'interrupted' | 'failed';
summary?: string;
completedTaskId?: string;
completedStatus?: 'resolved' | 'blocked' | 'failed';
failureReason?: string;
};
export type IdleNotificationPrimaryKind = 'heartbeat' | 'interrupted' | 'task_terminal' | 'failure';
export type ClassifiedIdleNotification = {
payload: IdleNotificationPayload;
primaryKind: IdleNotificationPrimaryKind;
hasPeerSummary: boolean;
peerSummary: string | null;
countsAsBootstrapConfirmation: boolean;
};
function getTrimmedOptionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function isIdleNotificationPayload(value: unknown): value is IdleNotificationPayload {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
(value as { type?: unknown }).type === 'idle_notification'
);
}
export function classifyIdleNotificationText(text: string): ClassifiedIdleNotification | null {
const parsed = parseInboxJson(text);
if (!isIdleNotificationPayload(parsed)) return null;
const peerSummary = getTrimmedOptionalString(parsed.summary);
const hasPeerSummary = peerSummary !== null;
const failureReason = getTrimmedOptionalString(parsed.failureReason);
const completedTaskId = getTrimmedOptionalString(parsed.completedTaskId);
let primaryKind: IdleNotificationPrimaryKind;
if (
parsed.idleReason === 'failed' ||
failureReason !== null ||
parsed.completedStatus === 'failed'
) {
primaryKind = 'failure';
} else if (
completedTaskId !== null ||
parsed.completedStatus === 'resolved' ||
parsed.completedStatus === 'blocked'
) {
primaryKind = 'task_terminal';
} else if (parsed.idleReason === 'interrupted') {
primaryKind = 'interrupted';
} else {
primaryKind = 'heartbeat';
}
return {
payload: parsed,
primaryKind,
hasPeerSummary,
peerSummary,
countsAsBootstrapConfirmation: primaryKind !== 'failure',
};
}
export function shouldSuppressDesktopNotificationForInboxText(text: string): boolean {
return classifyIdleNotificationText(text) !== null || isInboxNoiseMessage(text);
}
export function shouldExcludeInboxTextFromReplyCandidates(text: string): boolean {
return classifyIdleNotificationText(text) !== null || isInboxNoiseMessage(text);
}
export function getIdleGraphLabel(text: string): string | null {
const classified = classifyIdleNotificationText(text);
if (!classified) return null;
return classified.hasPeerSummary && classified.peerSummary ? classified.peerSummary : 'idle';
}

View file

@ -2276,6 +2276,75 @@ describe('TeamDataService', () => {
});
});
it('keeps the inbox passive-summary row preferred over a read-state-changed lead_process duplicate', async () => {
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [{ name: 'team-lead', role: 'Lead' }],
leadSessionId: 'lead-1',
})),
} as never,
{
getTasks: vi.fn(async () => []),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-04-08T10:00:00.000Z',
read: true,
summary: 'Peer summary',
messageId: 'passive-idle-dup-1',
},
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-04-08T10:00:01.000Z',
read: false,
source: 'lead_process',
relayOfMessageId: 'passive-idle-dup-1',
messageId: 'passive-idle-dup-1',
},
]),
} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
} as never,
{} as never,
{} as never,
{
readMessages: vi.fn(async () => []),
} as never
);
const data = await service.getTeamData('my-team');
const result = data.messages.find((message) => message.messageId === 'passive-idle-dup-1');
expect(result).toBeDefined();
expect(result?.source).not.toBe('lead_process');
expect(result).toMatchObject({
summary: 'Peer summary',
read: true,
});
});
it('caches unchanged lead-session extraction results and returns defensive clones', async () => {
const service = createLeadSessionCachingService();
const jsonlPath = await createTempJsonl([

View file

@ -141,11 +141,9 @@ describe('TeamInboxReader', () => {
const messages = await reader.getMessagesFor('my-team', 'alice');
expect(messages).toHaveLength(2);
// Legacy row gets a deterministic hash-based messageId
const legacy = messages.find((m) => m.text === 'legacy');
expect(legacy).toBeDefined();
expect(legacy!.messageId).toMatch(/^inbox-[a-f0-9]{16}$/);
// Explicit messageId is preserved
expect(legacy!.messageId).toBe('inbox-3d4d01c54fc0dc52');
const supported = messages.find((m) => m.text === 'supported');
expect(supported).toBeDefined();
expect(supported!.messageId).toBe('m-1');

View file

@ -130,6 +130,7 @@ vi.mock('agent-teams-controller', () => ({
},
}));
import { buildLegacyInboxMessageId } from '../../../../src/main/services/team/inboxMessageIdentity';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
function seedConfig(teamName: string): void {
@ -217,6 +218,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
hoisted.files.clear();
hoisted.readFile.mockClear();
hoisted.atomicWrite.mockClear();
hoisted.setAtomicWriteShouldFail(false);
hoisted.appendSentMessage.mockClear();
hoisted.sendInboxMessage.mockClear();
hoisted.setAtomicWriteShouldFail(false);
@ -907,6 +909,277 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(payload).toContain('Please review my changes');
});
it('marks pure member heartbeat idle as read without relaying it', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedMemberInbox(teamName, 'alice', [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
}),
timestamp: '2026-02-23T15:10:00.000Z',
read: false,
messageId: 'idle-member-heartbeat-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayMemberInboxMessages(teamName, 'alice');
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
const inbox = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
) as Array<{ messageId?: string; read?: boolean }>;
expect(inbox).toEqual([
expect.objectContaining({
messageId: 'idle-member-heartbeat-1',
read: true,
}),
]);
});
it('marks member heartbeat with peer summary read and does not relay it', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedMemberInbox(teamName, 'alice', [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-02-23T15:11:00.000Z',
read: false,
messageId: 'idle-member-summary-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const first = await service.relayMemberInboxMessages(teamName, 'alice');
const second = await service.relayMemberInboxMessages(teamName, 'alice');
expect(first).toBe(0);
expect(second).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
const inbox = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
) as Array<{ messageId?: string; read?: boolean }>;
expect(inbox).toEqual([
expect.objectContaining({
messageId: 'idle-member-summary-1',
read: true,
}),
]);
});
it('marks legacy member passive idle rows read via fallback identity', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedMemberInbox(teamName, 'alice', [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-02-23T15:11:30.000Z',
read: false,
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayMemberInboxMessages(teamName, 'alice');
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
const inbox = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
) as Array<{ read?: boolean }>;
expect(inbox).toEqual([expect.objectContaining({ read: true })]);
});
it('marks byte-identical legacy member passive idle duplicates read together', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
const duplicate = {
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-02-23T15:11:31.000Z',
read: false,
};
seedMemberInbox(teamName, 'alice', [duplicate, { ...duplicate }]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayMemberInboxMessages(teamName, 'alice');
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
const inbox = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
) as Array<{ read?: boolean }>;
expect(inbox).toEqual([
expect.objectContaining({ read: true }),
expect.objectContaining({ read: true }),
]);
});
it('retries passive member idle on next cycle when exact mark-read fails', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedMemberInbox(teamName, 'alice', [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-02-23T15:11:45.000Z',
read: false,
messageId: 'idle-member-summary-fail-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
hoisted.setAtomicWriteShouldFail(true);
const first = await service.relayMemberInboxMessages(teamName, 'alice');
hoisted.setAtomicWriteShouldFail(false);
const second = await service.relayMemberInboxMessages(teamName, 'alice');
expect(first).toBe(0);
expect(second).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
const inbox = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
) as Array<{ messageId?: string; read?: boolean }>;
expect(inbox).toEqual([
expect.objectContaining({
messageId: 'idle-member-summary-fail-1',
read: true,
}),
]);
});
it('does not rewrite the inbox file when exact mark-read is a no-op on an already-read legacy row', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const legacyRow = {
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-02-23T15:11:46.000Z',
read: true,
};
seedMemberInbox(teamName, 'alice', [legacyRow]);
await (service as any).markInboxMessagesRead(teamName, 'alice', [
{
messageId: buildLegacyInboxMessageId(
legacyRow.from,
legacyRow.timestamp,
legacyRow.text
),
},
]);
expect(hoisted.atomicWrite).not.toHaveBeenCalled();
});
it('marks persisted duplicate messageId passive rows read together', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedMemberInbox(teamName, 'alice', [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-02-23T15:11:47.000Z',
read: false,
messageId: 'dup-passive-id-1',
},
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-02-23T15:11:48.000Z',
read: false,
messageId: 'dup-passive-id-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayMemberInboxMessages(teamName, 'alice');
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
const inbox = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
) as Array<{ messageId?: string; read?: boolean }>;
expect(inbox).toEqual([
expect.objectContaining({ messageId: 'dup-passive-id-1', read: true }),
expect.objectContaining({ messageId: 'dup-passive-id-1', read: true }),
]);
});
it('relays actionable member idle notifications such as failures', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedMemberInbox(teamName, 'alice', [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'failed',
completedStatus: 'failed',
failureReason: 'teammate crashed',
}),
timestamp: '2026-02-23T15:12:00.000Z',
read: false,
messageId: 'idle-member-failure-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('idle_notification');
expect(payload).toContain('teammate crashed');
});
it('lead inbox relay prompt mentions task_create_from_message for user messages with messageId', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
@ -937,4 +1210,202 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(payload).toContain('task_create_from_message');
expect(payload).toContain('MessageId');
});
it('marks pure lead heartbeat idle as read without relaying it', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
}),
timestamp: '2026-02-23T16:10:00.000Z',
read: false,
messageId: 'idle-lead-heartbeat-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayLeadInboxMessages(teamName);
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
const inbox = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]'
) as Array<{ messageId?: string; read?: boolean }>;
expect(inbox).toEqual([
expect.objectContaining({
messageId: 'idle-lead-heartbeat-1',
read: true,
}),
]);
});
it('marks lead heartbeat with peer summary read across repeated scans and does not relay it', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-02-23T16:11:00.000Z',
read: false,
messageId: 'idle-lead-summary-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const first = await service.relayLeadInboxMessages(teamName);
const second = await service.relayLeadInboxMessages(teamName);
expect(first).toBe(0);
expect(second).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
const inbox = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]'
) as Array<{ messageId?: string; read?: boolean }>;
expect(inbox).toEqual([
expect.objectContaining({
messageId: 'idle-lead-summary-1',
read: true,
}),
]);
});
it('does not clear pending cross-team reply expectations for passive lead idle', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
service.registerPendingCrossTeamReplyExpectation(teamName, 'other-team', 'conv-passive');
seedLeadInbox(teamName, [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-02-23T16:11:30.000Z',
read: false,
messageId: 'idle-lead-summary-2',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayLeadInboxMessages(teamName);
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
const pendingKeys = (service as any).getPendingCrossTeamReplyExpectationKeys(teamName);
expect(Array.from(pendingKeys)).toContain('other-team\0conv-passive');
});
it('does not feed passive lead idle into same-team native matching', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-02-23T16:11:45.000Z',
read: false,
messageId: 'idle-lead-summary-native-match-1',
},
]);
const nativeMatchSpy = vi
.spyOn(service as any, 'confirmSameTeamNativeMatches')
.mockResolvedValue({ nativeMatchedMessageIds: new Set<string>(), persisted: true });
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayLeadInboxMessages(teamName);
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
expect(nativeMatchSpy).toHaveBeenCalledWith(teamName, 'team-lead', []);
});
it('does not let cross-team idle-shaped payloads inherit passive idle handling', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'other-team.alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-02-23T16:11:50.000Z',
read: false,
messageId: 'cross-team-idle-shaped-1',
source: 'cross_team',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayPromise = service.relayLeadInboxMessages(teamName);
const run = await waitForCapture(service);
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
content: [{ type: 'text', text: 'Seen.' }],
});
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
const relayed = await relayPromise;
expect(relayed).toBe(1);
expect(writeSpy).toHaveBeenCalledTimes(1);
});
it('relays actionable lead idle notifications such as task-terminal updates', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
completedTaskId: 'task-1',
completedStatus: 'blocked',
}),
timestamp: '2026-02-23T16:12:00.000Z',
read: false,
messageId: 'idle-lead-terminal-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayPromise = service.relayLeadInboxMessages(teamName);
const run = await waitForCapture(service);
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
content: [{ type: 'text', text: 'Investigating blocker.' }],
});
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
const relayed = await relayPromise;
expect(relayed).toBe(1);
expect(writeSpy).toHaveBeenCalledTimes(1);
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('idle_notification');
expect(payload).toContain('blocked');
});
});

View file

@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { classifyIdleNotificationForMainProcess } from '../../../../src/main/services/team/idleNotificationMainProcessSemantics';
describe('idleNotificationMainProcessSemantics', () => {
it('classifies heartbeat, passive peer summary, interrupted, and failure consistently', () => {
expect(
classifyIdleNotificationForMainProcess('{"type":"idle_notification","idleReason":"available"}')
).toMatchObject({
primaryKind: 'heartbeat',
hasPeerSummary: false,
handling: 'silent_noise',
});
expect(
classifyIdleNotificationForMainProcess(
JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
})
)
).toMatchObject({
primaryKind: 'heartbeat',
hasPeerSummary: true,
peerSummary: '[to bob] aligned on rollout order',
handling: 'passive_activity',
});
expect(
classifyIdleNotificationForMainProcess(
JSON.stringify({
type: 'idle_notification',
idleReason: 'interrupted',
summary: '[to bob] waiting for clarification',
})
)
).toMatchObject({
primaryKind: 'interrupted',
hasPeerSummary: true,
handling: 'visible_actionable',
});
expect(
classifyIdleNotificationForMainProcess(
JSON.stringify({
type: 'idle_notification',
idleReason: 'failed',
completedStatus: 'failed',
failureReason: 'teammate crashed',
})
)
).toMatchObject({
primaryKind: 'failure',
hasPeerSummary: false,
handling: 'visible_actionable',
});
});
it('treats whitespace summary as heartbeat noise and task-terminal states as actionable', () => {
expect(
classifyIdleNotificationForMainProcess(
JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: ' ',
})
)
).toMatchObject({
primaryKind: 'heartbeat',
hasPeerSummary: false,
handling: 'silent_noise',
});
expect(
classifyIdleNotificationForMainProcess(
JSON.stringify({
type: 'idle_notification',
completedTaskId: 'task-1',
completedStatus: 'resolved',
})
)
).toMatchObject({
primaryKind: 'task_terminal',
handling: 'visible_actionable',
});
expect(
classifyIdleNotificationForMainProcess(
JSON.stringify({
type: 'idle_notification',
completedStatus: 'blocked',
})
)
).toMatchObject({
primaryKind: 'task_terminal',
handling: 'visible_actionable',
});
});
it('returns null for malformed or non-idle payloads', () => {
expect(classifyIdleNotificationForMainProcess('{')).toBeNull();
expect(
classifyIdleNotificationForMainProcess('{"type":"shutdown_request","reason":"done"}')
).toBeNull();
});
});

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
buildLegacyInboxMessageId,
getEffectiveInboxMessageId,
} from '../../../../src/main/services/team/inboxMessageIdentity';
describe('inboxMessageIdentity', () => {
it('preserves explicit persisted messageId verbatim when present', () => {
expect(
getEffectiveInboxMessageId({
messageId: ' explicit-id ',
from: 'alice',
timestamp: '2026-04-08T12:00:00.000Z',
text: 'hello',
})
).toBe(' explicit-id ');
});
it('builds legacy fallback identity from raw persisted fields only', () => {
const row = {
from: 'alice',
timestamp: '2026-04-08T12:00:00.000Z',
text: 'line 1\nline 2 ',
summary: 'ignored',
read: false,
source: 'system_notification',
};
expect(getEffectiveInboxMessageId(row)).toBe(
buildLegacyInboxMessageId(row.from, row.timestamp, row.text)
);
});
it('preserves embedded newlines and whitespace in fallback-id inputs', () => {
const base = {
from: 'alice',
timestamp: '2026-04-08T12:00:00.000Z',
text: 'line 1\nline 2',
};
const padded = {
...base,
text: 'line 1\nline 2 ',
};
expect(getEffectiveInboxMessageId(base)).not.toBe(getEffectiveInboxMessageId(padded));
});
it('returns null when persisted messageId is absent and raw identity inputs are incomplete', () => {
expect(
getEffectiveInboxMessageId({
from: 'alice',
text: 'missing timestamp',
})
).toBeNull();
});
});

View file

@ -40,6 +40,7 @@ import {
getCrossTeamSentMemberName,
getCrossTeamSentTarget,
getSystemMessageLabel,
isNoiseMessage,
isQualifiedExternalRecipient,
} from '@renderer/components/team/activity/ActivityItem';
import type { InboxMessage } from '@shared/types';
@ -153,4 +154,51 @@ describe('ActivityItem legacy system message fallback', () => {
expect(getCrossTeamSentMemberName('team-best.user')).toBe('user');
expect(getCrossTeamSentMemberName('cross-team:team-best')).toBeNull();
});
it('keeps heartbeat peer summaries out of compact idle noise rendering', () => {
expect(isNoiseMessage('{"type":"idle_notification","idleReason":"available"}')).toBe(true);
expect(
isNoiseMessage(
JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
})
)
).toBe(false);
});
it('renders peer-summary idle rows with semantic summary text instead of generic idle noise', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const message: InboxMessage = {
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
from: 'alice',
timestamp: '2026-04-08T12:01:00.000Z',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: new Date('2026-04-08T12:01:00.000Z').toISOString(),
read: true,
source: 'inbox',
};
await act(async () => {
root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' }));
await Promise.resolve();
});
expect(host.textContent).toContain('[to bob] aligned on rollout order');
expect(host.textContent).not.toContain('Idle (available)');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,237 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InboxMessage } from '@shared/types';
const storeState = {
sendTeamMessage: vi.fn().mockResolvedValue(undefined),
sendCrossTeamMessage: vi.fn().mockResolvedValue(undefined),
sendingMessage: false,
sendMessageError: null,
lastSendMessageResult: null,
teams: [],
openTeamTab: vi.fn(),
};
const readHookState = {
readSet: new Set<string>(),
markRead: vi.fn(),
markAllRead: vi.fn(),
};
const expandedHookState = {
expandedSet: new Set<string>(),
toggle: vi.fn(),
};
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/hooks/useStableTeamMentionMeta', () => ({
useStableTeamMentionMeta: () => ({
teamNames: [],
teamColorByName: new Map<string, string>(),
}),
}));
vi.mock('@renderer/hooks/useTeamMessagesRead', () => ({
useTeamMessagesRead: () => readHookState,
}));
vi.mock('@renderer/hooks/useTeamMessagesExpanded', () => ({
useTeamMessagesExpanded: () => expandedHookState,
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: { children: React.ReactNode }) =>
React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
}) => React.createElement('button', { type: 'button', onClick }, children),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/team/messages/MessageComposer', () => ({
MessageComposer: () => React.createElement('div', null, 'composer'),
}));
vi.mock('@renderer/components/team/messages/MessagesFilterPopover', () => ({
MessagesFilterPopover: () => React.createElement('div', null, 'filter-popover'),
}));
vi.mock('@renderer/components/team/messages/StatusBlock', () => ({
StatusBlock: () => React.createElement('div', null, 'status-block'),
}));
vi.mock('@renderer/components/team/activity/ActivityTimeline', () => ({
ActivityTimeline: ({ messages }: { messages: InboxMessage[] }) =>
React.createElement(
'div',
{ 'data-testid': 'activity-timeline' },
messages.map((message) =>
React.createElement(
'div',
{
key: message.messageId ?? `${message.from}-${message.timestamp}`,
'data-message-id': message.messageId ?? '',
},
`${message.messageId ?? 'no-id'}:${message.text}`
)
)
),
}));
vi.mock('@renderer/components/team/activity/MessageExpandDialog', () => ({
MessageExpandDialog: () => null,
}));
import { MessagesPanel } from '@renderer/components/team/messages/MessagesPanel';
function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
return {
from: 'alice',
text: 'Hello',
timestamp: '2026-04-08T12:00:00.000Z',
read: true,
source: 'inbox',
messageId: 'msg-1',
...overrides,
};
}
describe('MessagesPanel idle summary invariants', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
beforeEach(() => {
readHookState.readSet = new Set<string>();
readHookState.markRead.mockReset();
readHookState.markAllRead.mockReset();
expandedHookState.expandedSet = new Set<string>();
expandedHookState.toggle.mockReset();
storeState.sendTeamMessage.mockClear();
storeState.sendCrossTeamMessage.mockClear();
storeState.openTeamTab.mockClear();
});
it('keeps read passive peer summaries in the activity timeline while unread badge only counts filtered unread messages', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const messages: InboxMessage[] = [
makeMessage({
messageId: 'passive-idle',
from: 'alice',
read: true,
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
}),
makeMessage({
messageId: 'human-reply',
from: 'bob',
read: false,
text: 'Need one more input from you',
timestamp: '2026-04-08T12:02:00.000Z',
}),
];
await act(async () => {
root.render(
React.createElement(MessagesPanel, {
teamName: 'atlas-hq',
position: 'sidebar',
onTogglePosition: vi.fn(),
members: [],
tasks: [],
messages,
timeWindow: null,
teamSessionIds: new Set<string>(),
pendingRepliesByMember: {},
onPendingReplyChange: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('passive-idle');
expect(host.textContent).toContain('human-reply');
expect(host.textContent).toContain('1 new');
expect(host.textContent).not.toContain('2 new');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not clear pending replies when only a passive idle summary arrives', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onPendingReplyChange = vi.fn();
const pendingSentAtMs = Date.parse('2026-04-08T12:00:00.000Z');
const messages: InboxMessage[] = [
makeMessage({
messageId: 'passive-idle',
from: 'alice',
read: true,
timestamp: '2026-04-08T12:01:00.000Z',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
}),
];
await act(async () => {
root.render(
React.createElement(MessagesPanel, {
teamName: 'atlas-hq',
position: 'sidebar',
onTogglePosition: vi.fn(),
members: [],
tasks: [],
messages,
timeWindow: null,
teamSessionIds: new Set<string>(),
pendingRepliesByMember: { alice: pendingSentAtMs },
onPendingReplyChange,
})
);
await Promise.resolve();
});
expect(onPendingReplyChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -210,6 +210,36 @@ describe('TeamGraphAdapter particles', () => {
expect(graph.particles.every((particle) => particle.kind === 'inbox_message')).toBe(true);
});
it('uses peer-summary text for idle particles instead of generic idle', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData(), 'my-team');
const next = createBaseTeamData({
messages: [
{
from: 'alice',
to: 'team-lead',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-04-08T19:00:01.000Z',
read: true,
messageId: 'idle-summary-1',
},
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(1);
expect(graph.particles[0]).toMatchObject({
kind: 'inbox_message',
label: '[to bob] aligned on rollout order',
});
});
it('creates particles for each newly appended task comment, not only the latest one', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({

View file

@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import {
classifyIdleNotification,
getIdleNoiseLabel,
shouldKeepIdleMessageInActivityWhenNoiseHidden,
} from '@renderer/utils/idleNotificationSemantics';
describe('idleNotificationSemantics', () => {
it('classifies heartbeat, passive peer summary, interrupted, and failure consistently', () => {
expect(
classifyIdleNotification('{"type":"idle_notification","idleReason":"available"}')
).toMatchObject({
primaryKind: 'heartbeat',
hasPeerSummary: false,
liveDelivery: 'silent_finalize',
uiPresentation: 'heartbeat',
countsAsBootstrapConfirmation: true,
});
expect(
classifyIdleNotification(
JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
})
)
).toMatchObject({
primaryKind: 'heartbeat',
hasPeerSummary: true,
peerSummary: '[to bob] aligned on rollout order',
liveDelivery: 'passive_activity',
uiPresentation: 'peer_summary',
countsAsBootstrapConfirmation: true,
});
expect(
classifyIdleNotification(
JSON.stringify({
type: 'idle_notification',
idleReason: 'interrupted',
summary: '[to bob] waiting for clarification',
})
)
).toMatchObject({
primaryKind: 'interrupted',
hasPeerSummary: true,
liveDelivery: 'visible_actionable',
uiPresentation: 'interrupted',
});
expect(
classifyIdleNotification(
JSON.stringify({
type: 'idle_notification',
idleReason: 'failed',
completedStatus: 'failed',
failureReason: 'teammate crashed',
})
)
).toMatchObject({
primaryKind: 'failure',
hasPeerSummary: false,
liveDelivery: 'visible_actionable',
uiPresentation: 'failure',
countsAsBootstrapConfirmation: false,
});
});
it('keeps only payload-backed peer summaries in the hidden-noise activity sink', () => {
expect(
shouldKeepIdleMessageInActivityWhenNoiseHidden(
'{"type":"idle_notification","idleReason":"available"}'
)
).toBe(false);
expect(
shouldKeepIdleMessageInActivityWhenNoiseHidden(
JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: ' ',
})
)
).toBe(false);
expect(
shouldKeepIdleMessageInActivityWhenNoiseHidden(
JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
})
)
).toBe(true);
});
it('keeps pure heartbeat as compact noise but not heartbeat with payload summary', () => {
expect(getIdleNoiseLabel('{"type":"idle_notification","idleReason":"available"}')).toBe(
'Idle (available)'
);
expect(
getIdleNoiseLabel(
JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
})
)
).toBeNull();
});
});

View file

@ -106,6 +106,38 @@ describe('filterTeamMessages', () => {
expect(result[0].messageId).toBe('msg-2');
});
it('can preserve passive peer-summary idle rows in the activity sink while keeping pure heartbeat hidden even after read', () => {
const messages = [
makeMessage({
messageId: 'heartbeat-hidden',
text: '{"type":"idle_notification","idleReason":"available"}',
}),
makeMessage({
messageId: 'peer-summary-visible',
read: true,
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
}),
makeMessage({
messageId: 'row-summary-only-hidden',
summary: 'Preview only',
text: '{"type":"idle_notification","idleReason":"available"}',
}),
];
const result = filterTeamMessages(messages, {
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: false },
searchQuery: '',
});
expect(result.map((message) => message.messageId)).toEqual(['peer-summary-visible']);
});
it('hides task comment notifications by semantic kind instead of text matching', () => {
const messages = [
makeMessage({

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
classifyIdleNotificationText,
getIdleGraphLabel,
shouldExcludeInboxTextFromReplyCandidates,
shouldSuppressDesktopNotificationForInboxText,
} from '../../../src/shared/utils/idleNotificationSemantics';
describe('idleNotificationSemantics', () => {
it('classifies passive peer summaries as heartbeat with peer summary', () => {
const classified = classifyIdleNotificationText(
JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
})
);
expect(classified).toMatchObject({
primaryKind: 'heartbeat',
hasPeerSummary: true,
peerSummary: '[to bob] aligned on rollout order',
countsAsBootstrapConfirmation: true,
});
});
it('suppresses desktop notifications for idle payloads but not normal text', () => {
expect(
shouldSuppressDesktopNotificationForInboxText(
'{"type":"idle_notification","idleReason":"available"}'
)
).toBe(true);
expect(
shouldSuppressDesktopNotificationForInboxText('Need one more input from you')
).toBe(false);
});
it('excludes passive idle summaries from reply candidates', () => {
expect(
shouldExcludeInboxTextFromReplyCandidates(
JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
})
)
).toBe(true);
expect(shouldExcludeInboxTextFromReplyCandidates('Human reply')).toBe(false);
});
it('builds graph labels from semantic idle summaries instead of generic idle', () => {
expect(
getIdleGraphLabel(
JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
})
)
).toBe('[to bob] aligned on rollout order');
expect(getIdleGraphLabel('{"type":"idle_notification","idleReason":"available"}')).toBe(
'idle'
);
});
});