@@ -58,7 +58,11 @@ import {
5858 createPruningCompactor ,
5959 type CompactionShape ,
6060} from "./compactor.js" ;
61- import type { SummaryContext } from "./summarizer.js" ;
61+ import {
62+ classifySummarizerFailure ,
63+ summarizerStubFallbackNotice ,
64+ type SummaryContext ,
65+ } from "./summarizer.js" ;
6266import { NOOP_TELEMETRY , type Telemetry } from "../telemetry/index.js" ;
6367import { COMPACTION_ABORTED_REASON } from "./compaction-lifecycle.js" ;
6468
@@ -418,7 +422,17 @@ export interface SessionPruningCompactorArgs {
418422 readPriorHandoff ?: ( ) => Promise < string | undefined > ;
419423 telemetry ?: Telemetry ;
420424 /** Fires only when turns were actually folded away — not on no-ops. */
421- onFolded ?: ( info : { turnsBefore : number ; turnsAfter : number } ) => void ;
425+ onFolded ?: ( info : {
426+ turnsBefore : number ;
427+ turnsAfter : number ;
428+ /** True when the fold used a statistics-only stub, not an LLM summary. */
429+ stub : boolean ;
430+ } ) => void ;
431+ /**
432+ * Operator-visible notice for a statistics-only stub that actually replaced
433+ * turns. Verify abort (keeping prior context) does not fire this.
434+ */
435+ onFailure ?: ( text : string ) => void ;
422436 /**
423437 * True when the lifecycle has discarded (or will discard) the in-flight
424438 * compact — e.g. bound to the session compaction lifecycle's signal. A
@@ -434,10 +448,39 @@ export interface SessionPruningCompactorArgs {
434448 compactionShape ?: Partial < CompactionShape > ;
435449}
436450
451+ function stubFallbackNoticeFromError ( error : unknown ) : string | undefined {
452+ const err = error instanceof Error ? error : new Error ( String ( error ) ) ;
453+ const kind = classifySummarizerFailure ( err ) ;
454+ if ( kind === "aborted" ) return undefined ;
455+ return summarizerStubFallbackNotice ( kind , err ) ;
456+ }
457+
458+ function stubFallbackNoticeFromRecord (
459+ pending : string | undefined ,
460+ kind : unknown ,
461+ ) : string {
462+ if ( pending !== undefined ) return pending ;
463+ const label = typeof kind === "string" && kind . length > 0 ? kind : "failed" ;
464+ return `Compaction summary failed — using a statistics-only stub (${ label } )` ;
465+ }
466+
437467/** Shared pruning-compactor defaults for the main session agent. */
438468export function createSessionPruningCompactor (
439469 args : SessionPruningCompactorArgs ,
440470) : Compactor {
471+ let pendingStubNotice : string | undefined ;
472+ const innerSummarize = args . summarize ;
473+ const summarize =
474+ innerSummarize === undefined
475+ ? undefined
476+ : async ( turns : ConversationTurn [ ] , ctx ?: SummaryContext ) => {
477+ try {
478+ return await innerSummarize ( turns , ctx ) ;
479+ } catch ( error ) {
480+ pendingStubNotice = stubFallbackNoticeFromError ( error ) ;
481+ throw error ;
482+ }
483+ } ;
441484 const compactor = createPruningCompactor ( {
442485 summaryMaxChars : SESSION_COMPACTOR_SUMMARY_MAX_CHARS ,
443486 // CL-9489 budgeted-tail shape: explicit defaults (same object the record
@@ -447,7 +490,7 @@ export function createSessionPruningCompactor(
447490 ...DEFAULT_TAIL_COMPACTION_SHAPE ,
448491 ...args . compactionShape ,
449492 } ,
450- ...( args . summarize !== undefined ? { summarize : args . summarize } : { } ) ,
493+ ...( summarize !== undefined ? { summarize } : { } ) ,
451494 ...( args . summaryContext ? { summaryContext : args . summaryContext } : { } ) ,
452495 ...( args . readPriorHandoff !== undefined
453496 ? { readPriorHandoff : args . readPriorHandoff }
@@ -457,6 +500,7 @@ export function createSessionPruningCompactor(
457500 return {
458501 ...compactor ,
459502 async apply ( turns , ctx ) {
503+ pendingStubNotice = undefined ;
460504 const turnsBefore = turns . length ;
461505 const startedAt = Date . now ( ) ;
462506 const result = await compactor . apply ( turns , ctx ) ;
@@ -473,21 +517,33 @@ export function createSessionPruningCompactor(
473517 // summarizedTurnCount is only set on the branch that actually folded
474518 // turns away. The other branch is a no-op (or image aging alone), and
475519 // reporting it as compaction would drag the duration and turn-count
476- // averages toward the runs where nothing happened. A statistics-only
477- // stub fold is not a successful LLM reduction: the operator notice
478- // owns that path, and emitting the success event would relabel it.
479- if (
480- result . record . decisions . summarizedTurnCount !== undefined &&
481- result . record . decisions . summarizeFailed !== 1
482- ) {
483- telemetry . capture ( "compaction" , {
484- mode : "llm" ,
485- duration_ms : Date . now ( ) - startedAt ,
486- turns_before : turnsBefore ,
487- turns_after : result . output . length ,
488- } ) ;
489- args . onFolded ?.( { turnsBefore, turnsAfter : result . output . length } ) ;
520+ // averages toward the runs where nothing happened.
521+ if ( result . record . decisions . summarizedTurnCount === undefined ) {
522+ return result ;
523+ }
524+ const stub = result . record . decisions . summarizeFailed === 1 ;
525+ // Stub folds still break the cached prefix, so prune still runs.
526+ // Success telemetry and the TUI fold flash must not relabel a stub.
527+ args . onFolded ?.( {
528+ turnsBefore,
529+ turnsAfter : result . output . length ,
530+ stub,
531+ } ) ;
532+ if ( stub ) {
533+ args . onFailure ?.(
534+ stubFallbackNoticeFromRecord (
535+ pendingStubNotice ,
536+ result . record . decisions . summarizeFailureKind ,
537+ ) ,
538+ ) ;
539+ return result ;
490540 }
541+ telemetry . capture ( "compaction" , {
542+ mode : "llm" ,
543+ duration_ms : Date . now ( ) - startedAt ,
544+ turns_before : turnsBefore ,
545+ turns_after : result . output . length ,
546+ } ) ;
491547 return result ;
492548 } ,
493549 } ;
0 commit comments