diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 2e6a2adb..cfbc5f28 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -9,6 +9,7 @@ import os from 'os'; import crypto from 'crypto'; import { createUiAuth } from './lib/ui-auth.js'; import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js'; +import { prepareNotificationLastMessage } from './lib/notification-message.js'; import { TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, TERMINAL_INPUT_WS_PATH, @@ -571,6 +572,22 @@ const resolveNotificationTemplate = (template, variables) => { }); }; +const shouldApplyResolvedTemplateMessage = (template, resolved, variables) => { + if (!resolved) { + return false; + } + + if (typeof template !== 'string') { + return true; + } + + if (template.includes('{last_message}')) { + return typeof variables?.last_message === 'string' && variables.last_message.trim().length > 0; + } + + return true; +}; + const summarizeText = async (text, targetLength) => { if (!text || typeof text !== 'string' || text.trim().length === 0) return text; @@ -678,7 +695,10 @@ const fetchLastAssistantMessageText = async (sessionId, messageId, maxLength = N const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, ''); const response = await fetch(`${url}?limit=5`, { method: 'GET', - headers: { Accept: 'application/json' }, + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, signal: AbortSignal.timeout(3000), }); @@ -3856,23 +3876,16 @@ const maybeSendPushForTrigger = async (payload) => { lastMessage = await fetchLastAssistantMessageText(sessionId, messageId); } - // Summarize if enabled and above threshold, otherwise truncate to maxLastMessageLength - if (settings.summarizeLastMessage && lastMessage.length > (settings.summaryThreshold || 200)) { - lastMessage = await summarizeText(lastMessage, settings.summaryLength || 100); - } else { - const maxLen = typeof settings.maxLastMessageLength === 'number' && settings.maxLastMessageLength > 0 - ? settings.maxLastMessageLength - : 250; - if (lastMessage.length > maxLen) { - lastMessage = lastMessage.slice(0, maxLen) + '...'; - } - } - variables.last_message = lastMessage; + variables.last_message = await prepareNotificationLastMessage({ + message: lastMessage, + settings, + summarize: summarizeText, + }); const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables); const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables); if (resolvedTitle) title = resolvedTitle; - if (resolvedBody) body = resolvedBody; + if (shouldApplyResolvedTemplateMessage(completionTemplate.message, resolvedBody, variables)) body = resolvedBody; } catch (err) { console.warn('[Notification] Template resolution failed, using defaults:', err?.message || err); } @@ -3923,24 +3936,17 @@ const maybeSendPushForTrigger = async (payload) => { lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId); } - // Summarize if enabled and above threshold, otherwise truncate to maxLastMessageLength - if (settings.summarizeLastMessage && lastMessage.length > (settings.summaryThreshold || 200)) { - lastMessage = await summarizeText(lastMessage, settings.summaryLength || 100); - } else { - const maxLen = typeof settings.maxLastMessageLength === 'number' && settings.maxLastMessageLength > 0 - ? settings.maxLastMessageLength - : 250; - if (lastMessage.length > maxLen) { - lastMessage = lastMessage.slice(0, maxLen) + '...'; - } - } - variables.last_message = lastMessage; + variables.last_message = await prepareNotificationLastMessage({ + message: lastMessage, + settings, + summarize: summarizeText, + }); 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 (resolvedBody) body = resolvedBody; + if (shouldApplyResolvedTemplateMessage(errorTemplate.message, resolvedBody, variables)) body = resolvedBody; } catch (err) { console.warn('[Notification] Error template resolution failed, using defaults:', err?.message || err); } @@ -4022,7 +4028,7 @@ const maybeSendPushForTrigger = async (payload) => { const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables); const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables); if (resolvedTitle) title = resolvedTitle; - if (resolvedBody) body = resolvedBody; + if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody; } catch (err) { console.warn('[Notification] Question template resolution failed, using defaults:', err?.message || err); } @@ -4114,7 +4120,7 @@ const maybeSendPushForTrigger = async (payload) => { const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables); const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables); if (resolvedTitle) title = resolvedTitle; - if (resolvedBody) body = resolvedBody; + if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody; } catch (err) { console.warn('[Notification] Permission template resolution failed, using defaults:', err?.message || err); } diff --git a/packages/web/server/lib/notification-message.js b/packages/web/server/lib/notification-message.js new file mode 100644 index 00000000..98224222 --- /dev/null +++ b/packages/web/server/lib/notification-message.js @@ -0,0 +1,49 @@ +const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250; +const DEFAULT_NOTIFICATION_SUMMARY_THRESHOLD = 200; +const DEFAULT_NOTIFICATION_SUMMARY_LENGTH = 100; + +const resolvePositiveNumber = (value, fallback) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return fallback; + } + return value; +}; + +export const truncateNotificationText = (text, maxLength = DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH) => { + if (typeof text !== 'string') { + return ''; + } + + const safeMaxLength = resolvePositiveNumber(maxLength, DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH); + if (text.length <= safeMaxLength) { + return text; + } + + return `${text.slice(0, safeMaxLength)}...`; +}; + +export const prepareNotificationLastMessage = async ({ message, settings, summarize }) => { + const originalMessage = typeof message === 'string' ? message : ''; + if (!originalMessage) { + return ''; + } + + const shouldSummarize = settings?.summarizeLastMessage === true && typeof summarize === 'function'; + const summaryThreshold = resolvePositiveNumber(settings?.summaryThreshold, DEFAULT_NOTIFICATION_SUMMARY_THRESHOLD); + const summaryLength = resolvePositiveNumber(settings?.summaryLength, DEFAULT_NOTIFICATION_SUMMARY_LENGTH); + const maxLastMessageLength = resolvePositiveNumber(settings?.maxLastMessageLength, DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH); + + let messageForNotification = originalMessage; + if (shouldSummarize && originalMessage.length > summaryThreshold) { + try { + const summary = await summarize(originalMessage, summaryLength); + if (typeof summary === 'string' && summary.trim().length > 0) { + messageForNotification = summary; + } + } catch { + messageForNotification = originalMessage; + } + } + + return truncateNotificationText(messageForNotification, maxLastMessageLength); +}; diff --git a/packages/web/server/lib/notification-message.test.js b/packages/web/server/lib/notification-message.test.js new file mode 100644 index 00000000..16201db1 --- /dev/null +++ b/packages/web/server/lib/notification-message.test.js @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'bun:test'; + +import { prepareNotificationLastMessage, truncateNotificationText } from './notification-message.js'; + +describe('notification message helpers', () => { + it('truncates oversized notification text', () => { + expect(truncateNotificationText('abcdef', 3)).toBe('abc...'); + }); + + it('falls back to original message when summarization fails', async () => { + const message = '0123456789'; + const summarize = async () => { + throw new Error('summarization failed'); + }; + + const result = await prepareNotificationLastMessage({ + message, + summarize, + settings: { + summarizeLastMessage: true, + summaryThreshold: 5, + summaryLength: 3, + maxLastMessageLength: 4, + }, + }); + + expect(result).toBe('0123...'); + }); + + it('falls back to original message when summary is empty', async () => { + const result = await prepareNotificationLastMessage({ + message: '0123456789', + summarize: async () => ' ', + settings: { + summarizeLastMessage: true, + summaryThreshold: 5, + summaryLength: 3, + maxLastMessageLength: 4, + }, + }); + + expect(result).toBe('0123...'); + }); + + it('uses summary when summarization succeeds', async () => { + const result = await prepareNotificationLastMessage({ + message: '0123456789', + summarize: async () => 'short summary', + settings: { + summarizeLastMessage: true, + summaryThreshold: 5, + summaryLength: 3, + maxLastMessageLength: 100, + }, + }); + + expect(result).toBe('short summary'); + }); +});