@@ -11,7 +11,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
1111import { tmpdir } from "node:os" ;
1212import { join } from "node:path" ;
1313import { getLogger } from "@intx/log" ;
14- import type { ToolCall } from "@intx/types/runtime" ;
14+ import type { ConversationTurn , ToolCall } from "@intx/types/runtime" ;
1515
1616import { LOG_NAMESPACE_ROOT } from "../branding.js" ;
1717import * as permissionStore from "../permission/store.js" ;
@@ -33,6 +33,10 @@ import type { SubAgentSourcesConfig } from "./runtime-assembly.js";
3333import type { Settings } from "../config/settings.js" ;
3434import type { Telemetry } from "../telemetry/index.js" ;
3535import { createModelSummarizer } from "./summarizer.js" ;
36+ import {
37+ createCompactionArchive ,
38+ wrapCompactorWithCompletenessGate ,
39+ } from "./compaction-archive.js" ;
3640import { generateSessionId , initSessionDir , sessionDir } from "./index.js" ;
3741import type { PluginModule } from "../plugins/loader.js" ;
3842
@@ -732,6 +736,147 @@ describe("createSessionPruningCompactor stub fallback", () => {
732736 expect ( folds ) . toEqual ( [ ] ) ;
733737 expect ( notices ) . toEqual ( [ ] ) ;
734738 } ) ;
739+
740+ test ( "completeness-gate discard after a stub fold fires no notice or onFolded" , async ( ) => {
741+ const notices : string [ ] = [ ] ;
742+ const folds : { stub : boolean } [ ] = [ ] ;
743+ const captured : { event : string } [ ] = [ ] ;
744+ const telemetry : Telemetry = {
745+ enabled : true ,
746+ installationId : "test" ,
747+ capture : ( event ) => {
748+ captured . push ( { event } ) ;
749+ } ,
750+ captureIntentional : ( ) => false ,
751+ flush : async ( ) => undefined ,
752+ discard : ( ) => undefined ,
753+ } ;
754+ const summarize = createModelSummarizer ( {
755+ getSource : ( ) =>
756+ ( {
757+ id : "test" ,
758+ provider : "openai" ,
759+ model : "test-model" ,
760+ baseURL : "http://localhost:1" ,
761+ credentialId : "test" ,
762+ } ) as never ,
763+ complete : async ( ) => {
764+ throw new Error ( "model unreachable" ) ;
765+ } ,
766+ } ) ;
767+ const dir = await mkdtemp ( join ( tmpdir ( ) , "compaction-gate-stub-" ) ) ;
768+ const blobs = new Map < string , Uint8Array > ( ) ;
769+ const archive = createCompactionArchive ( {
770+ sessionId : "sess-gate-stub" ,
771+ contextDir : dir ,
772+ writeBlob : async ( key , bytes ) => {
773+ blobs . set ( key , bytes ) ;
774+ } ,
775+ readBlob : async ( key ) => {
776+ const bytes = blobs . get ( key ) ;
777+ if ( bytes === undefined ) throw new Error ( `missing ${ key } ` ) ;
778+ return bytes ;
779+ } ,
780+ } ) ;
781+ const now = Date . now ( ) ;
782+ const many : ConversationTurn [ ] = Array . from ( { length : 8 } , ( _ , i ) => ( {
783+ role : i % 2 === 0 ? "user" : "assistant" ,
784+ content : [ { type : "text" , text : `t${ i } ` } ] ,
785+ timestamp : now ,
786+ } ) ) ;
787+ const result = await createSessionPruningCompactor ( {
788+ summarize,
789+ telemetry,
790+ onFolded : ( info ) => folds . push ( info ) ,
791+ onFailure : ( text ) => notices . push ( text ) ,
792+ compactionShape : { tailBudgetTokens : 1 } ,
793+ wrapPruning : ( pruning ) =>
794+ wrapCompactorWithCompletenessGate ( pruning , archive ) ,
795+ } ) . apply ( many , { state : { } as never , trigger : "test" } ) ;
796+ expect ( result . output ) . toBe ( many ) ;
797+ expect ( result . record . reason ) . toBe ( "incomplete-evidence-archive" ) ;
798+ expect ( result . record . decisions . summarizedTurnCount ) . toBeUndefined ( ) ;
799+ expect ( folds ) . toEqual ( [ ] ) ;
800+ expect ( notices ) . toEqual ( [ ] ) ;
801+ expect ( captured ) . toEqual ( [ ] ) ;
802+ } ) ;
803+
804+ test ( "a committed stub fold still notices and prunes" , async ( ) => {
805+ const notices : string [ ] = [ ] ;
806+ const folds : { turnsBefore : number ; turnsAfter : number ; stub : boolean } [ ] =
807+ [ ] ;
808+ const captured : { event : string } [ ] = [ ] ;
809+ const telemetry : Telemetry = {
810+ enabled : true ,
811+ installationId : "test" ,
812+ capture : ( event ) => {
813+ captured . push ( { event } ) ;
814+ } ,
815+ captureIntentional : ( ) => false ,
816+ flush : async ( ) => undefined ,
817+ discard : ( ) => undefined ,
818+ } ;
819+ const summarize = createModelSummarizer ( {
820+ getSource : ( ) =>
821+ ( {
822+ id : "test" ,
823+ provider : "openai" ,
824+ model : "test-model" ,
825+ baseURL : "http://localhost:1" ,
826+ credentialId : "test" ,
827+ } ) as never ,
828+ complete : async ( ) => {
829+ throw new Error ( "model unreachable" ) ;
830+ } ,
831+ } ) ;
832+ const dir = await mkdtemp ( join ( tmpdir ( ) , "compaction-gate-stub-ok-" ) ) ;
833+ const blobs = new Map < string , Uint8Array > ( ) ;
834+ const archive = createCompactionArchive ( {
835+ sessionId : "sess-gate-stub-ok" ,
836+ contextDir : dir ,
837+ writeBlob : async ( key , bytes ) => {
838+ blobs . set ( key , bytes ) ;
839+ } ,
840+ readBlob : async ( key ) => {
841+ const bytes = blobs . get ( key ) ;
842+ if ( bytes === undefined ) throw new Error ( `missing ${ key } ` ) ;
843+ return bytes ;
844+ } ,
845+ } ) ;
846+ const now = Date . now ( ) ;
847+ const many = Array . from ( { length : 8 } , ( _ , i ) => ( {
848+ role : ( i % 2 === 0 ? "user" : "assistant" ) as "user" | "assistant" ,
849+ content : [ { type : "text" as const , text : `t${ i } ` } ] ,
850+ timestamp : now ,
851+ } ) ) ;
852+ for ( const turn of many ) {
853+ const block = turn . content [ 0 ] ;
854+ if ( block ?. type !== "text" ) continue ;
855+ await archive . recordAuthorizedPayload ( {
856+ kind : turn . role === "assistant" ? "assistant_text" : "user_message" ,
857+ payload : block . text ,
858+ } ) ;
859+ }
860+ const result = await createSessionPruningCompactor ( {
861+ summarize,
862+ telemetry,
863+ onFolded : ( info ) => folds . push ( info ) ,
864+ onFailure : ( text ) => notices . push ( text ) ,
865+ compactionShape : { tailBudgetTokens : 1 } ,
866+ wrapPruning : ( pruning ) =>
867+ wrapCompactorWithCompletenessGate ( pruning , archive ) ,
868+ } ) . apply ( many as never , { state : { } as never , trigger : "test" } ) ;
869+ expect ( result . record . decisions . summarizeFailed ) . toBe ( 1 ) ;
870+ expect ( result . record . reason ) . toContain ( "statistics-only stub" ) ;
871+ expect ( result . output ) . not . toBe ( many ) ;
872+ expect ( folds ) . toEqual ( [
873+ { turnsBefore : 8 , turnsAfter : result . output . length , stub : true } ,
874+ ] ) ;
875+ expect ( captured ) . toEqual ( [ ] ) ;
876+ expect ( notices ) . toHaveLength ( 1 ) ;
877+ expect ( notices [ 0 ] ) . toContain ( "statistics-only stub" ) ;
878+ expect ( notices [ 0 ] ) . toContain ( "failed" ) ;
879+ } ) ;
735880} ) ;
736881
737882describe ( "buildCompactionContinuationMessage" , ( ) => {
0 commit comments