From 881e72d4a00ec9c1a5e1ae4799bffa900f27c1f8 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:31:13 +0300 Subject: [PATCH] fix: correct notification session id --- server/services/notification-orchestrator.js | 70 +++++++++++--- .../notification-orchestrator.test.js | 80 ++++++++++++++++ src/components/app/AppContent.tsx | 4 + .../chat/hooks/useChatComposerState.ts | 12 ++- src/components/chat/types/types.ts | 7 ++ src/components/chat/view/ChatInterface.tsx | 6 +- src/components/main-content/types/types.ts | 3 +- .../main-content/view/MainContent.tsx | 2 + src/hooks/useProjectsState.ts | 92 +++++++++++++++++++ 9 files changed, 254 insertions(+), 22 deletions(-) create mode 100644 server/services/notification-orchestrator.test.js diff --git a/server/services/notification-orchestrator.js b/server/services/notification-orchestrator.js index 43a7d058..f25fffb3 100644 --- a/server/services/notification-orchestrator.js +++ b/server/services/notification-orchestrator.js @@ -98,6 +98,44 @@ function normalizeSessionName(sessionName) { return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; } +function rowMatchesProvider(row, provider) { + return row && (!provider || row.provider === provider); +} + +function resolveSessionRow(sessionId, provider) { + if (!sessionId) { + return null; + } + + const appSessionRow = sessionsDb.getSessionById(sessionId); + if (rowMatchesProvider(appSessionRow, provider)) { + return appSessionRow; + } + + const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId); + if (rowMatchesProvider(providerSessionRow, provider)) { + return providerSessionRow; + } + + return null; +} + +function normalizeNotificationSession(event) { + if (!event?.sessionId || !event.provider || event.provider === 'system') { + return event; + } + + const row = resolveSessionRow(event.sessionId, event.provider); + if (!row || row.session_id === event.sessionId) { + return event; + } + + return { + ...event, + sessionId: row.session_id + }; +} + function resolveSessionName(event) { const explicitSessionName = normalizeSessionName(event.meta?.sessionName); if (explicitSessionName) { @@ -112,28 +150,29 @@ function resolveSessionName(event) { } function buildPushBody(event) { + const normalizedEvent = normalizeNotificationSession(event); const CODE_MAP = { - 'permission.required': event.meta?.toolName - ? `Action Required: Tool "${event.meta.toolName}" needs approval` + 'permission.required': normalizedEvent.meta?.toolName + ? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval` : 'Action Required: A tool needs your approval', - 'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped', - 'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error', - 'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification', + 'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped', + 'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error', + 'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification', 'push.enabled': 'Push notifications are now enabled!' }; - const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant'; - const sessionName = resolveSessionName(event); - const message = CODE_MAP[event.code] || 'You have a new notification'; + const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant'; + const sessionName = resolveSessionName(normalizedEvent); + const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification'; return { title: sessionName || 'CloudCLI', body: `${providerLabel}: ${message}`, data: { - sessionId: event.sessionId || null, - code: event.code, - provider: event.provider || null, + sessionId: normalizedEvent.sessionId || null, + code: normalizedEvent.code, + provider: normalizedEvent.provider || null, sessionName, - tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}` + tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}` } }; } @@ -175,15 +214,16 @@ function notifyUserIfEnabled({ userId, event }) { return; } + const normalizedEvent = normalizeNotificationSession(event); const preferences = notificationPreferencesDb.getPreferences(userId); - if (!shouldSendPush(preferences, event)) { + if (!shouldSendPush(preferences, normalizedEvent)) { return; } - if (isDuplicate(event)) { + if (isDuplicate(normalizedEvent)) { return; } - sendWebPush(userId, event).catch((err) => { + sendWebPush(userId, normalizedEvent).catch((err) => { console.error('Web push send error:', err); }); } diff --git a/server/services/notification-orchestrator.test.js b/server/services/notification-orchestrator.test.js new file mode 100644 index 00000000..d470765f --- /dev/null +++ b/server/services/notification-orchestrator.test.js @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import webPush from 'web-push'; + +import { + closeConnection, + initializeDatabase, + notificationPreferencesDb, + pushSubscriptionsDb, + sessionsDb, + userDb, +} from '../modules/database/index.js'; + +import { notifyRunStopped } from './notification-orchestrator.js'; + +async function withIsolatedDatabase(runTest) { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'notification-orchestrator-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +test('push payload uses the app session id when notified with a provider session id', async () => { + const originalSendNotification = webPush.sendNotification; + const sentPayloads = []; + + webPush.sendNotification = async (_subscription, payload) => { + sentPayloads.push(JSON.parse(payload)); + return {}; + }; + + try { + await withIsolatedDatabase(async () => { + const user = userDb.createUser('notify-user', 'hash'); + const userId = Number(user.id); + + notificationPreferencesDb.updatePreferences(userId, { + channels: { webPush: true }, + events: { actionRequired: true, stop: true, error: true }, + }); + pushSubscriptionsDb.saveSubscription(userId, 'https://example.test/push', 'p256dh', 'auth'); + sessionsDb.createAppSession('app-session-1', 'claude', '/workspace/demo'); + sessionsDb.assignProviderSessionId('app-session-1', 'claude-native-1'); + + notifyRunStopped({ + userId, + provider: 'claude', + sessionId: 'claude-native-1', + stopReason: 'completed', + }); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(sentPayloads.length, 1); + assert.equal(sentPayloads[0]?.data?.sessionId, 'app-session-1'); + assert.match(sentPayloads[0]?.data?.tag, /app-session-1/); + }); + } finally { + webPush.sendNotification = originalSendNotification; + } +}); diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 0e39c956..f4776c88 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -46,6 +46,7 @@ function AppContentInner() { setShowSettings, openSettings, refreshProjectsSilently, + registerOptimisticSession, sidebarSharedProps, handleNewSession, } = useProjectsState({ @@ -172,6 +173,9 @@ function AppContentInner() { onNavigateToSession={(targetSessionId: string, options) => navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) }) } + onSessionEstablished={(targetSessionId, context) => + registerOptimisticSession({ sessionId: targetSessionId, ...context }) + } onShowSettings={() => setShowSettings(true)} externalMessageUpdate={externalMessageUpdate} newSessionTrigger={newSessionTrigger} diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index ed0a4962..c1f86f2d 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -19,6 +19,7 @@ import type { ChatMessage, PendingPermissionRequest, PermissionMode, + SessionEstablishedContext, } from '../types/types'; import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app'; import { escapeRegExp } from '../utils/chatFormatting'; @@ -51,7 +52,7 @@ interface UseChatComposerStateArgs { * stable for the conversation's whole lifetime — the consumer navigates to * /session/:id and records it as the current session. */ - onSessionEstablished?: (sessionId: string) => void; + onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onInputFocusChange?: (focused: boolean) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onShowSettings?: () => void; @@ -606,6 +607,7 @@ export function useChatComposerState({ } const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; + const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); // The conversation always has a stable backend-allocated session id // BEFORE the first websocket send: brand-new chats allocate one here @@ -646,7 +648,11 @@ export function useChatComposerState({ return; } - onSessionEstablished?.(targetSessionId); + onSessionEstablished?.(targetSessionId, { + provider, + project: selectedProject, + summary: sessionSummary, + }); } const userMessage: ChatMessage = { @@ -696,8 +702,6 @@ export function useChatComposerState({ }; const toolsSettings = getToolsSettings(); - const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); - const model = provider === 'cursor' ? cursorModel diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index a8cde669..60ee18fd 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -107,6 +107,12 @@ export type SessionNavigationOptions = { replace?: boolean; }; +export type SessionEstablishedContext = { + provider: LLMProvider; + project: Project; + summary?: string | null; +}; + export interface ChatInterfaceProps { selectedProject: Project | null; selectedSession: ProjectSession | null; @@ -118,6 +124,7 @@ export interface ChatInterfaceProps { onSessionIdle?: MarkSessionIdle; processingSessions?: SessionActivityMap; onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void; + onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onShowSettings?: () => void; autoExpandTools?: boolean; showRawParameters?: boolean; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 7346f1c2..694c4d60 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -29,6 +29,7 @@ function ChatInterface({ onSessionIdle, processingSessions, onNavigateToSession, + onSessionEstablished, onShowSettings, autoExpandTools, showRawParameters, @@ -138,10 +139,11 @@ function ChatInterface({ // Brand-new conversation: the composer allocated a stable session id via // the session gateway before the first send. Record it locally and put it // in the URL — this id never changes again, so there is no later handoff. - const handleSessionEstablished = useCallback((sessionId: string) => { + const handleSessionEstablished = useCallback>((sessionId, context) => { setCurrentSessionId(sessionId); + onSessionEstablished?.(sessionId, context); onNavigateToSession?.(sessionId); - }, [setCurrentSessionId, onNavigateToSession]); + }, [setCurrentSessionId, onSessionEstablished, onNavigateToSession]); const { input, diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index 822de23b..a7398795 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -6,7 +6,7 @@ import type { MarkSessionProcessing, SessionActivityMap, } from '../../../hooks/useSessionProtection'; -import type { SessionNavigationOptions } from '../../chat/types/types'; +import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types'; export type TaskMasterTask = { id: string | number; @@ -52,6 +52,7 @@ export type MainContentProps = { onSessionIdle: MarkSessionIdle; processingSessions: SessionActivityMap; onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void; + onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void; onShowSettings: () => void; externalMessageUpdate: number; newSessionTrigger: number; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index e5682a1e..c085daee 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -45,6 +45,7 @@ function MainContent({ onSessionIdle, processingSessions, onNavigateToSession, + onSessionEstablished, onShowSettings, externalMessageUpdate, newSessionTrigger, @@ -131,6 +132,7 @@ function MainContent({ onSessionIdle={onSessionIdle} processingSessions={processingSessions} onNavigateToSession={onNavigateToSession} + onSessionEstablished={onSessionEstablished} onShowSettings={onShowSettings} autoExpandTools={autoExpandTools} showRawParameters={showRawParameters} diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 44d3af45..19ae2fe7 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -44,6 +44,13 @@ type FetchProjectsOptions = { showLoadingState?: boolean; }; +type RegisterOptimisticSessionArgs = { + sessionId: string; + provider: LLMProvider; + project: Project; + summary?: string | null; +}; + const serialize = (value: unknown) => JSON.stringify(value ?? null); const projectsHaveChanges = ( @@ -258,6 +265,21 @@ const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent) return next; }; +const projectFromRegistration = (project: Project): Project => ({ + projectId: project.projectId, + path: project.path || project.fullPath, + fullPath: project.fullPath || project.path || '', + displayName: project.displayName, + isStarred: project.isStarred, + sessions: project.sessions ?? [], + cursorSessions: project.cursorSessions ?? [], + codexSessions: project.codexSessions ?? [], + geminiSessions: project.geminiSessions ?? [], + opencodeSessions: project.opencodeSessions ?? [], + sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) }, + taskmaster: project.taskmaster, +}); + const VALID_TABS: Set = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']); const isValidTab = (tab: string): tab is AppTab => { @@ -373,6 +395,75 @@ export function useProjectsState({ await fetchProjects({ showLoadingState: false }); }, [fetchProjects]); + const registerOptimisticSession = useCallback(({ + sessionId: newSessionId, + provider, + project, + summary, + }: RegisterOptimisticSessionArgs) => { + if (!newSessionId || !project?.projectId) { + return; + } + + const now = new Date().toISOString(); + const optimisticSession: ProjectSession = { + id: newSessionId, + summary: summary ?? '', + messageCount: 0, + createdAt: now, + created_at: now, + updated_at: now, + lastActivity: now, + __provider: provider, + __projectId: project.projectId, + }; + const upsert: SessionUpsertedEvent = { + kind: 'session_upserted', + sessionId: newSessionId, + provider, + session: optimisticSession, + project: { + projectId: project.projectId, + path: project.path || project.fullPath, + fullPath: project.fullPath || project.path || '', + displayName: project.displayName, + isStarred: Boolean(project.isStarred), + }, + timestamp: now, + }; + + setProjects((previousProjects) => { + const existingProject = previousProjects.find((candidate) => candidate.projectId === project.projectId); + if (!existingProject) { + return [upsertSessionIntoProject(projectFromRegistration(project), upsert), ...previousProjects]; + } + + const updatedProject = upsertSessionIntoProject(existingProject, upsert); + if (updatedProject === existingProject) { + return previousProjects; + } + + return previousProjects.map((candidate) => + candidate.projectId === existingProject.projectId ? updatedProject : candidate, + ); + }); + + setSelectedProject((previousProject) => { + if (!previousProject || previousProject.projectId !== project.projectId) { + return previousProject; + } + + const updatedProject = upsertSessionIntoProject(previousProject, upsert); + return updatedProject === previousProject ? previousProject : updatedProject; + }); + + setSelectedSession((previousSession) => ( + previousSession?.id === newSessionId + ? { ...previousSession, ...optimisticSession } + : optimisticSession + )); + }, []); + // Hydrates TaskMaster details for the given `projectId`. The project // identifier comes directly from the DB-driven /api/projects response. const hydrateProjectTaskMaster = useCallback(async (projectId: string) => { @@ -950,6 +1041,7 @@ export function useProjectsState({ openSettings, fetchProjects, refreshProjectsSilently, + registerOptimisticSession, sidebarSharedProps, handleProjectSelect, handleSessionSelect,