From aa5c18bb29d683594a857189de46ee4ab58386b7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 18 Dec 2025 19:26:34 -0500 Subject: [PATCH] fix(stream): detect early abort and prevent navigation to non-existent conversation - Abort controller on job completion to signal pending operations - Detect early abort (no content, no responseMessageId) in abortJob - Set conversation and responseMessage to null for early aborts - Add earlyAbort flag to final event for frontend detection - Remove unused text field from AbortResult interface - Frontend handles earlyAbort by staying on/navigating to new chat --- client/src/hooks/SSE/useEventHandlers.ts | 15 +++++ .../api/src/stream/GenerationJobManager.ts | 59 +++++++++---------- .../api/src/stream/interfaces/IJobStore.ts | 2 - 3 files changed, 44 insertions(+), 32 deletions(-) diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 7ef0b435b0..570b548394 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -438,6 +438,21 @@ export default function useEventHandlers({ } = submission; try { + // Handle early abort - aborted during tool loading before any messages saved + // Don't update conversation state, just reset UI and stay on new chat + if ((data as Record).earlyAbort) { + console.log( + '[finalHandler] Early abort detected - no messages saved, staying on new chat', + ); + setShowStopButton(false); + setIsSubmitting(false); + // Navigate to new chat if not already there + if (location.pathname !== `/c/${Constants.NEW_CONVO}`) { + navigate(`/c/${Constants.NEW_CONVO}`, { replace: true }); + } + return; + } + if (responseMessage?.attachments && responseMessage.attachments.length > 0) { // Process each attachment through the attachmentHandler responseMessage.attachments.forEach((attachment) => { diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 41b2acacf0..56ab862430 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -378,6 +378,14 @@ class GenerationJobManagerClass { * by the periodic cleanup job. */ async completeJob(streamId: string, error?: string): Promise { + const runtime = this.runtimeState.get(streamId); + + // Abort the controller to signal all pending operations (e.g., OAuth flow monitors) + // that the job is done and they should clean up + if (runtime) { + runtime.abortController.abort(); + } + // Clear content state and run step buffer (Redis only) this.jobStore.clearContentState(streamId); this.runStepBuffers?.delete(streamId); @@ -410,7 +418,7 @@ class GenerationJobManagerClass { if (!jobData) { logger.warn(`[GenerationJobManager] Cannot abort - job not found: ${streamId}`); - return { success: false, jobData: null, content: [], text: '', finalEvent: null }; + return { success: false, jobData: null, content: [], finalEvent: null }; } if (runtime) { @@ -419,14 +427,18 @@ class GenerationJobManagerClass { // Get content before clearing state const content = (await this.jobStore.getContentParts(streamId)) ?? []; - const text = this.extractTextFromContent(content); + + // Detect "early abort" - aborted before any generation happened (e.g., during tool loading) + // In this case, no messages were saved to DB, so frontend shouldn't navigate to conversation + const isEarlyAbort = content.length === 0 && !jobData.responseMessageId; // Create final event for abort const userMessageId = jobData.userMessage?.messageId; const abortFinalEvent: t.ServerSentEvent = { final: true, - conversation: { conversationId: jobData.conversationId }, + // Don't include conversation for early aborts - it doesn't exist in DB + conversation: isEarlyAbort ? null : { conversationId: jobData.conversationId }, title: 'New Chat', requestMessage: jobData.userMessage ? { @@ -437,18 +449,21 @@ class GenerationJobManagerClass { isCreatedByUser: true, } : null, - responseMessage: { - messageId: jobData.responseMessageId ?? `${userMessageId ?? 'aborted'}_`, - parentMessageId: userMessageId, - conversationId: jobData.conversationId, - content, - text, - sender: jobData.sender ?? 'AI', - unfinished: true, - error: false, - isCreatedByUser: false, - }, + responseMessage: isEarlyAbort + ? null + : { + messageId: jobData.responseMessageId ?? `${userMessageId ?? 'aborted'}_`, + parentMessageId: userMessageId, + conversationId: jobData.conversationId, + content, + sender: jobData.sender ?? 'AI', + unfinished: true, + error: false, + isCreatedByUser: false, + }, aborted: true, + // Flag for early abort - no messages saved, frontend should go to new chat + earlyAbort: isEarlyAbort, } as unknown as t.ServerSentEvent; if (runtime) { @@ -478,26 +493,10 @@ class GenerationJobManagerClass { success: true, jobData, content, - text, finalEvent: abortFinalEvent, }; } - /** - * Extract plain text from content parts array. - */ - private extractTextFromContent(content: Agents.MessageContentComplex[]): string { - return content - .map((part) => { - if ('text' in part && typeof part.text === 'string') { - return part.text; - } - return ''; - }) - .join('') - .trim(); - } - /** * Subscribe to a job's event stream. * diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts index b2c5c038f4..830b428fc2 100644 --- a/packages/api/src/stream/interfaces/IJobStore.ts +++ b/packages/api/src/stream/interfaces/IJobStore.ts @@ -56,8 +56,6 @@ export interface AbortResult { jobData: SerializableJobData | null; /** Aggregated content from the stream */ content: Agents.MessageContentComplex[]; - /** Plain text representation of content */ - text: string; /** Final event to send to client */ finalEvent: unknown; }