@@ -37,6 +37,10 @@ import {
3737 createCompactionArchive ,
3838 wrapCompactorWithCompletenessGate ,
3939} from "./compaction-archive.js" ;
40+ import {
41+ COMPACTION_ABORTED_REASON ,
42+ createCompactionLifecycle ,
43+ } from "./compaction-lifecycle.js" ;
4044import { generateSessionId , initSessionDir , sessionDir } from "./index.js" ;
4145import type { PluginModule } from "../plugins/loader.js" ;
4246
@@ -876,6 +880,110 @@ describe("createSessionPruningCompactor stub fallback", () => {
876880 expect ( notices ) . toHaveLength ( 1 ) ;
877881 expect ( notices [ 0 ] ) . toContain ( "statistics-only stub" ) ;
878882 expect ( notices [ 0 ] ) . toContain ( "failed" ) ;
883+ const committedHandoffs = ( await archive . listOccurrences ( ) ) . filter (
884+ ( occurrence ) => occurrence . provenance === "compaction-handoff" ,
885+ ) ;
886+ expect ( committedHandoffs . length ) . toBeGreaterThan ( 0 ) ;
887+ } ) ;
888+
889+ test ( "TUI abort after a gate-committed stub does not record a phantom handoff" , async ( ) => {
890+ const notices : string [ ] = [ ] ;
891+ const folds : { stub : boolean } [ ] = [ ] ;
892+ const summarize = createModelSummarizer ( {
893+ getSource : ( ) =>
894+ ( {
895+ id : "test" ,
896+ provider : "openai" ,
897+ model : "test-model" ,
898+ baseURL : "http://localhost:1" ,
899+ credentialId : "test" ,
900+ } ) as never ,
901+ complete : async ( ) => {
902+ throw new Error ( "model unreachable" ) ;
903+ } ,
904+ } ) ;
905+ const dir = await mkdtemp ( join ( tmpdir ( ) , "compaction-gate-stub-abort-" ) ) ;
906+ const blobs = new Map < string , Uint8Array > ( ) ;
907+ const archive = createCompactionArchive ( {
908+ sessionId : "sess-gate-stub-abort" ,
909+ contextDir : dir ,
910+ writeBlob : async ( key , bytes ) => {
911+ blobs . set ( key , bytes ) ;
912+ } ,
913+ readBlob : async ( key ) => {
914+ const bytes = blobs . get ( key ) ;
915+ if ( bytes === undefined ) throw new Error ( `missing ${ key } ` ) ;
916+ return bytes ;
917+ } ,
918+ } ) ;
919+ const now = Date . now ( ) ;
920+ const many = Array . from ( { length : 8 } , ( _ , i ) => ( {
921+ role : ( i % 2 === 0 ? "user" : "assistant" ) as "user" | "assistant" ,
922+ content : [ { type : "text" as const , text : `t${ i } ` } ] ,
923+ timestamp : now ,
924+ } ) ) ;
925+ for ( const turn of many ) {
926+ const block = turn . content [ 0 ] ;
927+ if ( block ?. type !== "text" ) continue ;
928+ await archive . recordAuthorizedPayload ( {
929+ kind : turn . role === "assistant" ? "assistant_text" : "user_message" ,
930+ payload : block . text ,
931+ } ) ;
932+ }
933+ const lifecycle = createCompactionLifecycle ( ) ;
934+ const isAborted = ( ) => lifecycle . getSignal ( ) . aborted ;
935+ let releaseGated : ( ) => void = ( ) => undefined ;
936+ const gatedFinished = new Promise < void > ( ( resolve ) => {
937+ releaseGated = resolve ;
938+ } ) ;
939+ const abortingArchive = {
940+ ...archive ,
941+ certifyRange : async ( ids : readonly string [ ] ) => {
942+ const certificate = await archive . certifyRange ( ids ) ;
943+ lifecycle . abortCompaction ( "operator interrupt" ) ;
944+ return certificate ;
945+ } ,
946+ } ;
947+ const wrapped = lifecycle . wrapCompactor (
948+ createSessionPruningCompactor ( {
949+ summarize,
950+ onFolded : ( info ) => folds . push ( info ) ,
951+ onFailure : ( text ) => notices . push ( text ) ,
952+ isAborted,
953+ compactionShape : { tailBudgetTokens : 1 } ,
954+ wrapPruning : ( pruning ) => {
955+ const gated = wrapCompactorWithCompletenessGate (
956+ pruning ,
957+ abortingArchive ,
958+ { isAborted } ,
959+ ) ;
960+ return {
961+ name : gated . name ,
962+ version : gated . version ,
963+ apply : async ( turns , ctx ) => {
964+ try {
965+ return await gated . apply ( turns , ctx ) ;
966+ } finally {
967+ releaseGated ( ) ;
968+ }
969+ } ,
970+ } ;
971+ } ,
972+ } ) ,
973+ ) ;
974+ const result = await wrapped . apply ( many as never , {
975+ state : { } as never ,
976+ trigger : "test" ,
977+ } ) ;
978+ await gatedFinished ;
979+ expect ( result . output ) . toBe ( many ) ;
980+ expect ( result . record . reason ) . toBe ( COMPACTION_ABORTED_REASON ) ;
981+ expect ( folds ) . toEqual ( [ ] ) ;
982+ expect ( notices ) . toEqual ( [ ] ) ;
983+ const handoffs = ( await archive . listOccurrences ( ) ) . filter (
984+ ( occurrence ) => occurrence . provenance === "compaction-handoff" ,
985+ ) ;
986+ expect ( handoffs ) . toEqual ( [ ] ) ;
879987 } ) ;
880988} ) ;
881989
0 commit comments