Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
79 changes: 79 additions & 0 deletions src/notifications/__tests__/idle-cooldown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ describe('shouldSendIdleNotification', () => {
assert.equal(shouldSendIdleNotification(stateDir, 'sess1'), true);
});

it('returns true when cooldown is 0 even for unchanged fingerprints', () => {
process.env.OMX_IDLE_COOLDOWN_SECONDS = '0';
const sessionId = 'test-session-disabled-fingerprint';
const fingerprint = '{"phase":"idle","summary":"Waiting for input"}';

recordIdleNotificationSent(stateDir, sessionId, fingerprint);

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

it('returns false when cooldown has NOT elapsed', () => {
process.env.OMX_IDLE_COOLDOWN_SECONDS = '60';
recordIdleNotificationSent(stateDir, 'sess2');
Expand Down Expand Up @@ -96,6 +106,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 +189,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
// Cooldown of 0 means disabled — always send, including fingerprinted repeats
if (cooldownSecs === 0) return true;

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