diff --git a/src/main/index.ts b/src/main/index.ts index cd69b5d5..b4a0146a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { // 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); diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 563a634c..90d28679 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -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, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index ac792b0f..da7051e1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 ..., 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(); const latestReadInboundByConversation = new Map(); 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; - const msgId = typeof row.messageId === 'string' ? row.messageId : null; + const msgId = getEffectiveInboxMessageId(row); if (!msgId || !ids.has(msgId)) continue; if (row.read !== true) { diff --git a/src/main/services/team/idleNotificationMainProcessSemantics.ts b/src/main/services/team/idleNotificationMainProcessSemantics.ts new file mode 100644 index 00000000..36f336e0 --- /dev/null +++ b/src/main/services/team/idleNotificationMainProcessSemantics.ts @@ -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, + }; +} diff --git a/src/main/services/team/inboxMessageIdentity.ts b/src/main/services/team/inboxMessageIdentity.ts new file mode 100644 index 00000000..8e699d86 --- /dev/null +++ b/src/main/services/team/inboxMessageIdentity.ts @@ -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); +} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e679cc04..b147ce49 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 89570814..4636c18d 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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, ]); diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 073f21f0..a76c6d92 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -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(() => { 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} /> {' '} | Record | 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 | Record | IdleNotificationPayload +): boolean { + const classified = classifyIdleNotification(value); + return classified?.liveDelivery === 'passive_activity'; +} + +export function getIdleNoiseLabel( + value: string | Pick | Record | 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'; +} diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index 4b7b5327..abd69aef 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -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; diff --git a/src/shared/utils/idleNotificationSemantics.ts b/src/shared/utils/idleNotificationSemantics.ts new file mode 100644 index 00000000..227c535d --- /dev/null +++ b/src/shared/utils/idleNotificationSemantics.ts @@ -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'; +} diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index d6562433..80cbf95e 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -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([ diff --git a/test/main/services/team/TeamInboxReader.test.ts b/test/main/services/team/TeamInboxReader.test.ts index f2bf7431..5f59af5c 100644 --- a/test/main/services/team/TeamInboxReader.test.ts +++ b/test/main/services/team/TeamInboxReader.test.ts @@ -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'); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 1e98d18a..b02be173 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -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(), 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'); + }); }); diff --git a/test/main/services/team/idleNotificationMainProcessSemantics.test.ts b/test/main/services/team/idleNotificationMainProcessSemantics.test.ts new file mode 100644 index 00000000..f7e8fdf3 --- /dev/null +++ b/test/main/services/team/idleNotificationMainProcessSemantics.test.ts @@ -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(); + }); +}); diff --git a/test/main/services/team/inboxMessageIdentity.test.ts b/test/main/services/team/inboxMessageIdentity.test.ts new file mode 100644 index 00000000..a85aa301 --- /dev/null +++ b/test/main/services/team/inboxMessageIdentity.test.ts @@ -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(); + }); +}); diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index 7c96dc1a..e19f909e 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -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(); + }); + }); }); diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts new file mode 100644 index 00000000..c0a29446 --- /dev/null +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -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(), + markRead: vi.fn(), + markAllRead: vi.fn(), +}; + +const expandedHookState = { + expandedSet: new Set(), + 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(), + }), +})); + +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; + }) => 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 { + 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(); + readHookState.markRead.mockReset(); + readHookState.markAllRead.mockReset(); + expandedHookState.expandedSet = new Set(); + 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(), + 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(), + pendingRepliesByMember: { alice: pendingSentAtMs }, + onPendingReplyChange, + }) + ); + await Promise.resolve(); + }); + + expect(onPendingReplyChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index ae6c0507..389b7d5c 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -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({ diff --git a/test/renderer/utils/idleNotificationSemantics.test.ts b/test/renderer/utils/idleNotificationSemantics.test.ts new file mode 100644 index 00000000..a56f1ff3 --- /dev/null +++ b/test/renderer/utils/idleNotificationSemantics.test.ts @@ -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(); + }); +}); diff --git a/test/renderer/utils/teamMessageFiltering.test.ts b/test/renderer/utils/teamMessageFiltering.test.ts index d2ee9230..c8e7ac65 100644 --- a/test/renderer/utils/teamMessageFiltering.test.ts +++ b/test/renderer/utils/teamMessageFiltering.test.ts @@ -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({ diff --git a/test/shared/utils/idleNotificationSemantics.test.ts b/test/shared/utils/idleNotificationSemantics.test.ts new file mode 100644 index 00000000..f704a6c3 --- /dev/null +++ b/test/shared/utils/idleNotificationSemantics.test.ts @@ -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' + ); + }); +});