Files
openchamber/packages/web/server/lib/notifications/runtime.js
T
Bohdan Triapitsyn 91de51d1a5 fix: deduplicate desktop notifications and tighten notification text extraction
Desktop notifications no longer duplicate when native delivery succeeds
Reasoning chain-of-thought is excluded from notification body text
Untyped message parts are ignored in notification text extraction
2026-06-16 15:21:27 +03:00

567 lines
20 KiB
JavaScript

export const createNotificationTriggerRuntime = (deps) => {
const {
readSettingsFromDisk,
prepareNotificationLastMessage,
buildTemplateVariables,
extractLastMessageText,
fetchLastAssistantMessageText,
resolveNotificationTemplate,
shouldApplyResolvedTemplateMessage,
emitDesktopNotification,
broadcastUiNotification,
sendPushToAllUiSessions,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
} = deps;
let getIsWindowFocused = typeof deps.getIsWindowFocused === 'function'
? deps.getIsWindowFocused
: null;
const setGetIsWindowFocused = (cb) => {
getIsWindowFocused = typeof cb === 'function' ? cb : null;
};
const PUSH_READY_COOLDOWN_MS = 5000;
const PUSH_QUESTION_DEBOUNCE_MS = 500;
const PUSH_PERMISSION_DEBOUNCE_MS = 500;
const pushQuestionDebounceTimers = new Map();
const pushPermissionDebounceTimers = new Map();
const notifiedPermissionRequests = new Set();
const lastReadyNotificationAt = new Map();
const sessionParentIdCache = new Map();
const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000;
// Sessions where the client has enabled Permission Auto-Accept. Mirrored
// from the client-side permissionStore via POST /api/notifications/auto-accept
// so the server can suppress permission notifications BEFORE dispatch (the
// 500ms debounce race otherwise leaks notifications for auto-accepted
// permissions when the replied round-trip is slower than the debounce).
const autoAcceptingSessions = new Set();
const setAutoAcceptSession = (sessionId, enabled) => {
if (typeof sessionId !== 'string' || sessionId.length === 0) return;
if (enabled) {
autoAcceptingSessions.add(sessionId);
} else {
autoAcceptingSessions.delete(sessionId);
}
};
const buildSessionDeepLinkUrl = (sessionId) => {
if (!sessionId || typeof sessionId !== 'string') {
return '/';
}
return `/?session=${encodeURIComponent(sessionId)}`;
};
const getCachedSessionParentId = (sessionId) => {
const entry = sessionParentIdCache.get(sessionId);
if (!entry) return undefined;
if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) {
sessionParentIdCache.delete(sessionId);
return undefined;
}
return entry.parentID;
};
const setCachedSessionParentId = (sessionId, parentID) => {
if (!parentID) return;
sessionParentIdCache.set(sessionId, { parentID: parentID ?? null, at: Date.now() });
};
const getParentIdFromPayload = (payload) => {
if (!payload || typeof payload !== 'object') return null;
if (payload.type !== 'session.created' && payload.type !== 'session.updated') return null;
const parentID = payload.properties?.info?.parentID ?? null;
return typeof parentID === 'string' && parentID.length > 0 ? parentID : null;
};
const maybeCacheSessionParentFromPayload = (payload) => {
const sessionId = extractSessionIdFromPayload(payload);
if (typeof sessionId !== 'string' || sessionId.length === 0) return;
const parentID = getParentIdFromPayload(payload);
if (parentID) {
setCachedSessionParentId(sessionId, parentID);
}
};
const fetchSessionParentId = async (sessionId) => {
if (!sessionId) return undefined;
const cached = getCachedSessionParentId(sessionId);
if (cached !== undefined) return cached;
try {
const response = await fetch(buildOpenCodeUrl('/session', ''), {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
return undefined;
}
const data = await response.json().catch(() => null);
const sessions = Array.isArray(data)
? data
: Array.isArray(data?.items)
? data.items
: Array.isArray(data?.data)
? data.data
: null;
if (!sessions) {
return undefined;
}
const match = sessions.find((session) => session && typeof session === 'object' && session.id === sessionId);
const parentID = match?.parentID ?? null;
setCachedSessionParentId(sessionId, parentID);
return parentID;
} catch {
return undefined;
}
};
// Mirrors client-side autoRespondsPermission: a session auto-accepts if it
// OR any ancestor is flagged. Walks the parent chain via fetchSessionParentId.
const isSessionAutoAccepting = async (sessionId) => {
if (!sessionId || autoAcceptingSessions.size === 0) return false;
let current = sessionId;
const seen = new Set();
while (current && !seen.has(current)) {
if (autoAcceptingSessions.has(current)) return true;
seen.add(current);
const parent = await fetchSessionParentId(current);
if (!parent) return false;
current = parent;
}
return false;
};
const extractSessionIdFromPayload = (payload) => {
if (!payload || typeof payload !== 'object') return null;
const props = payload.properties;
const info = props?.info;
const sessionId =
info?.sessionID ??
info?.sessionId ??
props?.sessionID ??
props?.sessionId ??
props?.session ??
null;
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
};
const extractDirectoryFromPayload = (payload) => {
if (!payload || typeof payload !== 'object') return undefined;
const props = payload.properties;
const directory = props?.directory ?? props?.info?.directory;
if (typeof directory !== 'string') return undefined;
const trimmed = directory.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const formatMode = (raw) => {
const value = typeof raw === 'string' ? raw.trim() : '';
const normalized = value.length > 0 ? value : 'agent';
return normalized
.split(/[-_\s]+/)
.filter(Boolean)
.map((token) => token.charAt(0).toUpperCase() + token.slice(1))
.join(' ');
};
const formatModelId = (raw) => {
const value = typeof raw === 'string' ? raw.trim() : '';
if (!value) {
return 'Assistant';
}
const tokens = value.split(/[-_]+/).filter(Boolean);
const result = [];
for (let i = 0; i < tokens.length; i += 1) {
const current = tokens[i];
const next = tokens[i + 1];
if (/^\d+$/.test(current) && next && /^\d+$/.test(next)) {
result.push(`${current}.${next}`);
i += 1;
continue;
}
result.push(current);
}
return result
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
};
const maybeSendPushForTrigger = async (payload) => {
if (!payload || typeof payload !== 'object') {
return;
}
maybeCacheSessionParentFromPayload(payload);
const sessionId = extractSessionIdFromPayload(payload);
const notificationDirectory = extractDirectoryFromPayload(payload);
if (payload.type === 'message.updated') {
const info = payload.properties?.info;
if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
const settings = await readSettingsFromDisk();
if (settings.notifyOnSubtasks === false) {
const parentIDFromPayload = getParentIdFromPayload(payload);
const parentID = parentIDFromPayload
? parentIDFromPayload
: await fetchSessionParentId(sessionId);
if (parentID) {
return;
}
}
if (settings.notifyOnCompletion === false) {
return;
}
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
return;
}
const now = Date.now();
const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0;
if (now - lastAt < PUSH_READY_COOLDOWN_MS) {
return;
}
lastReadyNotificationAt.set(sessionId, now);
let title = `${formatMode(info?.mode)} agent is ready`;
let body = `${formatModelId(info?.modelID)} completed the task`;
try {
const templates = settings.notificationTemplates || {};
const isSubtask = await fetchSessionParentId(sessionId);
const completionTemplate = isSubtask && settings.notifyOnSubtasks !== false
? (templates.subtask || templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' })
: (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' });
const variables = await buildTemplateVariables(payload, sessionId);
const messageId = info?.id;
let lastMessage = extractLastMessageText(payload);
if (!lastMessage) {
lastMessage = await fetchLastAssistantMessageText(sessionId, messageId);
}
variables.last_message = await prepareNotificationLastMessage({
message: lastMessage,
settings,
});
const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables);
const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables);
if (resolvedTitle) title = resolvedTitle;
if (shouldApplyResolvedTemplateMessage(completionTemplate.message, resolvedBody, variables)) body = resolvedBody;
} catch (error) {
console.warn('[Notification] Template resolution failed, using defaults:', error?.message || error);
}
if (settings.nativeNotificationsEnabled) {
const notificationPayload = {
title,
body,
tag: `ready-${sessionId}`,
kind: 'ready',
sessionId,
directory: notificationDirectory,
requireHidden: settings.notificationMode !== 'always',
};
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
}
await sendPushToAllUiSessions(
{
title,
body,
tag: `ready-${sessionId}`,
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
type: 'ready',
},
},
{ requireNoSse: true },
);
}
if (info?.role === 'assistant' && info?.finish === 'error' && sessionId) {
const settings = await readSettingsFromDisk();
if (settings.notifyOnError === false) return;
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
return;
}
let title = 'Tool error';
let body = 'An error occurred';
try {
const variables = await buildTemplateVariables(payload, sessionId);
const errorMessageId = info?.id;
let lastMessage = extractLastMessageText(payload);
if (!lastMessage) {
lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId);
}
variables.last_message = await prepareNotificationLastMessage({
message: lastMessage,
settings,
});
const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' };
const resolvedTitle = resolveNotificationTemplate(errorTemplate.title, variables);
const resolvedBody = resolveNotificationTemplate(errorTemplate.message, variables);
if (resolvedTitle) title = resolvedTitle;
if (shouldApplyResolvedTemplateMessage(errorTemplate.message, resolvedBody, variables)) body = resolvedBody;
} catch (error) {
console.warn('[Notification] Error template resolution failed, using defaults:', error?.message || error);
}
if (settings.nativeNotificationsEnabled) {
const notificationPayload = {
title,
body,
tag: `error-${sessionId}`,
kind: 'error',
sessionId,
directory: notificationDirectory,
requireHidden: settings.notificationMode !== 'always',
};
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
}
await sendPushToAllUiSessions(
{
title,
body,
tag: `error-${sessionId}`,
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
type: 'error',
},
},
{ requireNoSse: true },
);
}
return;
}
if (payload.type === 'question.asked' && sessionId) {
const existingTimer = pushQuestionDebounceTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(async () => {
pushQuestionDebounceTimers.delete(sessionId);
const settings = await readSettingsFromDisk();
if (settings.notifyOnQuestion === false) {
return;
}
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
return;
}
const firstQuestion = payload.properties?.questions?.[0];
const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : '';
const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : '';
let title = /plan\s*mode/i.test(header)
? 'Switch to plan mode'
: /build\s*agent/i.test(header)
? 'Switch to build mode'
: header || 'Input needed';
let body = questionText || 'Agent is waiting for your response';
try {
const variables = await buildTemplateVariables(payload, sessionId);
variables.last_message = questionText || header || '';
const templates = settings.notificationTemplates || {};
const questionTemplate = templates.question || { title: 'Input needed', message: '{last_message}' };
const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
if (resolvedTitle) title = resolvedTitle;
if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
} catch (error) {
console.warn('[Notification] Question template resolution failed, using defaults:', error?.message || error);
}
if (settings.nativeNotificationsEnabled) {
const notificationPayload = {
kind: 'question',
title,
body,
tag: `question-${sessionId}`,
sessionId,
directory: notificationDirectory,
requireHidden: settings.notificationMode !== 'always',
};
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
}
void sendPushToAllUiSessions(
{
title,
body,
tag: `question-${sessionId}`,
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
type: 'question',
},
},
{ requireNoSse: true },
);
}, PUSH_QUESTION_DEBOUNCE_MS);
pushQuestionDebounceTimers.set(sessionId, timer);
return;
}
if (payload.type === 'permission.replied' && sessionId) {
const requestId = payload.properties?.requestID ?? payload.properties?.requestId ?? payload.properties?.id;
const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null;
const pendingNotification = pushPermissionDebounceTimers.get(sessionId);
if (!pendingNotification) {
return;
}
// Some runtimes may omit requestID on permission.replied.
// When request ID is missing, clear session debounce to avoid
// showing stale permission notifications for auto-approved prompts.
if (!requestKey || !pendingNotification.requestKey || pendingNotification.requestKey === requestKey) {
clearTimeout(pendingNotification.timer);
pushPermissionDebounceTimers.delete(sessionId);
}
return;
}
if (payload.type === 'permission.asked' && sessionId) {
const requestId = payload.properties?.id ?? payload.properties?.requestID ?? payload.properties?.requestId;
const permission = payload.properties?.permission;
const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null;
if (requestKey && notifiedPermissionRequests.has(requestKey)) {
return;
}
// Client may be in Permission Auto-Accept for this session (or any
// ancestor). Skip the whole notification path — the client responds
// directly and the user has opted out of approval prompts.
if (await isSessionAutoAccepting(sessionId)) {
if (requestKey) notifiedPermissionRequests.add(requestKey);
return;
}
const existingTimer = pushPermissionDebounceTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer.timer);
}
const timer = setTimeout(async () => {
pushPermissionDebounceTimers.delete(sessionId);
if (await isSessionAutoAccepting(sessionId)) {
if (requestKey) notifiedPermissionRequests.add(requestKey);
return;
}
const settings = await readSettingsFromDisk();
if (settings.notifyOnQuestion === false) {
return;
}
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
return;
}
const sessionTitle = payload.properties?.sessionTitle;
const permissionText = typeof permission === 'string' && permission.length > 0 ? permission : '';
const fallbackMessage = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0
? sessionTitle.trim()
: permissionText || 'Agent is waiting for your approval';
let title = 'Permission required';
let body = fallbackMessage;
try {
const variables = await buildTemplateVariables(payload, sessionId);
variables.last_message = fallbackMessage;
const templates = settings.notificationTemplates || {};
const questionTemplate = templates.question || { title: 'Permission required', message: '{last_message}' };
const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
if (resolvedTitle) title = resolvedTitle;
if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
} catch (error) {
console.warn('[Notification] Permission template resolution failed, using defaults:', error?.message || error);
}
if (settings.nativeNotificationsEnabled) {
const notificationPayload = {
kind: 'permission',
title,
body,
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
sessionId,
directory: notificationDirectory,
requireHidden: settings.notificationMode !== 'always',
};
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
}
if (requestKey) {
notifiedPermissionRequests.add(requestKey);
}
void sendPushToAllUiSessions(
{
title,
body,
tag: `permission-${sessionId}`,
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
type: 'permission',
},
},
{ requireNoSse: true },
);
}, PUSH_PERMISSION_DEBOUNCE_MS);
pushPermissionDebounceTimers.set(sessionId, { timer, requestKey });
}
};
return {
maybeSendPushForTrigger,
setAutoAcceptSession,
setGetIsWindowFocused,
};
};