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,
|
getTrafficLightPositionForZoom,
|
||||||
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
|
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
|
||||||
} from '@shared/constants';
|
} 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 { createLogger } from '@shared/utils/logger';
|
||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
|
|
@ -274,7 +275,7 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
||||||
// Skip messages sent from our own UI
|
// Skip messages sent from our own UI
|
||||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||||
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
||||||
if (isInboxNoiseMessage(msg.text)) continue;
|
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
|
||||||
|
|
||||||
const fromLabel = msg.from || 'Unknown';
|
const fromLabel = msg.from || 'Unknown';
|
||||||
const extracted = extractNotificationContent(msg.text);
|
const extracted = extractNotificationContent(msg.text);
|
||||||
|
|
@ -345,7 +346,7 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
||||||
// Skip messages sent from our own UI
|
// Skip messages sent from our own UI
|
||||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||||
// Skip internal coordination noise
|
// Skip internal coordination noise
|
||||||
if (isInboxNoiseMessage(msg.text)) continue;
|
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
|
||||||
|
|
||||||
const fromLabel = msg.from || 'team-lead';
|
const fromLabel = msg.from || 'team-lead';
|
||||||
const extracted = extractNotificationContent(msg.text);
|
const extracted = extractNotificationContent(msg.text);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createHash } from 'crypto';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
||||||
|
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
const MAX_INBOX_FILE_BYTES = 10 * 1024 * 1024; // 10MB — skip corrupt/oversized inbox files
|
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.
|
// 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
|
// We produce a deterministic hash so the same message always gets the same ID
|
||||||
// across reads — important for React keys, dedup, and message tracking.
|
// across reads — important for React keys, dedup, and message tracking.
|
||||||
const messageId =
|
const messageId = getEffectiveInboxMessageId(row);
|
||||||
typeof row.messageId === 'string' && row.messageId.trim().length > 0
|
if (!messageId) {
|
||||||
? row.messageId
|
continue;
|
||||||
: `inbox-${createHash('sha256').update(`${row.from}\n${row.timestamp}\n${row.text}`).digest('hex').slice(0, 16)}`;
|
}
|
||||||
messages.push({
|
messages.push({
|
||||||
from: row.from,
|
from: row.from,
|
||||||
to: typeof row.to === 'string' ? row.to : undefined,
|
to: typeof row.to === 'string' ? row.to : undefined,
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ import { buildActionModeProtocol } from './actionModeInstructions';
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||||
import { withFileLock } from './fileLock';
|
import { withFileLock } from './fileLock';
|
||||||
|
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
||||||
import { withInboxLock } from './inboxLock';
|
import { withInboxLock } from './inboxLock';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
|
|
@ -96,6 +97,10 @@ import {
|
||||||
resolveGeminiRuntimeAuth,
|
resolveGeminiRuntimeAuth,
|
||||||
type GeminiRuntimeAuthState,
|
type GeminiRuntimeAuthState,
|
||||||
} from '../runtime/geminiRuntimeAuth';
|
} from '../runtime/geminiRuntimeAuth';
|
||||||
|
import {
|
||||||
|
classifyIdleNotificationForMainProcess,
|
||||||
|
type ClassifiedMainProcessIdle,
|
||||||
|
} from './idleNotificationMainProcessSemantics';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a team CLI process using SIGKILL (uncatchable).
|
* Kill a team CLI process using SIGKILL (uncatchable).
|
||||||
|
|
@ -110,6 +115,18 @@ function killTeamProcess(child: ChildProcess | null | undefined): void {
|
||||||
killProcessTree(child, 'SIGKILL');
|
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 {
|
interface PersistedRuntimeMemberLike {
|
||||||
name?: string;
|
name?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
|
@ -117,6 +134,14 @@ interface PersistedRuntimeMemberLike {
|
||||||
backendType?: string;
|
backendType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RelayInboxMessage = InboxMessage & { messageId: string };
|
||||||
|
|
||||||
|
type RelayInboxMessageView = {
|
||||||
|
message: RelayInboxMessage;
|
||||||
|
idle: ClassifiedMainProcessIdle | null;
|
||||||
|
isCoarseNoise: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ActiveToolCall,
|
ActiveToolCall,
|
||||||
CrossTeamSendResult,
|
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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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}".
|
- 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;
|
if (unread.length === 0) return 0;
|
||||||
|
|
||||||
const noiseUnread = unread.filter((m) => isInboxNoiseMessage(m.text));
|
const relayView = buildRelayInboxView(unread);
|
||||||
if (noiseUnread.length > 0) {
|
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 {
|
try {
|
||||||
await this.markInboxMessagesRead(teamName, memberName, noiseUnread);
|
await this.markInboxMessagesRead(teamName, memberName, readOnlyIgnoredUnread);
|
||||||
} catch {
|
if (passiveIdleUnread.length > 0) {
|
||||||
// best-effort
|
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;
|
if (actionableUnread.length === 0) return 0;
|
||||||
|
|
||||||
const MAX_RELAY = 10;
|
const MAX_RELAY = 10;
|
||||||
|
|
@ -5584,6 +5636,23 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
if (unread.length === 0) return 0;
|
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 latestOutboundByConversation = new Map<string, number>();
|
||||||
const latestReadInboundByConversation = new Map<string, number>();
|
const latestReadInboundByConversation = new Map<string, number>();
|
||||||
for (const message of leadInboxMessages) {
|
for (const message of leadInboxMessages) {
|
||||||
|
|
@ -5651,7 +5720,8 @@ export class TeamProvisioningService {
|
||||||
// Includes noise (idle/shutdown), cross-team sender copies, cross-team reply dedup.
|
// Includes noise (idle/shutdown), cross-team sender copies, cross-team reply dedup.
|
||||||
const permanentlyIgnored = unread.filter(
|
const permanentlyIgnored = unread.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
isInboxNoiseMessage(m.text) ||
|
silentIdleIds.has(m.messageId) ||
|
||||||
|
coarseNonIdleNoiseIds.has(m.messageId) ||
|
||||||
m.source === CROSS_TEAM_SENT_SOURCE ||
|
m.source === CROSS_TEAM_SENT_SOURCE ||
|
||||||
isCrossTeamReplyToOwnOutbound(m) ||
|
isCrossTeamReplyToOwnOutbound(m) ||
|
||||||
wasRecentlyDeliveredCrossTeam(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).
|
// Category 2: same-team native delivery confirmation (one-to-one pairing).
|
||||||
const { nativeMatchedMessageIds, persisted: sameTeamPersisted } =
|
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.
|
// 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).
|
// NOT marked read (crash safety: if native delivery fails, retry will relay).
|
||||||
const runStartedAtMs = Date.parse(run.startedAt);
|
const runStartedAtMs = Date.parse(run.startedAt);
|
||||||
const permanentlyIgnoredIds = new Set(permanentlyIgnored.map((m) => m.messageId));
|
const deferredByAge = remainingUnread.filter(
|
||||||
const deferredByAge = unread.filter(
|
|
||||||
(m) =>
|
(m) =>
|
||||||
!permanentlyIgnoredIds.has(m.messageId) &&
|
|
||||||
!nativeMatchedMessageIds.has(m.messageId) &&
|
!nativeMatchedMessageIds.has(m.messageId) &&
|
||||||
this.shouldDeferSameTeamMessage(m, leadName, runStartedAtMs)
|
this.shouldDeferSameTeamMessage(m, leadName, runStartedAtMs)
|
||||||
);
|
);
|
||||||
|
|
@ -5690,24 +5780,17 @@ export class TeamProvisioningService {
|
||||||
// NOT relayed to the lead. The actual interception + ToolApprovalRequest emission
|
// NOT relayed to the lead. The actual interception + ToolApprovalRequest emission
|
||||||
// is handled by the early scan above (which checks processedPermissionRequestIds).
|
// is handled by the early scan above (which checks processedPermissionRequestIds).
|
||||||
const permissionRequestIds = new Set(
|
const permissionRequestIds = new Set(
|
||||||
unread
|
remainingUnread
|
||||||
.filter(
|
.filter((m) => !deferredIds.has(m.messageId) && parsePermissionRequest(m.text) !== null)
|
||||||
(m) =>
|
|
||||||
!permanentlyIgnoredIds.has(m.messageId) &&
|
|
||||||
!deferredIds.has(m.messageId) &&
|
|
||||||
parsePermissionRequest(m.text) !== null
|
|
||||||
)
|
|
||||||
.map((m) => m.messageId)
|
.map((m) => m.messageId)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Actionable: everything not in any category.
|
// Actionable: everything not in any category.
|
||||||
const actionableUnread = unread.filter(
|
const actionableUnread = remainingUnread.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
!permanentlyIgnoredIds.has(m.messageId) &&
|
|
||||||
!nativeMatchedMessageIds.has(m.messageId) &&
|
!nativeMatchedMessageIds.has(m.messageId) &&
|
||||||
!deferredIds.has(m.messageId) &&
|
!deferredIds.has(m.messageId) &&
|
||||||
!permissionRequestIds.has(m.messageId) &&
|
!permissionRequestIds.has(m.messageId)
|
||||||
!isInboxNoiseMessage(m.text)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Layer 3: schedule retry timers.
|
// Layer 3: schedule retry timers.
|
||||||
|
|
@ -6038,7 +6121,7 @@ export class TeamProvisioningService {
|
||||||
for (const item of parsed) {
|
for (const item of parsed) {
|
||||||
if (!item || typeof item !== 'object') continue;
|
if (!item || typeof item !== 'object') continue;
|
||||||
const row = item as Record<string, unknown>;
|
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 (!msgId || !ids.has(msgId)) continue;
|
||||||
|
|
||||||
if (row.read !== true) {
|
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);
|
return () => window.clearInterval(id);
|
||||||
}, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ import {
|
||||||
getSanitizedInboxMessageText,
|
getSanitizedInboxMessageText,
|
||||||
} from '@renderer/utils/bootstrapPromptSanitizer';
|
} from '@renderer/utils/bootstrapPromptSanitizer';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import {
|
||||||
|
classifyIdleNotification,
|
||||||
|
getIdleNoiseLabel,
|
||||||
|
} from '@renderer/utils/idleNotificationSemantics';
|
||||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||||
import {
|
import {
|
||||||
areInboxMessagesEquivalentForRender,
|
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.). */
|
/** Check if a message renders as a compact noise row (idle, shutdown, etc.). */
|
||||||
export function isNoiseMessage(text: string): boolean {
|
export function isNoiseMessage(text: string): boolean {
|
||||||
const parsed = parseStructuredAgentMessage(text);
|
return (
|
||||||
return parsed !== null && getNoiseLabel(parsed) !== null;
|
getIdleNoiseLabel(text) !== null ||
|
||||||
|
((): boolean => {
|
||||||
|
const parsed = parseStructuredAgentMessage(text);
|
||||||
|
return parsed !== null && getNoiseLabel(parsed) !== null;
|
||||||
|
})()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNoiseLabel(parsed: StructuredMessage): string | null {
|
function getNoiseLabel(parsed: StructuredMessage): string | null {
|
||||||
const type = getStringField(parsed, 'type');
|
const type = getStringField(parsed, 'type');
|
||||||
|
|
||||||
if (type === 'idle_notification') {
|
if (type === 'idle_notification') {
|
||||||
const reason = getStringField(parsed, 'idleReason');
|
return getIdleNoiseLabel(parsed);
|
||||||
return reason ? `Idle (${reason})` : 'Idle';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'shutdown_response') {
|
if (type === 'shutdown_response') {
|
||||||
|
|
@ -545,6 +553,7 @@ export const ActivityItem = memo(
|
||||||
const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text));
|
const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text));
|
||||||
// Never collapse rate limit messages as noise — they must be visible
|
// Never collapse rate limit messages as noise — they must be visible
|
||||||
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
|
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
|
||||||
|
const idleSemantic = useMemo(() => classifyIdleNotification(message), [message]);
|
||||||
|
|
||||||
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
||||||
const isManaged = collapseMode === 'managed';
|
const isManaged = collapseMode === 'managed';
|
||||||
|
|
@ -656,6 +665,9 @@ export const ActivityItem = memo(
|
||||||
}, [isCrossTeamAny, strippedText]);
|
}, [isCrossTeamAny, strippedText]);
|
||||||
|
|
||||||
const rawSummary = useMemo(() => {
|
const rawSummary = useMemo(() => {
|
||||||
|
if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) {
|
||||||
|
return idleSemantic.peerSummary;
|
||||||
|
}
|
||||||
if (isSlashCommandResult && message.commandOutput) {
|
if (isSlashCommandResult && message.commandOutput) {
|
||||||
return message.summary || getCommandOutputSummary(message.text);
|
return message.summary || getCommandOutputSummary(message.text);
|
||||||
}
|
}
|
||||||
|
|
@ -683,6 +695,7 @@ export const ActivityItem = memo(
|
||||||
isSlashCommandResult,
|
isSlashCommandResult,
|
||||||
message.commandOutput,
|
message.commandOutput,
|
||||||
message,
|
message,
|
||||||
|
idleSemantic,
|
||||||
slashCommandMeta,
|
slashCommandMeta,
|
||||||
structured,
|
structured,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||||
import {
|
import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
|
|
@ -190,12 +190,21 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
});
|
});
|
||||||
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
|
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||||
|
|
||||||
|
const activityTimelineMessages = useMemo(() => {
|
||||||
|
return filterTeamMessages(messages, {
|
||||||
|
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
||||||
|
timeWindow,
|
||||||
|
filter: messagesFilter,
|
||||||
|
searchQuery: messagesSearchQuery,
|
||||||
|
});
|
||||||
|
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||||
|
|
||||||
const replyCandidateMessages = useMemo(
|
const replyCandidateMessages = useMemo(
|
||||||
() =>
|
() =>
|
||||||
messages.filter(
|
messages.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.messageKind !== 'task_comment_notification' &&
|
m.messageKind !== 'task_comment_notification' &&
|
||||||
!isInboxNoiseMessage(typeof m.text === 'string' ? m.text : '')
|
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
|
||||||
),
|
),
|
||||||
[messages]
|
[messages]
|
||||||
);
|
);
|
||||||
|
|
@ -204,17 +213,17 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
const expandedItem = useMemo<TimelineItem | null>(() => {
|
const expandedItem = useMemo<TimelineItem | null>(() => {
|
||||||
if (!expandedItemKey) return null;
|
if (!expandedItemKey) return null;
|
||||||
if (!expandedItemKey.startsWith('thoughts-')) {
|
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;
|
return msg ? { type: 'message', message: msg } : null;
|
||||||
}
|
}
|
||||||
const allItems = groupTimelineItems(filteredMessages);
|
const allItems = groupTimelineItems(activityTimelineMessages);
|
||||||
return (
|
return (
|
||||||
allItems.find(
|
allItems.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey
|
item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey
|
||||||
) ?? null
|
) ?? null
|
||||||
);
|
);
|
||||||
}, [expandedItemKey, filteredMessages]);
|
}, [expandedItemKey, activityTimelineMessages]);
|
||||||
|
|
||||||
// Auto-clear stale expanded key
|
// Auto-clear stale expanded key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -408,7 +417,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onTaskClick={onTaskClick}
|
onTaskClick={onTaskClick}
|
||||||
/>
|
/>
|
||||||
<ActivityTimeline
|
<ActivityTimeline
|
||||||
messages={filteredMessages}
|
messages={activityTimelineMessages}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={members}
|
members={members}
|
||||||
readState={readState}
|
readState={readState}
|
||||||
|
|
@ -574,7 +583,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
/>{' '}
|
/>{' '}
|
||||||
</div>
|
</div>
|
||||||
<ActivityTimeline
|
<ActivityTimeline
|
||||||
messages={filteredMessages}
|
messages={activityTimelineMessages}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={members}
|
members={members}
|
||||||
readState={readState}
|
readState={readState}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@
|
||||||
import { getUnreadCount } from '@renderer/services/commentReadStorage';
|
import { getUnreadCount } from '@renderer/services/commentReadStorage';
|
||||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||||
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
|
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 { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -565,12 +569,10 @@ export class TeamGraphAdapter {
|
||||||
// Skip comment notifications — #buildCommentParticles handles them with real text
|
// Skip comment notifications — #buildCommentParticles handles them with real text
|
||||||
if (msg.summary?.startsWith('Comment on ')) continue;
|
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 msgText = msg.text ?? '';
|
||||||
const noiseType = getInboxJsonType(msgText);
|
const idleSemantic = classifyIdleNotificationText(msgText);
|
||||||
if (noiseType === 'idle_notification') {
|
if (!idleSemantic && isInboxNoiseMessage(msgText)) {
|
||||||
// Show idle as a simple label, don't skip
|
|
||||||
} else if (isInboxNoiseMessage(msgText)) {
|
|
||||||
continue; // skip shutdown_approved, teammate_terminated, shutdown_request
|
continue; // skip shutdown_approved, teammate_terminated, shutdown_request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -623,11 +625,9 @@ export class TeamGraphAdapter {
|
||||||
);
|
);
|
||||||
const isFromTeammate = fromId !== leadId;
|
const isFromTeammate = fromId !== leadId;
|
||||||
|
|
||||||
// For idle notifications, show a clean "idle" label instead of raw JSON
|
|
||||||
const particleLabel =
|
const particleLabel =
|
||||||
noiseType === 'idle_notification'
|
getIdleGraphLabel(msgText) ??
|
||||||
? 'idle'
|
TeamGraphAdapter.#buildParticleLabel(msg.summary ?? msg.text, 'inbox');
|
||||||
: TeamGraphAdapter.#buildParticleLabel(msg.summary ?? msg.text, 'inbox');
|
|
||||||
|
|
||||||
particles.push({
|
particles.push({
|
||||||
id: `particle:msg:${teamName}:${msgKey}`,
|
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,
|
getSanitizedInboxMessageSummary,
|
||||||
getSanitizedInboxMessageText,
|
getSanitizedInboxMessageText,
|
||||||
} from '@renderer/utils/bootstrapPromptSanitizer';
|
} from '@renderer/utils/bootstrapPromptSanitizer';
|
||||||
|
import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
|
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
@ -15,12 +16,18 @@ export interface TeamMessagesFilter {
|
||||||
export function filterTeamMessages(
|
export function filterTeamMessages(
|
||||||
messages: InboxMessage[],
|
messages: InboxMessage[],
|
||||||
options: {
|
options: {
|
||||||
|
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
|
||||||
timeWindow?: { start: number; end: number } | null;
|
timeWindow?: { start: number; end: number } | null;
|
||||||
filter: TeamMessagesFilter;
|
filter: TeamMessagesFilter;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
}
|
}
|
||||||
): InboxMessage[] {
|
): InboxMessage[] {
|
||||||
const { timeWindow, filter, searchQuery } = options;
|
const {
|
||||||
|
includePassiveIdlePeerSummariesWhenNoiseHidden = false,
|
||||||
|
timeWindow,
|
||||||
|
filter,
|
||||||
|
searchQuery,
|
||||||
|
} = options;
|
||||||
|
|
||||||
let list = messages.filter((m) => m.messageKind !== 'task_comment_notification');
|
let list = messages.filter((m) => m.messageKind !== 'task_comment_notification');
|
||||||
if (timeWindow) {
|
if (timeWindow) {
|
||||||
|
|
@ -30,7 +37,14 @@ export function filterTeamMessages(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!filter.showNoise) {
|
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;
|
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 () => {
|
it('caches unchanged lead-session extraction results and returns defensive clones', async () => {
|
||||||
const service = createLeadSessionCachingService();
|
const service = createLeadSessionCachingService();
|
||||||
const jsonlPath = await createTempJsonl([
|
const jsonlPath = await createTempJsonl([
|
||||||
|
|
|
||||||
|
|
@ -141,11 +141,9 @@ describe('TeamInboxReader', () => {
|
||||||
|
|
||||||
const messages = await reader.getMessagesFor('my-team', 'alice');
|
const messages = await reader.getMessagesFor('my-team', 'alice');
|
||||||
expect(messages).toHaveLength(2);
|
expect(messages).toHaveLength(2);
|
||||||
// Legacy row gets a deterministic hash-based messageId
|
|
||||||
const legacy = messages.find((m) => m.text === 'legacy');
|
const legacy = messages.find((m) => m.text === 'legacy');
|
||||||
expect(legacy).toBeDefined();
|
expect(legacy).toBeDefined();
|
||||||
expect(legacy!.messageId).toMatch(/^inbox-[a-f0-9]{16}$/);
|
expect(legacy!.messageId).toBe('inbox-3d4d01c54fc0dc52');
|
||||||
// Explicit messageId is preserved
|
|
||||||
const supported = messages.find((m) => m.text === 'supported');
|
const supported = messages.find((m) => m.text === 'supported');
|
||||||
expect(supported).toBeDefined();
|
expect(supported).toBeDefined();
|
||||||
expect(supported!.messageId).toBe('m-1');
|
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';
|
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
|
|
||||||
function seedConfig(teamName: string): void {
|
function seedConfig(teamName: string): void {
|
||||||
|
|
@ -217,6 +218,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
hoisted.files.clear();
|
hoisted.files.clear();
|
||||||
hoisted.readFile.mockClear();
|
hoisted.readFile.mockClear();
|
||||||
hoisted.atomicWrite.mockClear();
|
hoisted.atomicWrite.mockClear();
|
||||||
|
hoisted.setAtomicWriteShouldFail(false);
|
||||||
hoisted.appendSentMessage.mockClear();
|
hoisted.appendSentMessage.mockClear();
|
||||||
hoisted.sendInboxMessage.mockClear();
|
hoisted.sendInboxMessage.mockClear();
|
||||||
hoisted.setAtomicWriteShouldFail(false);
|
hoisted.setAtomicWriteShouldFail(false);
|
||||||
|
|
@ -907,6 +909,277 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
expect(payload).toContain('Please review my changes');
|
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 () => {
|
it('lead inbox relay prompt mentions task_create_from_message for user messages with messageId', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
|
|
@ -937,4 +1210,202 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
expect(payload).toContain('task_create_from_message');
|
expect(payload).toContain('task_create_from_message');
|
||||||
expect(payload).toContain('MessageId');
|
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,
|
getCrossTeamSentMemberName,
|
||||||
getCrossTeamSentTarget,
|
getCrossTeamSentTarget,
|
||||||
getSystemMessageLabel,
|
getSystemMessageLabel,
|
||||||
|
isNoiseMessage,
|
||||||
isQualifiedExternalRecipient,
|
isQualifiedExternalRecipient,
|
||||||
} from '@renderer/components/team/activity/ActivityItem';
|
} from '@renderer/components/team/activity/ActivityItem';
|
||||||
import type { InboxMessage } from '@shared/types';
|
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('team-best.user')).toBe('user');
|
||||||
expect(getCrossTeamSentMemberName('cross-team:team-best')).toBeNull();
|
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);
|
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', () => {
|
it('creates particles for each newly appended task comment, not only the latest one', () => {
|
||||||
const adapter = TeamGraphAdapter.create();
|
const adapter = TeamGraphAdapter.create();
|
||||||
const baseline = createBaseTeamData({
|
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');
|
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', () => {
|
it('hides task comment notifications by semantic kind instead of text matching', () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
makeMessage({
|
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