11import assert from "@/common/utils/assert" ;
22import type { MuxMessage , DisplayedMessage , QueuedMessage } from "@/common/types/message" ;
3- import { createMuxMessage } from "@/common/types/message" ;
43import type { FrontendWorkspaceMetadata } from "@/common/types/workspace" ;
54import type { WorkspaceChatMessage } from "@/common/types/ipc" ;
65import type { TodoItem } from "@/common/types/tools" ;
@@ -18,17 +17,11 @@ import {
1817 isRestoreToInput ,
1918} from "@/common/types/ipc" ;
2019import { MapStore } from "./MapStore" ;
21- import { createDisplayUsage } from "@/common/utils/tokens/displayUsage" ;
20+ import { getUsageHistory } from "@/common/utils/tokens/displayUsage" ;
2221import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager" ;
2322import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator" ;
24- import { sumUsageHistory } from "@/common/utils/tokens/usageAggregator" ;
2523import type { TokenConsumer } from "@/common/types/chatStats" ;
2624import type { LanguageModelV2Usage } from "@ai-sdk/provider" ;
27- import { getCancelledCompactionKey } from "@/common/constants/storage" ;
28- import {
29- isCompactingStream ,
30- findCompactionRequestMessage ,
31- } from "@/common/utils/compaction/handler" ;
3225import { createFreshRetryState } from "@/browser/utils/messages/retryState" ;
3326
3427export interface WorkspaceState {
@@ -149,10 +142,6 @@ export class WorkspaceStore {
149142 aggregator . handleStreamEnd ( data as never ) ;
150143 aggregator . clearTokenState ( ( data as { messageId : string } ) . messageId ) ;
151144
152- if ( this . handleCompactionCompletion ( workspaceId , aggregator , data ) ) {
153- return ;
154- }
155-
156145 // Reset retry state on successful stream completion
157146 updatePersistedState ( getRetryStateKey ( workspaceId ) , createFreshRetryState ( ) ) ;
158147
@@ -164,10 +153,6 @@ export class WorkspaceStore {
164153 aggregator . clearTokenState ( ( data as { messageId : string } ) . messageId ) ;
165154 aggregator . handleStreamAbort ( data as never ) ;
166155
167- if ( this . handleCompactionAbort ( workspaceId , aggregator , data ) ) {
168- return ;
169- }
170-
171156 this . states . bump ( workspaceId ) ;
172157 this . dispatchResumeCheck ( workspaceId ) ;
173158 this . finalizeUsageStats ( workspaceId , ( data as { metadata ?: never } ) . metadata ) ;
@@ -446,42 +431,8 @@ export class WorkspaceStore {
446431 const aggregator = this . assertGet ( workspaceId ) ;
447432
448433 const messages = aggregator . getAllMessages ( ) ;
449-
450- // Extract usage from assistant messages
451- const usageHistory : ChatUsageDisplay [ ] = [ ] ;
452- let cumulativeHistorical : ChatUsageDisplay | undefined ;
453-
454- for ( const msg of messages ) {
455- if ( msg . role === "assistant" ) {
456- // Check for historical usage from compaction summaries
457- // This preserves costs from messages deleted during compaction
458- if ( msg . metadata ?. historicalUsage ) {
459- cumulativeHistorical = msg . metadata . historicalUsage ;
460- }
461-
462- // Extract current message's usage
463- if ( msg . metadata ?. usage ) {
464- // Use the model from this specific message (not global)
465- const model = msg . metadata . model ?? aggregator . getCurrentModel ( ) ?? "unknown" ;
466-
467- const usage = createDisplayUsage (
468- msg . metadata . usage ,
469- model ,
470- msg . metadata . providerMetadata
471- ) ;
472-
473- if ( usage ) {
474- usageHistory . push ( usage ) ;
475- }
476- }
477- }
478- }
479-
480- // If we have historical usage from a compaction, prepend it to history
481- // This ensures costs from pre-compaction messages are included in totals
482- if ( cumulativeHistorical ) {
483- usageHistory . unshift ( cumulativeHistorical ) ;
484- }
434+ const model = aggregator . getCurrentModel ( ) ;
435+ const usageHistory = getUsageHistory ( messages , model ) ;
485436
486437 // Calculate total from usage history (now includes historical)
487438 const totalTokens = usageHistory . reduce (
@@ -544,178 +495,6 @@ export class WorkspaceStore {
544495 return this . consumersStore . subscribeKey ( workspaceId , listener ) ;
545496 }
546497
547- /**
548- * Handle compact_summary tool completion.
549- * Returns true if compaction was handled (caller should early return).
550- */
551- // Track processed compaction-request IDs to dedupe performCompaction across duplicated events
552- private processedCompactionRequestIds = new Set < string > ( ) ;
553-
554- private handleCompactionCompletion (
555- workspaceId : string ,
556- aggregator : StreamingMessageAggregator ,
557- data : WorkspaceChatMessage
558- ) : boolean {
559- // Type guard: only StreamEndEvent has messageId
560- if ( ! ( "messageId" in data ) ) return false ;
561-
562- // Check if this was a compaction stream
563- if ( ! isCompactingStream ( aggregator ) ) {
564- return false ;
565- }
566-
567- // Extract the compaction-request message to identify this compaction run
568- const compactionRequestMsg = findCompactionRequestMessage ( aggregator ) ;
569- if ( ! compactionRequestMsg ) {
570- return false ;
571- }
572-
573- // Dedupe: If we've already processed this compaction-request, skip re-running
574- if ( this . processedCompactionRequestIds . has ( compactionRequestMsg . id ) ) {
575- return true ; // Already handled compaction for this request
576- }
577-
578- // Extract the summary text from the assistant's response
579- const summary = aggregator . getCompactionSummary ( data . messageId ) ;
580- if ( ! summary ) {
581- console . warn ( "[WorkspaceStore] Compaction completed but no summary text found" ) ;
582- return false ;
583- }
584-
585- // Mark this compaction-request as processed before performing compaction
586- this . processedCompactionRequestIds . add ( compactionRequestMsg . id ) ;
587-
588- this . performCompaction ( workspaceId , aggregator , data , summary ) ;
589- return true ;
590- }
591-
592- /**
593- * Handle interruption of a compaction stream (StreamAbortEvent).
594- *
595- * Two distinct flows trigger this:
596- * - **Ctrl+A (accept early)**: Perform compaction with [truncated] sentinel
597- * - **Ctrl+C (cancel)**: Skip compaction, let cancelCompaction handle cleanup
598- *
599- * Uses localStorage to distinguish flows:
600- * - Checks for cancellation marker in localStorage
601- * - Verifies messageId matches for freshness
602- * - Reload-safe: localStorage persists across page reloads
603- */
604- private handleCompactionAbort (
605- workspaceId : string ,
606- aggregator : StreamingMessageAggregator ,
607- data : WorkspaceChatMessage
608- ) : boolean {
609- // Type guard: only StreamAbortEvent has messageId
610- if ( ! ( "messageId" in data ) ) return false ;
611-
612- // Check if this was a compaction stream
613- if ( ! isCompactingStream ( aggregator ) ) {
614- return false ;
615- }
616-
617- // Get the compaction request message for ID verification
618- const compactionRequestMsg = findCompactionRequestMessage ( aggregator ) ;
619- if ( ! compactionRequestMsg ) {
620- return false ;
621- }
622-
623- // Ctrl+C flow: Check localStorage for cancellation marker
624- // Verify compaction-request user message ID matches (stable across retries)
625- const storageKey = getCancelledCompactionKey ( workspaceId ) ;
626- const cancelData = localStorage . getItem ( storageKey ) ;
627- if ( cancelData ) {
628- try {
629- const parsed = JSON . parse ( cancelData ) as { compactionRequestId : string ; timestamp : number } ;
630- if ( parsed . compactionRequestId === compactionRequestMsg . id ) {
631- // This is a cancelled compaction - clean up marker and skip compaction
632- localStorage . removeItem ( storageKey ) ;
633- return false ; // Skip compaction, cancelCompaction() handles cleanup
634- }
635- } catch ( error ) {
636- console . error ( "[WorkspaceStore] Failed to parse cancellation data:" , error ) ;
637- }
638- // If compactionRequestId doesn't match or parse failed, clean up stale data
639- localStorage . removeItem ( storageKey ) ;
640- }
641-
642- // Ctrl+A flow: Accept early with [truncated] sentinel
643- const partialSummary = aggregator . getCompactionSummary ( data . messageId ) ;
644- if ( ! partialSummary ) {
645- console . warn ( "[WorkspaceStore] Compaction aborted but no partial summary found" ) ;
646- return false ;
647- }
648-
649- // Append [truncated] sentinel on new line to indicate incomplete summary
650- const truncatedSummary = partialSummary . trim ( ) + "\n\n[truncated]" ;
651-
652- this . performCompaction ( workspaceId , aggregator , data , truncatedSummary ) ;
653- return true ;
654- }
655-
656- /**
657- * Perform history compaction by replacing chat history with summary message.
658- * Type-safe: only called when we've verified data is a StreamEndEvent.
659- */
660- private performCompaction (
661- workspaceId : string ,
662- aggregator : StreamingMessageAggregator ,
663- data : WorkspaceChatMessage ,
664- summary : string
665- ) : void {
666- // Extract metadata safely with type guard
667- const metadata = "metadata" in data ? data . metadata : undefined ;
668-
669- // Calculate cumulative historical usage before replacing history
670- // This preserves costs from all messages that are about to be deleted
671- const currentUsage = this . getWorkspaceUsage ( workspaceId ) ;
672- const historicalUsage =
673- currentUsage . usageHistory . length > 0 ? sumUsageHistory ( currentUsage . usageHistory ) : undefined ;
674-
675- // Extract continueMessage from compaction-request before history gets replaced
676- const compactRequestMsg = findCompactionRequestMessage ( aggregator ) ;
677- const muxMeta = compactRequestMsg ?. metadata ?. muxMetadata ;
678- const continueMessage =
679- muxMeta ?. type === "compaction-request" ? muxMeta . parsed . continueMessage : undefined ;
680-
681- const summaryMessage = createMuxMessage (
682- `summary-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . substring ( 2 , 11 ) } ` ,
683- "assistant" ,
684- summary ,
685- {
686- timestamp : Date . now ( ) ,
687- compacted : true ,
688- model : aggregator . getCurrentModel ( ) ,
689- usage : metadata ?. usage ,
690- historicalUsage, // Store cumulative costs from all pre-compaction messages
691- providerMetadata :
692- metadata && "providerMetadata" in metadata
693- ? ( metadata . providerMetadata as Record < string , unknown > | undefined )
694- : undefined ,
695- duration : metadata ?. duration ,
696- systemMessageTokens :
697- metadata && "systemMessageTokens" in metadata
698- ? ( metadata . systemMessageTokens as number | undefined )
699- : undefined ,
700- // Store continueMessage in summary so it survives history replacement
701- muxMetadata : continueMessage
702- ? { type : "compaction-result" , continueMessage, requestId : compactRequestMsg ?. id }
703- : { type : "normal" } ,
704- }
705- ) ;
706-
707- void ( async ( ) => {
708- try {
709- await window . api . workspace . replaceChatHistory ( workspaceId , summaryMessage ) ;
710- } catch ( error ) {
711- console . error ( "[WorkspaceStore] Failed to replace history:" , error ) ;
712- } finally {
713- this . states . bump ( workspaceId ) ;
714- this . checkAndBumpRecencyIfChanged ( ) ;
715- }
716- } ) ( ) ;
717- }
718-
719498 /**
720499 * Update usage and schedule consumer calculation after stream completion.
721500 *
0 commit comments