Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/notifications/__tests__/idle-cooldown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,63 @@ describe('shouldSendIdleNotification', () => {
writeFileSync(cooldownPath, 'invalid json {{{');
assert.equal(shouldSendIdleNotification(stateDir), true);
});

it('suppresses repeated unchanged idle fingerprints', () => {
const sessionId = 'test-session-unchanged';
const fingerprint = '{"phase":"idle","summary":"Waiting for input"}';

recordIdleNotificationSent(stateDir, sessionId, fingerprint);

assert.equal(shouldSendIdleNotification(stateDir, sessionId, fingerprint), false);
});

it('allows a changed summary fingerprint immediately', () => {
process.env.OMX_IDLE_COOLDOWN_SECONDS = '60';
const sessionId = 'test-session-summary-change';

recordIdleNotificationSent(stateDir, sessionId, '{"phase":"idle","summary":"Waiting on review"}');

assert.equal(
shouldSendIdleNotification(stateDir, sessionId, '{"phase":"idle","summary":"Waiting on user input"}'),
true,
);
});

it('allows a progress transition to clear prior idle suppression', () => {
process.env.OMX_IDLE_COOLDOWN_SECONDS = '60';
const sessionId = 'test-session-progress-reset';
const blockedFingerprint = '{"phase":"idle","summary":"Blocked on dependency"}';
const progressFingerprint = '{"phase":"progress","summary":"Applied fix and running tests"}';

recordIdleNotificationSent(stateDir, sessionId, blockedFingerprint);
assert.equal(shouldSendIdleNotification(stateDir, sessionId, blockedFingerprint), false);

recordIdleNotificationSent(stateDir, sessionId, progressFingerprint);
assert.equal(shouldSendIdleNotification(stateDir, sessionId, blockedFingerprint), true);
});

it('allows terminal transitions to clear prior idle suppression', () => {
process.env.OMX_IDLE_COOLDOWN_SECONDS = '60';
const sessionId = 'test-session-terminal-reset';
const blockedFingerprint = '{"phase":"idle","summary":"Awaiting next step"}';

recordIdleNotificationSent(stateDir, sessionId, blockedFingerprint);
recordIdleNotificationSent(stateDir, sessionId, '{"phase":"finished","summary":"Completed and waiting for input"}');
assert.equal(shouldSendIdleNotification(stateDir, sessionId, blockedFingerprint), true);

recordIdleNotificationSent(stateDir, sessionId, blockedFingerprint);
recordIdleNotificationSent(stateDir, sessionId, '{"phase":"failed","summary":"Command failed"}');
assert.equal(shouldSendIdleNotification(stateDir, sessionId, blockedFingerprint), true);
});

it('still honors cooldown-only behavior when no fingerprint is provided', () => {
process.env.OMX_IDLE_COOLDOWN_SECONDS = '60';
const sessionId = 'test-session-cooldown-only';

recordIdleNotificationSent(stateDir, sessionId);

assert.equal(shouldSendIdleNotification(stateDir, sessionId), false);
});
});

describe('recordIdleNotificationSent', () => {
Expand All @@ -122,4 +179,16 @@ describe('recordIdleNotificationSent', () => {
const sessionFile = join(stateDir, 'sessions', sessionId, 'idle-notif-cooldown.json');
assert.ok(existsSync(sessionFile));
});

it('persists the idle fingerprint when provided', () => {
const sessionId = 'fingerprint-session';
const fingerprint = '{"phase":"idle","summary":"Waiting for input"}';

recordIdleNotificationSent(stateDir, sessionId, fingerprint);

const sessionFile = join(stateDir, 'sessions', sessionId, 'idle-notif-cooldown.json');
const content = JSON.parse(readFileSync(sessionFile, 'utf-8')) as { lastSentAt: string; fingerprint?: string };
assert.equal(content.fingerprint, fingerprint);
assert.equal(typeof content.lastSentAt, 'string');
});
});
74 changes: 55 additions & 19 deletions src/notifications/idle-cooldown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import { codexHome } from '../utils/paths.js';

const DEFAULT_COOLDOWN_SECONDS = 60;
const SESSION_ID_SAFE_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/;
const MAX_IDLE_FINGERPRINT_LENGTH = 512;

interface IdleNotificationState {
lastSentAt?: string;
fingerprint?: string;
}

/**
* Read the idle notification cooldown in seconds.
Expand Down Expand Up @@ -66,47 +72,77 @@ function getCooldownStatePath(stateDir: string, sessionId?: string): string {
return join(stateDir, 'idle-notif-cooldown.json');
}

function normalizeIdleFingerprint(fingerprint: string | null | undefined): string {
if (typeof fingerprint !== 'string') return '';
const normalized = fingerprint.trim();
if (!normalized) return '';
return normalized.length > MAX_IDLE_FINGERPRINT_LENGTH
? normalized.slice(0, MAX_IDLE_FINGERPRINT_LENGTH)
: normalized;
}

function readIdleNotificationState(cooldownPath: string): IdleNotificationState | null {
try {
if (!existsSync(cooldownPath)) return null;
const data = JSON.parse(readFileSync(cooldownPath, 'utf-8')) as Record<string, unknown>;
return {
lastSentAt: typeof data?.lastSentAt === 'string' ? data.lastSentAt : undefined,
fingerprint: normalizeIdleFingerprint(typeof data?.fingerprint === 'string' ? data.fingerprint : ''),
};
} catch {
return null;
}
}

/**
* Check whether the idle notification cooldown has elapsed.
* Check whether an idle notification should be sent.
*
* Returns true if the notification should be sent (cooldown has elapsed or is disabled).
* Returns false if the notification should be suppressed (too soon since last send).
* Without a fingerprint this preserves the legacy cooldown-only behavior.
* With a fingerprint it suppresses unchanged idle-state repeats until the
* fingerprint meaningfully changes.
*/
export function shouldSendIdleNotification(stateDir: string, sessionId?: string): boolean {
export function shouldSendIdleNotification(stateDir: string, sessionId?: string, fingerprint?: string): boolean {
const cooldownSecs = getIdleNotificationCooldownSeconds();
const normalizedFingerprint = normalizeIdleFingerprint(fingerprint);

// Cooldown of 0 means disabled — always send
if (cooldownSecs === 0) return true;
if (cooldownSecs === 0 && !normalizedFingerprint) return true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect cooldown=0 even when fingerprints are provided

shouldSendIdleNotification now treats OMX_IDLE_COOLDOWN_SECONDS=0 as “always send” only when no fingerprint is passed, but notify-hook always passes a fingerprint. In that common path, unchanged fingerprints are still suppressed, which regresses the documented behavior that cooldown 0 disables throttling entirely and can silently drop repeated idle notifications/hooks for users who explicitly opted out of throttling.

Useful? React with 👍 / 👎.


const cooldownPath = getCooldownStatePath(stateDir, sessionId);
try {
if (!existsSync(cooldownPath)) return true;
const state = readIdleNotificationState(cooldownPath);
if (!state) return true;

const data = JSON.parse(readFileSync(cooldownPath, 'utf-8')) as Record<string, unknown>;
if (data?.lastSentAt && typeof data.lastSentAt === 'string') {
const lastSentMs = new Date(data.lastSentAt).getTime();
if (Number.isFinite(lastSentMs)) {
const elapsedSecs = (Date.now() - lastSentMs) / 1000;
if (elapsedSecs < cooldownSecs) return false;
}
if (normalizedFingerprint) {
return state.fingerprint !== normalizedFingerprint;
}

if (state.lastSentAt) {
const lastSentMs = new Date(state.lastSentAt).getTime();
if (Number.isFinite(lastSentMs)) {
const elapsedSecs = (Date.now() - lastSentMs) / 1000;
if (elapsedSecs < cooldownSecs) return false;
}
} catch {
// ignore read/parse errors — treat as no cooldown file, allow send
}

return true;
}

/**
* Record that an idle notification was sent at the current timestamp.
* Call this after a successful dispatch to arm the cooldown.
* Call this after a successful dispatch to arm the cooldown and optionally
* persist the current idle-state fingerprint.
*/
export function recordIdleNotificationSent(stateDir: string, sessionId?: string): void {
export function recordIdleNotificationSent(stateDir: string, sessionId?: string, fingerprint?: string): void {
const cooldownPath = getCooldownStatePath(stateDir, sessionId);
try {
const dir = dirname(cooldownPath);
mkdirSync(dir, { recursive: true });
writeFileSync(cooldownPath, JSON.stringify({ lastSentAt: new Date().toISOString() }, null, 2));
const normalizedFingerprint = normalizeIdleFingerprint(fingerprint);
const state: IdleNotificationState = { lastSentAt: new Date().toISOString() };
if (normalizedFingerprint) {
state.fingerprint = normalizedFingerprint;
}
writeFileSync(cooldownPath, JSON.stringify(state, null, 2));
} catch {
// ignore write errors — best effort
}
Expand Down
78 changes: 76 additions & 2 deletions src/scripts/notify-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,79 @@ const RALPH_ACTIVE_PROGRESS_PHASES = new Set([
'fixing',
]);

const IDLE_NOTIFICATION_SUMMARY_MAX_LENGTH = 240;

function summarizeIdleNotificationMessage(message: unknown): string {
const source = safeString(message)
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const preferred = source.at(-1) || '';
const normalized = preferred.replace(/\s+/g, ' ').trim();
Comment on lines +75 to +79
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive fingerprint summary from status content, not last line

The fingerprint summary is taken from the last non-empty line of the assistant message. For multi-line replies that end with stable boilerplate (for example, a repeated closing prompt), different idle/blocker updates can collapse to the same summary and be treated as unchanged, so shouldSendIdleNotification suppresses notifications even when the underlying idle reason changed.

Useful? React with 👍 / 👎.

if (!normalized) return '';
return normalized.length > IDLE_NOTIFICATION_SUMMARY_MAX_LENGTH
? `${normalized.slice(0, IDLE_NOTIFICATION_SUMMARY_MAX_LENGTH - 1)}…`
: normalized;
}

function classifyIdleNotificationPhase(message: unknown): 'idle' | 'progress' | 'finished' | 'failed' {
const lower = safeString(message).toLowerCase();
if (!lower) return 'idle';

if (/(error|failed|exception|invalid|timed out|timeout)/i.test(lower)) {
return 'failed';
}

if ([
'all tests pass',
'build succeeded',
'completed',
'complete',
'done',
'final summary',
'summary',
].some((pattern) => lower.includes(pattern))) {
return 'finished';
}

if ([
'verify',
'verified',
'verification',
'review',
'reviewed',
'diagnostic',
'typecheck',
'test',
'implement',
'implemented',
'apply patch',
'change',
'fix',
'update',
'refactor',
'resume',
'resumed',
'progress',
'continue',
'continued',
].some((pattern) => lower.includes(pattern))) {
return 'progress';
}

return 'idle';
}

function buildIdleNotificationFingerprint(payload: Record<string, unknown>): string {
const lastAssistantMessage = safeString(payload['last-assistant-message'] || payload.last_assistant_message || '');
const summary = summarizeIdleNotificationMessage(lastAssistantMessage);
const phase = classifyIdleNotificationPhase(lastAssistantMessage);
return JSON.stringify({
phase,
...(summary ? { summary } : {}),
});
}

async function main() {
const rawPayload = process.argv[process.argv.length - 1];
if (!rawPayload || rawPayload.startsWith('-')) {
Expand Down Expand Up @@ -443,19 +516,20 @@ async function main() {
const { notifyLifecycle } = await import('../notifications/index.js');
const { shouldSendIdleNotification, recordIdleNotificationSent } = await import('../notifications/idle-cooldown.js');
const sessionJsonPath = join(stateDir, 'session.json');
const idleFingerprint = buildIdleNotificationFingerprint(payload);
let notifySessionId = '';
try {
const sessionData = JSON.parse(await readFile(sessionJsonPath, 'utf-8'));
notifySessionId = safeString(sessionData && sessionData.session_id ? sessionData.session_id : '');
} catch { /* no session file */ }

if (notifySessionId && shouldSendIdleNotification(stateDir, notifySessionId)) {
if (notifySessionId && shouldSendIdleNotification(stateDir, notifySessionId, idleFingerprint)) {
const idleResult = await notifyLifecycle('session-idle', {
sessionId: notifySessionId,
projectPath: cwd,
});
if (idleResult && idleResult.anySuccess) {
recordIdleNotificationSent(stateDir, notifySessionId);
recordIdleNotificationSent(stateDir, notifySessionId, idleFingerprint);
}
try {
const { buildNativeHookEvent } = await import('../hooks/extensibility/events.js');
Expand Down
Loading