@@ -106,6 +106,8 @@ interface WorkspaceStreamInfo {
106106 partialWritePromise ?: Promise < void > ;
107107 // Track background processing promise for guaranteed cleanup
108108 processingPromise : Promise < void > ;
109+ // Flag for soft-interrupt: when true, stream will end at next block boundary
110+ softInterruptPending : boolean ;
109111 // Temporary directory for tool outputs (auto-cleaned when stream ends)
110112 runtimeTempDir : string ;
111113 // Runtime for temp directory cleanup
@@ -414,31 +416,63 @@ export class StreamManager extends EventEmitter {
414416
415417 streamInfo . abortController . abort ( ) ;
416418
417- // CRITICAL: Wait for processing to fully complete before cleanup
418- // This prevents race conditions where the old stream is still running
419- // while a new stream starts (e.g., old stream writing to partial.json)
420- await streamInfo . processingPromise ;
419+ await this . cleanupStream ( workspaceId , streamInfo ) ;
420+ } catch ( error ) {
421+ console . error ( "Error during stream cancellation:" , error ) ;
422+ // Force cleanup even if cancellation fails
423+ this . workspaceStreams . delete ( workspaceId ) ;
424+ }
425+ }
426+
427+ // Checks if a soft interrupt is necessary, and performs one if so
428+ // Similar to cancelStreamSafely but performs cleanup without blocking
429+ private async checkSoftCancelStream (
430+ workspaceId : WorkspaceId ,
431+ streamInfo : WorkspaceStreamInfo
432+ ) : Promise < void > {
433+ if ( ! streamInfo . softInterruptPending ) return ;
434+ try {
435+ streamInfo . state = StreamState . STOPPING ;
421436
422- // Get usage and duration metadata (usage may be undefined if aborted early )
423- const { usage , duration } = await this . getStreamMetadata ( streamInfo ) ;
437+ // Flush any pending partial write immediately (preserves work on interruption )
438+ await this . flushPartialWrite ( workspaceId , streamInfo ) ;
424439
425- // Emit abort event with usage if available
426- this . emit ( "stream-abort" , {
427- type : "stream-abort" ,
428- workspaceId : workspaceId as string ,
429- messageId : streamInfo . messageId ,
430- metadata : { usage, duration } ,
431- } ) ;
440+ streamInfo . abortController . abort ( ) ;
432441
433- // Clean up immediately
434- this . workspaceStreams . delete ( workspaceId ) ;
442+ // Return back to the stream loop so we can wait for it to finish before
443+ // sending the stream abort event.
444+ void this . cleanupStream ( workspaceId , streamInfo ) ;
435445 } catch ( error ) {
436446 console . error ( "Error during stream cancellation:" , error ) ;
437447 // Force cleanup even if cancellation fails
438448 this . workspaceStreams . delete ( workspaceId ) ;
439449 }
440450 }
441451
452+ private async cleanupStream (
453+ workspaceId : WorkspaceId ,
454+ streamInfo : WorkspaceStreamInfo
455+ ) : Promise < void > {
456+ // CRITICAL: Wait for processing to fully complete before cleanup
457+ // This prevents race conditions where the old stream is still running
458+ // while a new stream starts (e.g., old stream writing to partial.json)
459+ await streamInfo . processingPromise ;
460+
461+ // Get usage and duration metadata (usage may be undefined if aborted early)
462+ const { usage, duration } = await this . getStreamMetadata ( streamInfo ) ;
463+
464+ // Emit abort event with usage if available
465+ this . emit ( "stream-abort" , {
466+ type : "stream-abort" ,
467+ workspaceId : workspaceId as string ,
468+ messageId : streamInfo . messageId ,
469+ metadata : { usage, duration } ,
470+ } ) ;
471+
472+ // Clean up immediately
473+ this . workspaceStreams . delete ( workspaceId ) ;
474+ }
475+
442476 /**
443477 * Atomically creates a new stream with all necessary setup
444478 */
@@ -525,6 +559,7 @@ export class StreamManager extends EventEmitter {
525559 lastPartialWriteTime : 0 , // Initialize to 0 to allow immediate first write
526560 partialWritePromise : undefined , // No write in flight initially
527561 processingPromise : Promise . resolve ( ) , // Placeholder, overwritten in startStream
562+ softInterruptPending : false , // Initialize to false
528563 runtimeTempDir, // Stream-scoped temp directory for tool outputs
529564 runtime, // Runtime for temp directory cleanup
530565 } ;
@@ -688,6 +723,7 @@ export class StreamManager extends EventEmitter {
688723 workspaceId : workspaceId as string ,
689724 messageId : streamInfo . messageId ,
690725 } ) ;
726+ await this . checkSoftCancelStream ( workspaceId , streamInfo ) ;
691727 break ;
692728 }
693729
@@ -742,6 +778,7 @@ export class StreamManager extends EventEmitter {
742778 strippedOutput
743779 ) ;
744780 }
781+ await this . checkSoftCancelStream ( workspaceId , streamInfo ) ;
745782 break ;
746783 }
747784
@@ -778,6 +815,7 @@ export class StreamManager extends EventEmitter {
778815 toolErrorPart . toolName ,
779816 errorOutput
780817 ) ;
818+ await this . checkSoftCancelStream ( workspaceId , streamInfo ) ;
781819 break ;
782820 }
783821
@@ -823,9 +861,14 @@ export class StreamManager extends EventEmitter {
823861 case "start-step" :
824862 case "text-start" :
825863 case "finish" :
826- case "finish-step" :
827864 // These events can be logged or handled if needed
828865 break ;
866+
867+ case "finish-step" :
868+ case "text-end" :
869+ case "tool-input-end" :
870+ await this . checkSoftCancelStream ( workspaceId , streamInfo ) ;
871+ break ;
829872 }
830873 }
831874
@@ -1196,14 +1239,32 @@ export class StreamManager extends EventEmitter {
11961239
11971240 /**
11981241 * Stops an active stream for a workspace
1242+ * First call: Sets soft interrupt and emits delta event → frontend shows "Interrupting..."
1243+ * Second call: Hard aborts the stream immediately
11991244 */
12001245 async stopStream ( workspaceId : string ) : Promise < Result < void > > {
12011246 const typedWorkspaceId = workspaceId as WorkspaceId ;
12021247
12031248 try {
12041249 const streamInfo = this . workspaceStreams . get ( typedWorkspaceId ) ;
1205- if ( streamInfo ) {
1250+ if ( ! streamInfo ) {
1251+ return Ok ( undefined ) ; // No active stream
1252+ }
1253+
1254+ if ( streamInfo . softInterruptPending ) {
12061255 await this . cancelStreamSafely ( typedWorkspaceId , streamInfo ) ;
1256+ } else {
1257+ // First Escape: Soft interrupt - emit delta to notify frontend
1258+ streamInfo . softInterruptPending = true ;
1259+ this . emit ( "stream-delta" , {
1260+ type : "stream-delta" ,
1261+ workspaceId : workspaceId ,
1262+ messageId : streamInfo . messageId ,
1263+ delta : "" ,
1264+ tokens : 0 ,
1265+ timestamp : Date . now ( ) ,
1266+ softInterruptPending : true , // Signal to frontend
1267+ } ) ;
12071268 }
12081269 return Ok ( undefined ) ;
12091270 } catch ( error ) {
0 commit comments