fix(notifications): suppress permission notifs when session auto-accepts

Server now mirrors client-side Permission Auto-Accept via
POST /api/notifications/auto-accept and short-circuits permission.asked
dispatch (walking the session parent chain). Prior 500ms debounce raced
the client auto-response and leaked notifications.

Also hint under Summarize Last Message that templates must contain
{last_message} for the setting to take effect.
This commit is contained in:
Bohdan Triapitsyn
2026-04-22 19:22:50 +03:00
parent 1ad64cc69e
commit a2730b793e
7 changed files with 91 additions and 0 deletions
@@ -786,6 +786,11 @@ export const NotificationSettings: React.FC = () => {
/> />
<span className="typography-ui-label text-foreground">Summarize Last Message</span> <span className="typography-ui-label text-foreground">Summarize Last Message</span>
</div> </div>
<div className="pl-6 pb-1">
<span className="typography-meta text-muted-foreground">
Requires <code className="text-[var(--primary-base)]">{'{last_message}'}</code> in the notification template.
</span>
</div>
<div className={cn("flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8")}> <div className={cn("flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8")}>
<div className="flex min-w-0 flex-col sm:w-56 shrink-0"> <div className="flex min-w-0 flex-col sm:w-56 shrink-0">
+24
View File
@@ -169,6 +169,15 @@ export const usePermissionStore = create<PermissionStore>()(
return { autoAccept }; return { autoAccept };
}); });
// Mirror state to the server so it can suppress permission
// notifications at the source (otherwise the 500ms debounce
// races with the client's auto-response and can leak).
void fetch('/api/notifications/auto-accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, enabled }),
}).catch(() => { /* best-effort */ });
if (!enabled) { if (!enabled) {
return; return;
} }
@@ -260,6 +269,21 @@ export const usePermissionStore = create<PermissionStore>()(
autoAccept: nextAutoAccept, autoAccept: nextAutoAccept,
}; };
}, },
onRehydrateStorage: () => (state) => {
if (!state) return;
// Re-broadcast auto-accept state to the server after
// rehydration so server-side notification suppression
// survives page reloads / server restarts.
for (const [sid, enabled] of Object.entries(state.autoAccept || {})) {
if (enabled === true) {
void fetch('/api/notifications/auto-accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: sid, enabled: true }),
}).catch(() => { /* best-effort */ });
}
}
},
} }
), ),
{ name: "permission-store" } { name: "permission-store" }
+2
View File
@@ -646,6 +646,7 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({
}); });
const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args); const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args);
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({ const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
waitForOpenCodePort: (...args) => waitForOpenCodePort(...args), waitForOpenCodePort: (...args) => waitForOpenCodePort(...args),
@@ -1098,6 +1099,7 @@ async function main(options = {}) {
modelsMetadataCacheTtl: MODELS_METADATA_CACHE_TTL, modelsMetadataCacheTtl: MODELS_METADATA_CACHE_TTL,
fetchFreeZenModels, fetchFreeZenModels,
getCachedZenModels, getCachedZenModels,
setAutoAcceptSession,
}); });
uiAuthController = bootstrapResult.uiAuthController; uiAuthController = bootstrapResult.uiAuthController;
@@ -46,6 +46,7 @@ export const registerNotificationRoutes = (app, dependencies) => {
markSessionUnviewed, markSessionUnviewed,
markUserMessageSent, markUserMessageSent,
setPushInitialized, setPushInitialized,
setAutoAcceptSession,
} = dependencies; } = dependencies;
const ensureSessionWatcher = async () => { const ensureSessionWatcher = async () => {
@@ -294,4 +295,20 @@ export const registerNotificationRoutes = (app, dependencies) => {
messageSent: true, messageSent: true,
}); });
}); });
// Mirror client-side Permission Auto-Accept state to the server so it can
// suppress permission notifications at the source (the 500ms debounce race
// otherwise leaks notifications for auto-accepted permissions).
app.post('/api/notifications/auto-accept', (req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
const enabled = body.enabled === true;
if (!sessionId) {
return res.status(400).json({ error: 'sessionId required' });
}
if (typeof setAutoAcceptSession === 'function') {
setAutoAcceptSession(sessionId, enabled);
}
return res.json({ success: true, sessionId, enabled });
});
}; };
@@ -27,6 +27,21 @@ export const createNotificationTriggerRuntime = (deps) => {
const sessionParentIdCache = new Map(); const sessionParentIdCache = new Map();
const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000; 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) => { const buildSessionDeepLinkUrl = (sessionId) => {
if (!sessionId || typeof sessionId !== 'string') { if (!sessionId || typeof sessionId !== 'string') {
return '/'; return '/';
@@ -80,6 +95,22 @@ export const createNotificationTriggerRuntime = (deps) => {
} }
}; };
// 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) => { const extractSessionIdFromPayload = (payload) => {
if (!payload || typeof payload !== 'object') return null; if (!payload || typeof payload !== 'object') return null;
const props = payload.properties; const props = payload.properties;
@@ -390,6 +421,14 @@ export const createNotificationTriggerRuntime = (deps) => {
return; 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); const existingTimer = pushPermissionDebounceTimers.get(sessionId);
if (existingTimer) { if (existingTimer) {
clearTimeout(existingTimer.timer); clearTimeout(existingTimer.timer);
@@ -473,5 +512,6 @@ export const createNotificationTriggerRuntime = (deps) => {
return { return {
maybeSendPushForTrigger, maybeSendPushForTrigger,
setAutoAcceptSession,
}; };
}; };
+2
View File
@@ -47,6 +47,7 @@ export const createBootstrapRuntime = (dependencies) => {
modelsMetadataCacheTtl, modelsMetadataCacheTtl,
fetchFreeZenModels, fetchFreeZenModels,
getCachedZenModels, getCachedZenModels,
setAutoAcceptSession,
} = options; } = options;
registerServerStatusRoutes(app, { registerServerStatusRoutes(app, {
@@ -100,6 +101,7 @@ export const createBootstrapRuntime = (dependencies) => {
markSessionUnviewed: sessionRuntime.markSessionUnviewed, markSessionUnviewed: sessionRuntime.markSessionUnviewed,
markUserMessageSent: sessionRuntime.markUserMessageSent, markUserMessageSent: sessionRuntime.markUserMessageSent,
setPushInitialized, setPushInitialized,
setAutoAcceptSession,
}); });
registerOpenChamberRoutes(app, { registerOpenChamberRoutes(app, {
@@ -260,6 +260,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
req.path.startsWith('/api/terminal') || req.path.startsWith('/api/terminal') ||
req.path.startsWith('/api/opencode') || req.path.startsWith('/api/opencode') ||
req.path.startsWith('/api/push') || req.path.startsWith('/api/push') ||
req.path.startsWith('/api/notifications') ||
req.path.startsWith('/api/text') || req.path.startsWith('/api/text') ||
req.path.startsWith('/api/voice') || req.path.startsWith('/api/voice') ||
req.path.startsWith('/api/tts') || req.path.startsWith('/api/tts') ||