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
This commit is contained in:
Bohdan Triapitsyn
2026-06-16 15:21:27 +03:00
parent 6ea8fb357d
commit 91de51d1a5
7 changed files with 144 additions and 46 deletions
+9 -8
View File
@@ -326,11 +326,12 @@ type UiNotificationPayload = {
body?: unknown body?: unknown
tag?: unknown tag?: unknown
kind?: unknown kind?: unknown
sessionId?: unknown sessionId?: unknown
directory?: unknown directory?: unknown
requireHidden?: unknown requireHidden?: unknown
desktopStdoutActive?: unknown desktopNotificationDelivered?: unknown
} desktopStdoutActive?: unknown
}
const asOptionalString = (value: unknown): string | undefined => { const asOptionalString = (value: unknown): string | undefined => {
if (typeof value !== "string") return undefined if (typeof value !== "string") return undefined
@@ -349,9 +350,9 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
} }
const notification = properties as UiNotificationPayload const notification = properties as UiNotificationPayload
if (notification.desktopStdoutActive === true && getRuntimeKey() === "local") { if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
return true return true
} }
const notifications = getRegisteredRuntimeAPIs()?.notifications const notifications = getRegisteredRuntimeAPIs()?.notifications
if (!notifications?.notifyAgentCompletion) { if (!notifications?.notifyAgentCompletion) {
+1 -1
View File
@@ -1520,7 +1520,7 @@ const extractNotificationTextFromParts = (parts: unknown): string => {
.map((part) => { .map((part) => {
if (!part || typeof part !== 'object') return ''; if (!part || typeof part !== 'object') return '';
const entry = part as { type?: unknown; text?: unknown; content?: unknown }; const entry = part as { type?: unknown; text?: unknown; content?: unknown };
if (entry.type === 'text' || typeof entry.text === 'string' || typeof entry.content === 'string') { if (entry.type === 'text') {
return typeof entry.text === 'string' ? entry.text : typeof entry.content === 'string' ? entry.content : ''; return typeof entry.text === 'string' ? entry.text : typeof entry.content === 'string' ? entry.content : '';
} }
return ''; return '';
@@ -29,43 +29,51 @@ export const createNotificationEmitterRuntime = (dependencies) => {
const emitDesktopNotification = (payload) => { const emitDesktopNotification = (payload) => {
const desktopNotifyEnabled = getDesktopNotifyEnabled(); const desktopNotifyEnabled = getDesktopNotifyEnabled();
if (!desktopNotifyEnabled) { if (!desktopNotifyEnabled) {
return; return false;
} }
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
return; return false;
} }
if (onDesktopNotification) { if (onDesktopNotification) {
try { try {
onDesktopNotification(payload); onDesktopNotification(payload);
return true;
} catch { } catch {
// ignore host-side throw // ignore host-side throw
} }
return; return false;
} }
try { try {
// stdout fallback for runtimes that parse the one-line `${prefix}{json}` protocol. // stdout fallback for runtimes that parse the one-line `${prefix}{json}` protocol.
process.stdout.write(`${desktopNotifyPrefix}${JSON.stringify(payload)}\n`); process.stdout.write(`${desktopNotifyPrefix}${JSON.stringify(payload)}\n`);
return true;
} catch { } catch {
// ignore // ignore
} }
return false;
}; };
const broadcastUiNotification = (payload) => { const broadcastUiNotification = (payload, options = {}) => {
const desktopNotifyEnabled = getDesktopNotifyEnabled(); const desktopNotifyEnabled = getDesktopNotifyEnabled();
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
return; return;
} }
const desktopNotificationDelivered = options.desktopNotificationDelivered === true;
const syntheticPayload = { const syntheticPayload = {
type: 'openchamber:notification', type: 'openchamber:notification',
properties: { properties: {
...payload, ...payload,
// Tell the UI whether the stdout notification channel is active. // Tell local desktop UI whether a native channel already accepted this
// When true, the desktop UI should skip this SSE notification to avoid duplicates. // notification. If so, the SSE/WS event is informational only and must
// When false, the UI must handle this SSE notification itself. // not create a second OS notification.
desktopNotificationDelivered,
// Legacy marker retained for older clients that only know about stdout.
desktopStdoutActive: desktopNotifyEnabled, desktopStdoutActive: desktopNotifyEnabled,
}, },
}; };
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { createNotificationEmitterRuntime } from './emitter-runtime.js';
const createRuntime = (overrides = {}) => createNotificationEmitterRuntime({
process: { stdout: { write: vi.fn() } },
getDesktopNotifyEnabled: () => true,
desktopNotifyPrefix: '[desktop-notify]',
getUiNotificationClients: () => new Set(),
getBroadcastGlobalUiEvent: () => null,
...overrides,
});
describe('notification emitter runtime', () => {
it('reports desktop delivery through the injected native callback', () => {
const onDesktopNotification = vi.fn();
const runtime = createRuntime({ onDesktopNotification });
const payload = { title: 'Ready', body: 'Done' };
expect(runtime.emitDesktopNotification(payload)).toBe(true);
expect(onDesktopNotification).toHaveBeenCalledWith(payload);
});
it('reports stdout desktop delivery for legacy shells', () => {
const write = vi.fn();
const runtime = createRuntime({ process: { stdout: { write } } });
expect(runtime.emitDesktopNotification({ title: 'Ready' })).toBe(true);
expect(write).toHaveBeenCalledWith('[desktop-notify]{"title":"Ready"}\n');
});
it('marks UI broadcasts that were already delivered natively', () => {
const broadcastGlobalUiEvent = vi.fn();
const runtime = createRuntime({ getBroadcastGlobalUiEvent: () => broadcastGlobalUiEvent });
runtime.broadcastUiNotification({ title: 'Ready' }, { desktopNotificationDelivered: true });
expect(broadcastGlobalUiEvent).toHaveBeenCalledWith({
type: 'openchamber:notification',
properties: {
title: 'Ready',
desktopNotificationDelivered: true,
desktopStdoutActive: true,
},
});
});
});
@@ -279,8 +279,8 @@ export const createNotificationTriggerRuntime = (deps) => {
directory: notificationDirectory, directory: notificationDirectory,
requireHidden: settings.notificationMode !== 'always', requireHidden: settings.notificationMode !== 'always',
}; };
emitDesktopNotification(notificationPayload); const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
broadcastUiNotification(notificationPayload); broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
} }
await sendPushToAllUiSessions( await sendPushToAllUiSessions(
@@ -341,8 +341,8 @@ export const createNotificationTriggerRuntime = (deps) => {
directory: notificationDirectory, directory: notificationDirectory,
requireHidden: settings.notificationMode !== 'always', requireHidden: settings.notificationMode !== 'always',
}; };
emitDesktopNotification(notificationPayload); const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
broadcastUiNotification(notificationPayload); broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
} }
await sendPushToAllUiSessions( await sendPushToAllUiSessions(
@@ -408,7 +408,7 @@ export const createNotificationTriggerRuntime = (deps) => {
} }
if (settings.nativeNotificationsEnabled) { if (settings.nativeNotificationsEnabled) {
emitDesktopNotification({ const notificationPayload = {
kind: 'question', kind: 'question',
title, title,
body, body,
@@ -416,17 +416,9 @@ export const createNotificationTriggerRuntime = (deps) => {
sessionId, sessionId,
directory: notificationDirectory, directory: notificationDirectory,
requireHidden: settings.notificationMode !== 'always', requireHidden: settings.notificationMode !== 'always',
}); };
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
broadcastUiNotification({ broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
kind: 'question',
title,
body,
tag: `question-${sessionId}`,
sessionId,
directory: notificationDirectory,
requireHidden: settings.notificationMode !== 'always',
});
} }
void sendPushToAllUiSessions( void sendPushToAllUiSessions(
@@ -530,7 +522,7 @@ export const createNotificationTriggerRuntime = (deps) => {
} }
if (settings.nativeNotificationsEnabled) { if (settings.nativeNotificationsEnabled) {
emitDesktopNotification({ const notificationPayload = {
kind: 'permission', kind: 'permission',
title, title,
body, body,
@@ -538,17 +530,9 @@ export const createNotificationTriggerRuntime = (deps) => {
sessionId, sessionId,
directory: notificationDirectory, directory: notificationDirectory,
requireHidden: settings.notificationMode !== 'always', requireHidden: settings.notificationMode !== 'always',
}); };
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
broadcastUiNotification({ broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
kind: 'permission',
title,
body,
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
sessionId,
directory: notificationDirectory,
requireHidden: settings.notificationMode !== 'always',
});
} }
if (requestKey) { if (requestKey) {
@@ -84,11 +84,17 @@ export const createNotificationTemplateRuntime = (deps) => {
: text; : text;
}; };
const isNotificationTextPart = (part) => {
if (!part || typeof part !== 'object') return false;
if (part.type !== 'text') return false;
return typeof part.text === 'string' || typeof part.content === 'string';
};
const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
if (!Array.isArray(parts) || parts.length === 0) return ''; if (!Array.isArray(parts) || parts.length === 0) return '';
const textParts = parts const textParts = parts
.filter((part) => part && (part.type === 'text' || typeof part.text === 'string' || typeof part.content === 'string')) .filter(isNotificationTextPart)
.map((part) => part.text || part.content || '') .map((part) => part.text || part.content || '')
.filter(Boolean); .filter(Boolean);
@@ -112,7 +118,7 @@ export const createNotificationTemplateRuntime = (deps) => {
const content = info.content; const content = info.content;
if (Array.isArray(content)) { if (Array.isArray(content)) {
const textContent = content const textContent = content
.filter((entry) => entry && (entry.type === 'text' || typeof entry.text === 'string')) .filter(isNotificationTextPart)
.map((entry) => entry.text || '') .map((entry) => entry.text || '')
.filter(Boolean); .filter(Boolean);
if (textContent.length > 0) { if (textContent.length > 0) {
@@ -30,3 +30,55 @@ describe('notification template runtime zen models', () => {
await expect(runtime.resolveZenModel()).resolves.toBe('trinity-large-preview-free'); await expect(runtime.resolveZenModel()).resolves.toBe('trinity-large-preview-free');
}); });
}); });
describe('notification template message extraction', () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('excludes reasoning parts from payload message text', () => {
const runtime = createRuntime();
expect(runtime.extractLastMessageText({
properties: {
info: {
parts: [
{ type: 'reasoning', text: 'private chain of thought' },
{ type: 'text', text: 'final answer' },
],
},
},
})).toBe('final answer');
});
it('ignores untyped parts even when they contain text', () => {
const runtime = createRuntime();
expect(runtime.extractLastMessageText({
properties: {
info: {
parts: [
{ text: 'untyped text' },
{ content: 'untyped content' },
{ type: 'text', text: 'typed final answer' },
],
},
},
})).toBe('typed final answer');
});
it('excludes reasoning parts when fetching assistant messages', async () => {
const runtime = createRuntime();
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify([
{
info: { id: 'msg-1', role: 'assistant', finish: 'stop' },
parts: [
{ type: 'reasoning', text: 'private chain of thought' },
{ type: 'text', text: 'final answer' },
],
},
])));
await expect(runtime.fetchLastAssistantMessageText('session-1', 'msg-1')).resolves.toBe('final answer');
});
});