refactor(team): extract message notification scanner

This commit is contained in:
777genius 2026-05-21 23:47:55 +03:00
parent ec004e9328
commit a8e7f1ccd5
5 changed files with 483 additions and 238 deletions

View file

@ -94,10 +94,9 @@ import {
TEAM_VALIDATE_CLI_ARGS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, wrapAgentBlock } from '@shared/constants/agentBlocks';
import { wrapAgentBlock } from '@shared/constants/agentBlocks';
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import {
extractFlagsFromHelp,
extractUserFlags,
@ -111,7 +110,6 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import {
buildStandaloneSlashCommandMeta,
parseStandaloneSlashCommand,
@ -133,7 +131,6 @@ import {
import {
getAutoResumeService,
initializeAutoResumeService,
planRateLimitAutoResume,
} from '../services/team/AutoResumeService';
import {
cloneLaunchIoGovernorPayload,
@ -156,6 +153,7 @@ import {
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
import { teamMessageNotificationScanner } from './teams/teamMessageNotificationScanner';
import {
validateFromField,
validateMemberName,
@ -301,14 +299,6 @@ function validateTeamGetDataOptions(
};
}
/**
* In-memory set of rate-limit message keys already processed.
* Independent of NotificationManager storage — survives notification deletion/pruning.
* Without this, deleted rate-limit notifications would re-appear on next getData() scan.
*/
const seenRateLimitKeys = new Set<string>();
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
async function withTimeoutValue<T>(
promise: Promise<T>,
timeoutMs: number,
@ -442,178 +432,6 @@ function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string |
);
}
/**
* In-memory set of API error message keys already processed.
* Independent of NotificationManager storage — survives notification deletion/pruning.
*/
const seenApiErrorKeys = new Set<string>();
const SEEN_API_ERROR_KEYS_MAX = 500;
function formatNotificationClockTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function buildRateLimitNotificationBody(plan: ReturnType<typeof planRateLimitAutoResume>): string {
if (plan.kind === 'scheduled') {
return `Auto-resume scheduled at ${formatNotificationClockTime(new Date(plan.fireAtMs))}`;
}
return 'Manual restart needed';
}
/**
* Check messages for rate limit indicators and fire notifications for new ones.
* Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion)
* and NotificationManager dedupeKey (to prevent storage duplicates).
*/
function checkRateLimitMessages(
messages: readonly {
messageId?: string;
from: string;
text: string;
timestamp: string;
to?: string;
source?: string;
leadSessionId?: string;
}[],
teamName: string,
teamDisplayName: string,
projectPath?: string,
teamIsAlive = true,
currentLeadSessionId: string | null = null
): void {
const observedAt = new Date();
const autoResumeEnabled =
ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit;
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!isRateLimitMessage(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
const isLeadAutoResumeCandidate =
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
const autoResumeSessionMatches =
msg.source !== 'lead_session' ||
(Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId);
const autoResumePlan = planRateLimitAutoResume({
enabled: autoResumeEnabled,
canAutoResume: teamIsAlive && isLeadAutoResumeCandidate && autoResumeSessionMatches,
messageText: msg.text,
observedAt,
messageTimestamp: new Date(msg.timestamp),
});
// In-memory guard: prevents resurrection after user deletes the notification.
if (!seenRateLimitKeys.has(dedupeKey)) {
seenRateLimitKeys.add(dedupeKey);
// Evict oldest entries to prevent unbounded growth
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
const first = seenRateLimitKeys.values().next().value;
if (first) seenRateLimitKeys.delete(first);
}
void NotificationManager.getInstance()
.addTeamNotification({
teamEventType: 'rate_limit',
teamName,
teamDisplayName,
from: msg.from,
summary: 'Rate limit',
body: buildRateLimitNotificationBody(autoResumePlan),
dedupeKey,
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
projectPath,
})
.catch(() => undefined);
}
// Only schedule auto-resume while a live team run currently exists.
// Persisted history for an offline/stopped team may still contain the old
// rate-limit message, but arming a new timer from that stale history would
// resurrect the nudge into a later manual restart.
if (autoResumePlan.kind === 'scheduled') {
// Only let persisted lead_session history rebuild auto-resume when it
// clearly belongs to the currently running lead session. Otherwise an old
// rate-limit from a previous manual run can resurrect into a newer restart.
// Pass the original message timestamp so relative reset windows survive restarts
// and old history does not rebuild a fresh auto-resume timer from "now".
getAutoResumeService().handleRateLimitMessage(
teamName,
msg.text,
observedAt,
new Date(msg.timestamp)
);
}
}
}
/**
* Check messages for API errors (e.g. "API Error: 429 ...") and fire OS notifications.
* Mirrors the rate-limit approach: in-memory dedup + NotificationManager dedupeKey.
* Skips rate-limit messages (they have their own notification path).
*/
function checkApiErrorMessages(
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
teamName: string,
teamDisplayName: string,
projectPath?: string
): void {
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!isApiErrorMessage(msg.text)) continue;
// Don't double-notify if it's also a rate limit message
if (isRateLimitMessage(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `api-error:${teamName}:${rawKey}`;
if (seenApiErrorKeys.has(dedupeKey)) continue;
seenApiErrorKeys.add(dedupeKey);
if (seenApiErrorKeys.size > SEEN_API_ERROR_KEYS_MAX) {
const first = seenApiErrorKeys.values().next().value;
if (first) seenApiErrorKeys.delete(first);
}
// Extract status code for summary
const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text);
const statusCode = statusMatch?.[1] ?? '???';
void NotificationManager.getInstance()
.addTeamNotification({
teamEventType: 'api_error',
teamName,
teamDisplayName,
from: msg.from,
summary: `API Error ${statusCode}`,
body: 'Manual restart needed',
dedupeKey,
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
projectPath,
})
.catch(() => undefined);
}
}
function scanTeamMessageNotifications(
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
teamName: string,
teamDisplayName: string,
projectPath?: string
): void {
if (messages.length === 0) {
return;
}
checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath);
checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath);
}
let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
@ -1145,17 +963,24 @@ async function handleGetData(
if (live.length === 0) {
if (durableMessages.length > 0) {
checkRateLimitMessages(
durableMessages,
tn,
displayName,
teamMessageNotificationScanner.checkRateLimitMessages(durableMessages, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
isAlive,
currentLeadSessionId
);
checkApiErrorMessages(durableMessages, tn, displayName, projectPath);
teamIsAlive: isAlive,
currentLeadSessionId,
});
teamMessageNotificationScanner.checkApiErrorMessages(durableMessages, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
} else {
scanTeamMessageNotifications(live, tn, displayName, projectPath);
teamMessageNotificationScanner.scan(live, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
}
return { success: true, data: { ...data, isAlive } };
}
@ -1177,8 +1002,18 @@ async function handleGetData(
}
}
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
checkApiErrorMessages(merged, tn, displayName, projectPath);
teamMessageNotificationScanner.checkRateLimitMessages(merged, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
teamIsAlive: isAlive,
currentLeadSessionId,
});
teamMessageNotificationScanner.checkApiErrorMessages(merged, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
return { success: true, data: { ...data, isAlive } };
}
@ -2786,8 +2621,8 @@ function buildMessageDeliveryText(
'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
];
hiddenBlocks.push(
wrapAgentBlock(
[
AGENT_BLOCK_OPEN,
`You received a direct message from ${senderDescriptor} via the UI.`,
...replyInstructionLines,
`Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`,
@ -2805,8 +2640,8 @@ function buildMessageDeliveryText(
'Do NOT stay silent while you go ask someone else.',
]
: []),
AGENT_BLOCK_CLOSE,
].join('\n')
)
);
}
@ -2844,12 +2679,11 @@ async function handleGetMessagesPage(
.catch(() => ({ displayName: teamName }));
void notificationContextPromise
.then((notificationContext) => {
scanTeamMessageNotifications(
messagesPage.messages,
teamMessageNotificationScanner.scan(messagesPage.messages, {
teamName,
notificationContext.displayName,
notificationContext.projectPath
);
teamDisplayName: notificationContext.displayName,
projectPath: notificationContext.projectPath,
});
})
.catch((error: unknown) => {
logger.debug(
@ -3062,10 +2896,12 @@ async function handleSendMessage(
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
...(rosterContextBlock ? [rosterContextBlock] : []),
...(delegateAckBlock ? [delegateAckBlock] : []),
AGENT_BLOCK_OPEN,
wrapAgentBlock(
[
`MessageId: ${preGeneratedMessageId}`,
`When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`,
AGENT_BLOCK_CLOSE,
].join('\n')
),
``,
`Message from user:`,
buildMessageDeliveryText(payload.text!, {

View file

@ -0,0 +1,240 @@
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
import {
getAutoResumeService,
planRateLimitAutoResume,
type RateLimitAutoResumePlan,
} from '@main/services/team/AutoResumeService';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import type { TeamNotificationPayload } from '@main/utils/teamNotificationBuilder';
export interface TeamNotificationMessage {
messageId?: string;
from: string;
text: string;
timestamp: string;
to?: string;
source?: string;
leadSessionId?: string;
}
interface TeamNotificationSink {
addTeamNotification(payload: TeamNotificationPayload): Promise<unknown>;
}
interface AutoResumeSink {
handleRateLimitMessage(
teamName: string,
messageText: string,
observedAt: Date,
messageTimestamp: Date
): void;
}
interface ConfigReader {
getConfig(): {
notifications: {
autoResumeOnRateLimit: boolean;
};
};
}
export interface TeamMessageNotificationScannerDeps {
configReader?: ConfigReader;
notificationSink?: TeamNotificationSink;
autoResumeSink?: AutoResumeSink;
planAutoResume?: typeof planRateLimitAutoResume;
isRateLimit?: (text: string) => boolean;
isApiError?: (text: string) => boolean;
now?: () => Date;
formatClockTime?: (date: Date) => string;
}
export interface TeamMessageNotificationContext {
teamName: string;
teamDisplayName: string;
projectPath?: string;
teamIsAlive?: boolean;
currentLeadSessionId?: string | null;
}
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
const SEEN_API_ERROR_KEYS_MAX = 500;
function formatNotificationClockTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function buildRateLimitNotificationBody(
plan: RateLimitAutoResumePlan,
formatClockTime: (date: Date) => string
): string {
if (plan.kind === 'scheduled') {
return `Auto-resume scheduled at ${formatClockTime(new Date(plan.fireAtMs))}`;
}
return 'Manual restart needed';
}
function evictOldestIfNeeded(keys: Set<string>, maxSize: number): void {
if (keys.size <= maxSize) {
return;
}
const first = keys.values().next().value;
if (first) {
keys.delete(first);
}
}
function createDefaultNotificationSink(): TeamNotificationSink {
return {
addTeamNotification: (payload) => NotificationManager.getInstance().addTeamNotification(payload),
};
}
export class TeamMessageNotificationScanner {
readonly #seenRateLimitKeys = new Set<string>();
readonly #seenApiErrorKeys = new Set<string>();
readonly #configReader: ConfigReader;
readonly #notificationSink: TeamNotificationSink;
readonly #planAutoResume: typeof planRateLimitAutoResume;
readonly #isRateLimit: (text: string) => boolean;
readonly #isApiError: (text: string) => boolean;
readonly #now: () => Date;
readonly #formatClockTime: (date: Date) => string;
readonly #autoResumeSink: AutoResumeSink | null;
constructor(deps: TeamMessageNotificationScannerDeps = {}) {
this.#configReader = deps.configReader ?? ConfigManager.getInstance();
this.#notificationSink = deps.notificationSink ?? createDefaultNotificationSink();
this.#planAutoResume = deps.planAutoResume ?? planRateLimitAutoResume;
this.#isRateLimit = deps.isRateLimit ?? isRateLimitMessage;
this.#isApiError = deps.isApiError ?? isApiErrorMessage;
this.#now = deps.now ?? (() => new Date());
this.#formatClockTime = deps.formatClockTime ?? formatNotificationClockTime;
this.#autoResumeSink = deps.autoResumeSink ?? null;
}
checkRateLimitMessages(
messages: readonly TeamNotificationMessage[],
context: TeamMessageNotificationContext
): void {
const observedAt = this.#now();
const autoResumeEnabled = this.#configReader.getConfig().notifications.autoResumeOnRateLimit;
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!this.#isRateLimit(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `rate-limit:${context.teamName}:${rawKey}`;
const isLeadAutoResumeCandidate =
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
const currentLeadSessionId = context.currentLeadSessionId ?? null;
const autoResumeSessionMatches =
msg.source !== 'lead_session' ||
(Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId);
const autoResumePlan = this.#planAutoResume({
enabled: autoResumeEnabled,
canAutoResume:
(context.teamIsAlive ?? true) &&
isLeadAutoResumeCandidate &&
autoResumeSessionMatches,
messageText: msg.text,
observedAt,
messageTimestamp: new Date(msg.timestamp),
});
if (!this.#seenRateLimitKeys.has(dedupeKey)) {
this.#seenRateLimitKeys.add(dedupeKey);
evictOldestIfNeeded(this.#seenRateLimitKeys, SEEN_RATE_LIMIT_KEYS_MAX);
void this.#notificationSink
.addTeamNotification({
teamEventType: 'rate_limit',
teamName: context.teamName,
teamDisplayName: context.teamDisplayName,
from: msg.from,
summary: 'Rate limit',
body: buildRateLimitNotificationBody(autoResumePlan, this.#formatClockTime),
dedupeKey,
target: {
kind: 'member',
teamName: context.teamName,
memberName: msg.from,
focus: 'logs',
},
projectPath: context.projectPath,
})
.catch(() => undefined);
}
if (autoResumePlan.kind === 'scheduled') {
const autoResumeSink = this.#autoResumeSink ?? getAutoResumeService();
autoResumeSink.handleRateLimitMessage(
context.teamName,
msg.text,
observedAt,
new Date(msg.timestamp)
);
}
}
}
checkApiErrorMessages(
messages: readonly TeamNotificationMessage[],
context: TeamMessageNotificationContext
): void {
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!this.#isApiError(msg.text)) continue;
if (this.#isRateLimit(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `api-error:${context.teamName}:${rawKey}`;
if (this.#seenApiErrorKeys.has(dedupeKey)) continue;
this.#seenApiErrorKeys.add(dedupeKey);
evictOldestIfNeeded(this.#seenApiErrorKeys, SEEN_API_ERROR_KEYS_MAX);
const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text);
const statusCode = statusMatch?.[1] ?? '???';
void this.#notificationSink
.addTeamNotification({
teamEventType: 'api_error',
teamName: context.teamName,
teamDisplayName: context.teamDisplayName,
from: msg.from,
summary: `API Error ${statusCode}`,
body: 'Manual restart needed',
dedupeKey,
target: {
kind: 'member',
teamName: context.teamName,
memberName: msg.from,
focus: 'logs',
},
projectPath: context.projectPath,
})
.catch(() => undefined);
}
}
scan(messages: readonly TeamNotificationMessage[], context: TeamMessageNotificationContext): void {
if (messages.length === 0) {
return;
}
this.checkRateLimitMessages(messages, context);
this.checkApiErrorMessages(messages, context);
}
}
export const teamMessageNotificationScanner = new TeamMessageNotificationScanner();

View file

@ -2,12 +2,7 @@ import { fromProvisioningMembers, isMixedOpenCodeSideLanePlan } from '@features/
import { yieldToEventLoop } from '@main/utils/asyncYield';
import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { killProcessByPid } from '@main/utils/processKill';
import {
AGENT_BLOCK_CLOSE,
AGENT_BLOCK_OPEN,
stripAgentBlocks,
wrapAgentBlock,
} from '@shared/constants/agentBlocks';
import { stripAgentBlocks, wrapAgentBlock } from '@shared/constants/agentBlocks';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
@ -2053,11 +2048,14 @@ export class TeamDataService {
parts.push(`\nDetails:\n${task.description.trim()}`);
}
parts.push(
`\n${AGENT_BLOCK_OPEN}`,
'',
wrapAgentBlock(
[
`Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`,
`Update task status using the board MCP tools:`,
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
AGENT_BLOCK_CLOSE
].join('\n')
)
);
await this.sendMessage(teamName, {
member: task.owner,

View file

@ -1,4 +1,4 @@
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { wrapAgentBlock } from '@shared/constants/agentBlocks';
import * as agentTeamsControllerModule from 'agent-teams-controller';
import type { AgentActionMode } from '@shared/types';
@ -46,7 +46,7 @@ export function buildActionModeAgentBlock(mode: AgentActionMode | undefined): st
}
const lines = ACTION_MODE_BLOCKS[mode];
return `${AGENT_BLOCK_OPEN}\n${lines.join('\n')}\n${AGENT_BLOCK_CLOSE}`;
return wrapAgentBlock(lines.join('\n'));
}
export function isAgentActionMode(value: unknown): value is AgentActionMode {

View file

@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
TeamMessageNotificationScanner,
type TeamNotificationMessage,
} from '../../../../src/main/ipc/teams/teamMessageNotificationScanner';
import type { RateLimitAutoResumePlan } from '../../../../src/main/services/team/AutoResumeService';
import type { TeamNotificationPayload } from '../../../../src/main/utils/teamNotificationBuilder';
function createMessage(overrides: Partial<TeamNotificationMessage> = {}): TeamNotificationMessage {
return {
from: 'team-lead',
text: "You've hit your limit. Resets in 5 minutes.",
timestamp: '2026-04-17T12:00:00.000Z',
messageId: 'msg-1',
source: 'lead_session',
leadSessionId: 'sess-live',
...overrides,
};
}
describe('TeamMessageNotificationScanner', () => {
const notificationSink = {
addTeamNotification: vi.fn<() => Promise<unknown>>(),
};
const autoResumeSink = {
handleRateLimitMessage: vi.fn(),
};
let autoResumeEnabled = true;
beforeEach(() => {
notificationSink.addTeamNotification.mockReset();
notificationSink.addTeamNotification.mockResolvedValue(null);
autoResumeSink.handleRateLimitMessage.mockReset();
autoResumeEnabled = true;
});
function createScanner(options?: {
isRateLimit?: (text: string) => boolean;
isApiError?: (text: string) => boolean;
planAutoResume?: (input: {
enabled: boolean;
canAutoResume: boolean;
messageText: string;
observedAt: Date;
messageTimestamp?: Date;
}) => RateLimitAutoResumePlan;
}): TeamMessageNotificationScanner {
return new TeamMessageNotificationScanner({
configReader: {
getConfig: () => ({ notifications: { autoResumeOnRateLimit: autoResumeEnabled } }),
},
notificationSink,
autoResumeSink,
now: () => new Date('2026-04-17T12:02:00.000Z'),
formatClockTime: () => '12:05',
isRateLimit: options?.isRateLimit ?? ((text) => text.includes('limit')),
isApiError: options?.isApiError ?? ((text) => text.startsWith('API Error:')),
planAutoResume:
options?.planAutoResume ??
((input) =>
input.enabled && input.canAutoResume
? {
kind: 'scheduled',
resetTime: new Date('2026-04-17T12:05:00.000Z'),
delayMs: 180_000,
fireAtMs: Date.parse('2026-04-17T12:05:30.000Z'),
rawDelayMs: 180_000,
}
: { kind: 'manual', reason: 'disabled' }),
});
}
it('notifies and schedules auto-resume for a live lead rate-limit message', () => {
const scanner = createScanner();
scanner.checkRateLimitMessages([createMessage()], {
teamName: 'my-team',
teamDisplayName: 'My Team',
projectPath: '/tmp/project',
teamIsAlive: true,
currentLeadSessionId: 'sess-live',
});
expect(notificationSink.addTeamNotification).toHaveBeenCalledWith(
expect.objectContaining<TeamNotificationPayload>({
teamEventType: 'rate_limit',
teamName: 'my-team',
teamDisplayName: 'My Team',
from: 'team-lead',
summary: 'Rate limit',
body: 'Auto-resume scheduled at 12:05',
dedupeKey: 'rate-limit:my-team:msg-1',
target: { kind: 'member', teamName: 'my-team', memberName: 'team-lead', focus: 'logs' },
projectPath: '/tmp/project',
})
);
expect(autoResumeSink.handleRateLimitMessage).toHaveBeenCalledWith(
'my-team',
"You've hit your limit. Resets in 5 minutes.",
new Date('2026-04-17T12:02:00.000Z'),
new Date('2026-04-17T12:00:00.000Z')
);
});
it('dedupes notification storage but still re-evaluates auto-resume later', () => {
const scanner = createScanner();
const context = {
teamName: 'my-team',
teamDisplayName: 'My Team',
teamIsAlive: true,
currentLeadSessionId: 'sess-live',
};
autoResumeEnabled = false;
scanner.checkRateLimitMessages([createMessage()], context);
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
expect(autoResumeSink.handleRateLimitMessage).not.toHaveBeenCalled();
autoResumeEnabled = true;
scanner.checkRateLimitMessages([createMessage()], context);
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
expect(autoResumeSink.handleRateLimitMessage).toHaveBeenCalledTimes(1);
});
it('does not schedule auto-resume from an older lead session', () => {
const scanner = createScanner();
scanner.checkRateLimitMessages(
[createMessage({ leadSessionId: 'sess-old', messageId: 'old-session' })],
{
teamName: 'my-team',
teamDisplayName: 'My Team',
teamIsAlive: true,
currentLeadSessionId: 'sess-live',
}
);
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
expect(autoResumeSink.handleRateLimitMessage).not.toHaveBeenCalled();
});
it('sends API-error notifications while leaving rate limits to the rate-limit path', () => {
const scanner = createScanner({
isRateLimit: (text) => text.includes('429'),
isApiError: (text) => text.startsWith('API Error:'),
});
scanner.checkApiErrorMessages(
[
createMessage({ text: 'API Error: 429 rate limited', messageId: 'rate-limit-api' }),
createMessage({ text: 'API Error: 500 server failed', messageId: 'api-500' }),
],
{
teamName: 'my-team',
teamDisplayName: 'My Team',
}
);
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
expect(notificationSink.addTeamNotification).toHaveBeenCalledWith(
expect.objectContaining({
teamEventType: 'api_error',
summary: 'API Error 500',
dedupeKey: 'api-error:my-team:api-500',
})
);
});
});