@@ -123,6 +123,9 @@ export class WorkspaceStore {
123123 // Track previous sidebar state per workspace (to prevent unnecessary bumps)
124124 private previousSidebarValues = new Map < string , WorkspaceSidebarState > ( ) ;
125125
126+ // Track workspaces currently replaying buffered history (to avoid O(N) scheduling)
127+ private replayingHistory = new Set < string > ( ) ;
128+
126129 // Track model usage (injected dependency for useModelLRU integration)
127130 private readonly onModelUsed ?: ( model : string ) => void ;
128131
@@ -139,6 +142,18 @@ export class WorkspaceStore {
139142 // message completion events (not on deltas) to prevent App.tsx re-renders.
140143 }
141144
145+ /**
146+ * Dispatch resume check event for a workspace.
147+ * Triggers useResumeManager to check if interrupted stream can be resumed.
148+ */
149+ private dispatchResumeCheck ( workspaceId : string ) : void {
150+ window . dispatchEvent (
151+ new CustomEvent ( CUSTOM_EVENTS . RESUME_CHECK_REQUESTED , {
152+ detail : { workspaceId } ,
153+ } )
154+ ) ;
155+ }
156+
142157 /**
143158 * Check if any workspace's recency changed and bump global recency if so.
144159 * Uses cached recency values from aggregators for O(1) comparison per workspace.
@@ -388,16 +403,125 @@ export class WorkspaceStore {
388403 }
389404
390405 /**
391- * Helper to bump usage store if metadata contains usage.
392- * Simplifies event handling logic and provides forward compatibility.
406+ * Handle compact_summary tool completion.
407+ * Returns true if compaction was handled (caller should early return).
408+ */
409+ private handleCompactSummaryCompletion (
410+ workspaceId : string ,
411+ aggregator : StreamingMessageAggregator ,
412+ data : WorkspaceChatMessage
413+ ) : boolean {
414+ // Type guard: only StreamEndEvent has parts
415+ if ( ! ( "parts" in data ) || ! data . parts ) return false ;
416+
417+ for ( const part of data . parts ) {
418+ if ( part . type === "dynamic-tool" && part . toolName === "compact_summary" ) {
419+ const output = part . output as { summary ?: string } | undefined ;
420+ if ( output ?. summary ) {
421+ this . performCompaction ( workspaceId , aggregator , data , output . summary ) ;
422+ return true ;
423+ }
424+ break ;
425+ }
426+ }
427+ return false ;
428+ }
429+
430+ /**
431+ * Perform history compaction by replacing chat history with summary message.
432+ * Type-safe: only called when we've verified data has parts (i.e., StreamEndEvent).
393433 */
394- private bumpUsageIfPresent (
434+ private performCompaction (
395435 workspaceId : string ,
396- metadata ?: { usage ?: LanguageModelV2Usage ; model ?: string }
436+ aggregator : StreamingMessageAggregator ,
437+ data : WorkspaceChatMessage ,
438+ summary : string
397439 ) : void {
440+ // We know data is StreamEndEvent because handleCompactSummaryCompletion verified it has parts
441+ // Extract metadata safely with type guard
442+ const metadata = "metadata" in data ? data . metadata : undefined ;
443+
444+ // Extract continueMessage from compaction-request before history gets replaced
445+ const messages = aggregator . getAllMessages ( ) ;
446+ const compactRequestMsg = [ ...messages ]
447+ . reverse ( )
448+ . find ( ( m ) => m . role === "user" && m . metadata ?. cmuxMetadata ?. type === "compaction-request" ) ;
449+ const cmuxMeta = compactRequestMsg ?. metadata ?. cmuxMetadata ;
450+ const continueMessage =
451+ cmuxMeta ?. type === "compaction-request" ? cmuxMeta . parsed . continueMessage : undefined ;
452+
453+ const summaryMessage = createCmuxMessage (
454+ `summary-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . substring ( 2 , 11 ) } ` ,
455+ "assistant" ,
456+ summary ,
457+ {
458+ timestamp : Date . now ( ) ,
459+ compacted : true ,
460+ model : aggregator . getCurrentModel ( ) ,
461+ usage : metadata ?. usage ,
462+ providerMetadata :
463+ metadata && "providerMetadata" in metadata
464+ ? ( metadata . providerMetadata as Record < string , unknown > | undefined )
465+ : undefined ,
466+ duration : metadata ?. duration ,
467+ systemMessageTokens :
468+ metadata && "systemMessageTokens" in metadata
469+ ? ( metadata . systemMessageTokens as number | undefined )
470+ : undefined ,
471+ // Store continueMessage in summary so it survives history replacement
472+ cmuxMetadata : continueMessage
473+ ? { type : "compaction-result" , continueMessage }
474+ : { type : "normal" } ,
475+ }
476+ ) ;
477+
478+ void ( async ( ) => {
479+ try {
480+ await window . api . workspace . replaceChatHistory ( workspaceId , summaryMessage ) ;
481+ } catch ( error ) {
482+ console . error ( "[WorkspaceStore] Failed to replace history:" , error ) ;
483+ } finally {
484+ this . states . bump ( workspaceId ) ;
485+ this . checkAndBumpRecencyIfChanged ( ) ;
486+ }
487+ } ) ( ) ;
488+ }
489+
490+ /**
491+ * Update usage and schedule consumer calculation after stream completion.
492+ *
493+ * CRITICAL ORDERING: This must be called AFTER the aggregator updates its messages.
494+ * If called before, the UI will re-render and read stale data from the aggregator,
495+ * causing a race condition where usage appears empty until refresh.
496+ *
497+ * Handles both:
498+ * - Instant usage display (from API metadata) - only if usage present
499+ * - Async consumer breakdown (tokenization via Web Worker) - normally scheduled,
500+ * but skipped during history replay to avoid O(N) scheduling overhead
501+ */
502+ private finalizeUsageStats (
503+ workspaceId : string ,
504+ metadata ?: { usage ?: LanguageModelV2Usage }
505+ ) : void {
506+ // During history replay: only bump usage, skip scheduling (caught-up schedules once at end)
507+ if ( this . replayingHistory . has ( workspaceId ) ) {
508+ if ( metadata ?. usage ) {
509+ this . usageStore . bump ( workspaceId ) ;
510+ }
511+ return ;
512+ }
513+
514+ // Normal real-time path: bump usage and schedule calculation
398515 if ( metadata ?. usage ) {
399516 this . usageStore . bump ( workspaceId ) ;
400517 }
518+
519+ // Always schedule consumer calculation (tool calls, text, etc. need tokenization)
520+ // Even streams without usage metadata need token counts recalculated
521+ const aggregator = this . aggregators . get ( workspaceId ) ;
522+ if ( aggregator ) {
523+ this . consumerManager . scheduleCalculation ( workspaceId , aggregator ) ;
524+ }
401525 }
402526
403527 /**
@@ -540,13 +664,19 @@ export class WorkspaceStore {
540664 this . historicalMessages . set ( workspaceId , [ ] ) ;
541665 }
542666
667+ // Mark that we're replaying buffered history (prevents O(N) scheduling)
668+ this . replayingHistory . add ( workspaceId ) ;
669+
543670 // Process buffered stream events now that history is loaded
544671 const pendingEvents = this . pendingStreamEvents . get ( workspaceId ) ?? [ ] ;
545672 for ( const event of pendingEvents ) {
546673 this . processStreamEvent ( workspaceId , aggregator , event ) ;
547674 }
548675 this . pendingStreamEvents . set ( workspaceId , [ ] ) ;
549676
677+ // Done replaying buffered events
678+ this . replayingHistory . delete ( workspaceId ) ;
679+
550680 // Mark as caught up
551681 this . caughtUp . set ( workspaceId , true ) ;
552682 this . states . bump ( workspaceId ) ;
@@ -555,8 +685,10 @@ export class WorkspaceStore {
555685 // Bump usage after loading history
556686 this . usageStore . bump ( workspaceId ) ;
557687
558- // Queue consumer calculation in background
559- this . consumerManager . scheduleCalculation ( workspaceId , aggregator ) ;
688+ // Schedule consumer calculation once after all buffered events processed
689+ if ( aggregator . getAllMessages ( ) . length > 0 ) {
690+ this . consumerManager . scheduleCalculation ( workspaceId , aggregator ) ;
691+ }
560692
561693 return ;
562694 }
@@ -578,17 +710,10 @@ export class WorkspaceStore {
578710 aggregator : StreamingMessageAggregator ,
579711 data : WorkspaceChatMessage
580712 ) : void {
581- // Bump usage if metadata present (forward compatible - works for any event type)
582- this . bumpUsageIfPresent ( workspaceId , "metadata" in data ? data . metadata : undefined ) ;
583-
584713 if ( isStreamError ( data ) ) {
585714 aggregator . handleStreamError ( data ) ;
586715 this . states . bump ( workspaceId ) ;
587- window . dispatchEvent (
588- new CustomEvent ( CUSTOM_EVENTS . RESUME_CHECK_REQUESTED , {
589- detail : { workspaceId } ,
590- } )
591- ) ;
716+ this . dispatchResumeCheck ( workspaceId ) ;
592717 return ;
593718 }
594719
@@ -624,67 +749,18 @@ export class WorkspaceStore {
624749 aggregator . handleStreamEnd ( data ) ;
625750 aggregator . clearTokenState ( data . messageId ) ;
626751
627- // Handle compact_summary completion
628- if ( data . parts ) {
629- for ( const part of data . parts ) {
630- if ( part . type === "dynamic-tool" && part . toolName === "compact_summary" ) {
631- const output = part . output as { summary ?: string } | undefined ;
632- if ( output ?. summary ) {
633- // Extract continueMessage from compaction-request before history gets replaced
634- const messages = aggregator . getAllMessages ( ) ;
635- const compactRequestMsg = [ ...messages ]
636- . reverse ( )
637- . find (
638- ( m ) =>
639- m . role === "user" && m . metadata ?. cmuxMetadata ?. type === "compaction-request"
640- ) ;
641- const cmuxMeta = compactRequestMsg ?. metadata ?. cmuxMetadata ;
642- const continueMessage =
643- cmuxMeta ?. type === "compaction-request"
644- ? cmuxMeta . parsed . continueMessage
645- : undefined ;
646-
647- const summaryMessage = createCmuxMessage (
648- `summary-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . substring ( 2 , 11 ) } ` ,
649- "assistant" ,
650- output . summary ,
651- {
652- timestamp : Date . now ( ) ,
653- compacted : true ,
654- model : aggregator . getCurrentModel ( ) ,
655- usage : data . metadata . usage ,
656- providerMetadata : data . metadata . providerMetadata ,
657- duration : data . metadata . duration ,
658- systemMessageTokens : data . metadata . systemMessageTokens ,
659- // Store continueMessage in summary so it survives history replacement
660- cmuxMetadata : continueMessage
661- ? { type : "compaction-result" , continueMessage }
662- : { type : "normal" } ,
663- }
664- ) ;
665-
666- void ( async ( ) => {
667- try {
668- await window . api . workspace . replaceChatHistory ( workspaceId , summaryMessage ) ;
669- } catch ( error ) {
670- console . error ( "[WorkspaceStore] Failed to replace history:" , error ) ;
671- } finally {
672- this . states . bump ( workspaceId ) ;
673- this . checkAndBumpRecencyIfChanged ( ) ;
674- }
675- } ) ( ) ;
676- return ;
677- }
678- break ;
679- }
680- }
752+ // Early return if compact_summary handled (async replacement in progress)
753+ if ( this . handleCompactSummaryCompletion ( workspaceId , aggregator , data ) ) {
754+ return ;
681755 }
682756
757+ // Normal stream-end handling
683758 this . states . bump ( workspaceId ) ;
684759 this . checkAndBumpRecencyIfChanged ( ) ; // Stream ended, update recency
685760
686- // Queue consumer calculation in background
687- this . consumerManager . scheduleCalculation ( workspaceId , aggregator ) ;
761+ // Update usage stats and schedule consumer calculation
762+ // MUST happen after aggregator.handleStreamEnd() stores the metadata
763+ this . finalizeUsageStats ( workspaceId , data . metadata ) ;
688764
689765 return ;
690766 }
@@ -693,18 +769,11 @@ export class WorkspaceStore {
693769 aggregator . clearTokenState ( data . messageId ) ;
694770 aggregator . handleStreamAbort ( data ) ;
695771 this . states . bump ( workspaceId ) ;
696- window . dispatchEvent (
697- new CustomEvent ( CUSTOM_EVENTS . RESUME_CHECK_REQUESTED , {
698- detail : { workspaceId } ,
699- } )
700- ) ;
772+ this . dispatchResumeCheck ( workspaceId ) ;
701773
702- this . bumpUsageIfPresent ( workspaceId , data . metadata ) ;
703-
704- // Recalculate consumers if usage updated (abort may have usage if stream completed)
705- if ( data . metadata ?. usage ) {
706- this . consumerManager . scheduleCalculation ( workspaceId , aggregator ) ;
707- }
774+ // Update usage stats if available (abort may have usage if stream completed processing)
775+ // MUST happen after aggregator.handleStreamAbort() stores the metadata
776+ this . finalizeUsageStats ( workspaceId , data . metadata ) ;
708777
709778 return ;
710779 }
0 commit comments