fix(team): align idle inbox semantics across app
This commit is contained in:
parent
2c9926c734
commit
78d4c2826b
23 changed files with 1646 additions and 57 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
26
src/main/services/team/inboxMessageIdentity.ts
Normal file
26
src/main/services/team/inboxMessageIdentity.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
76
src/renderer/utils/idleNotificationSemantics.ts
Normal file
76
src/renderer/utils/idleNotificationSemantics.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
86
src/shared/utils/idleNotificationSemantics.ts
Normal file
86
src/shared/utils/idleNotificationSemantics.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
57
test/main/services/team/inboxMessageIdentity.test.ts
Normal file
57
test/main/services/team/inboxMessageIdentity.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
237
test/renderer/components/team/messages/MessagesPanel.test.ts
Normal file
237
test/renderer/components/team/messages/MessagesPanel.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
113
test/renderer/utils/idleNotificationSemantics.test.ts
Normal file
113
test/renderer/utils/idleNotificationSemantics.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
66
test/shared/utils/idleNotificationSemantics.test.ts
Normal file
66
test/shared/utils/idleNotificationSemantics.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue