55import { EventEmitter } from "node:events" ;
66import { describe , expect , test } from "bun:test" ;
77import type { KeyEvent } from "@opentui/core" ;
8+ import type { ConversationTurn } from "@intx/types/runtime" ;
89import { createHarness , type Harness } from "./harness.js" ;
910import { acceptOverlaySelection } from "./shell/overlay-host.js" ;
1011import {
@@ -16,6 +17,7 @@ import { mountProductHost, type ProductHostConfig } from "./product-host.js";
1617import { buildModelsFirstCatalog , modelOptionId } from "./model-catalog.js" ;
1718import { hydrateHistoryRows } from "./history-hydrate.js" ;
1819import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js" ;
20+ import { turnsToContentBlocks } from "./turns-to-blocks.js" ;
1921import { enterSubagentObserve } from "./shell/observe.js" ;
2022import { transcriptMarker } from "./shell/transcript.js" ;
2123
@@ -97,6 +99,46 @@ function composedKey(glyph: string): KeyEvent {
9799 } as KeyEvent ;
98100}
99101
102+ function userTextTurn ( text : string ) : ConversationTurn {
103+ return {
104+ role : "user" ,
105+ content : [ { type : "text" , text } ] ,
106+ timestamp : 0 ,
107+ } as unknown as ConversationTurn ;
108+ }
109+
110+ function spawnCallTurn ( id : string ) : ConversationTurn {
111+ return {
112+ role : "assistant" ,
113+ model : "test" ,
114+ timestamp : 0 ,
115+ content : [
116+ {
117+ type : "tool_call" ,
118+ id,
119+ name : "spawn_agent" ,
120+ arguments : { description : `job-${ id } ` } ,
121+ } ,
122+ ] ,
123+ } as unknown as ConversationTurn ;
124+ }
125+
126+ function spawnResultTurn ( id : string ) : ConversationTurn {
127+ return {
128+ role : "assistant" ,
129+ model : "test" ,
130+ timestamp : 0 ,
131+ content : [
132+ {
133+ type : "tool_result" ,
134+ callId : id ,
135+ content : `done ${ id } ` ,
136+ isError : false ,
137+ } ,
138+ ] ,
139+ } as unknown as ConversationTurn ;
140+ }
141+
100142describe ( "mountProductHost" , ( ) => {
101143 test ( "stream events emitted on the event emitter paint rows into the shell" , async ( ) => {
102144 const { host, emitter } = await mountHeadless ( ) ;
@@ -260,6 +302,113 @@ describe("mountProductHost", () => {
260302 }
261303 } ) ;
262304
305+ test ( "resume pipeline of long text history paints the eviction marker" , async ( ) => {
306+ const { host, emitter } = await mountHeadless ( ) ;
307+ try {
308+ const total = MAX_RETAINED_STREAM_ROWS + 200 ;
309+ const turns = Array . from ( { length : total } , ( _ , i ) =>
310+ userTextTurn ( `row-${ i } ` ) ,
311+ ) ;
312+ const blocks = turnsToContentBlocks ( turns ) ;
313+ emitter . emit ( "history.hydrate" , blocks ) ;
314+ expect ( host . shell . streamLog . length ) . toBe ( MAX_RETAINED_STREAM_ROWS ) ;
315+ expect ( host . shell . streamLogBase ) . toBe ( 200 ) ;
316+ expect ( host . shell . streamLog [ 0 ] ) . toEqual ( {
317+ role : "user" ,
318+ text : "row-200" ,
319+ } ) ;
320+ expect ( transcriptMarker ( host . shell ) ) . toBeDefined ( ) ;
321+ } finally {
322+ host . dispose ( ) ;
323+ }
324+ } ) ;
325+
326+ test ( "resume pipeline of tool pairs fills the retained row cap" , async ( ) => {
327+ const { host, emitter } = await mountHeadless ( ) ;
328+ try {
329+ const pairCount = MAX_RETAINED_STREAM_ROWS + 200 ;
330+ const turns : ConversationTurn [ ] = [ ] ;
331+ for ( let i = 0 ; i < pairCount ; i ++ ) {
332+ turns . push ( spawnCallTurn ( `p${ i } ` ) , spawnResultTurn ( `p${ i } ` ) ) ;
333+ }
334+ const blocks = turnsToContentBlocks ( turns ) ;
335+ emitter . emit ( "history.hydrate" , blocks ) ;
336+ expect ( host . shell . streamLog . length ) . toBe ( MAX_RETAINED_STREAM_ROWS ) ;
337+ expect ( host . shell . streamLogBase ) . toBe ( 200 ) ;
338+ const first = host . shell . streamLog [ 0 ] ;
339+ expect ( first ?. pending ) . not . toBe ( true ) ;
340+ expect ( first ?. text ) . toBe ( "done p200" ) ;
341+ const last = host . shell . streamLog [ host . shell . streamLog . length - 1 ] ;
342+ expect ( last ?. pending ) . not . toBe ( true ) ;
343+ expect ( last ?. text ) . toBe ( `done p${ pairCount - 1 } ` ) ;
344+ expect ( transcriptMarker ( host . shell ) ) . toBeDefined ( ) ;
345+ } finally {
346+ host . dispose ( ) ;
347+ }
348+ } ) ;
349+
350+ test ( "resume pipeline keeps a pair merged when it straddles the old block splice" , async ( ) => {
351+ const { host, emitter } = await mountHeadless ( ) ;
352+ try {
353+ const turns : ConversationTurn [ ] = [ ] ;
354+ for ( let i = 0 ; i < 400 ; i ++ ) {
355+ turns . push ( spawnCallTurn ( `c${ i } ` ) , spawnResultTurn ( `c${ i } ` ) ) ;
356+ }
357+ turns . push ( userTextTurn ( "trailing" ) ) ;
358+ const blocks = turnsToContentBlocks ( turns ) ;
359+ emitter . emit ( "history.hydrate" , { blocks, truncated : false } ) ;
360+ expect ( host . shell . streamLog . length ) . toBe ( 401 ) ;
361+ expect ( host . shell . streamLog [ 0 ] ?. pending ) . not . toBe ( true ) ;
362+ expect ( host . shell . streamLog [ 0 ] ?. text ) . toBe ( "done c0" ) ;
363+ expect ( host . shell . streamLog [ host . shell . streamLog . length - 1 ] ) . toEqual ( {
364+ role : "user" ,
365+ text : "trailing" ,
366+ } ) ;
367+ expect ( host . shell . streamLogBase ) . toBe ( 0 ) ;
368+ } finally {
369+ host . dispose ( ) ;
370+ }
371+ } ) ;
372+
373+ test ( "resume pipeline keeps a pair merged across the retained row cap" , async ( ) => {
374+ const { host, emitter } = await mountHeadless ( ) ;
375+ try {
376+ const turns = [
377+ ...Array . from ( { length : 605 } , ( _ , i ) => userTextTurn ( `row-${ i } ` ) ) ,
378+ spawnCallTurn ( "cut-1" ) ,
379+ spawnResultTurn ( "cut-1" ) ,
380+ ] ;
381+ const blocks = turnsToContentBlocks ( turns ) ;
382+ emitter . emit ( "history.hydrate" , blocks ) ;
383+ const allRows = hydrateHistoryRows ( blocks ) ;
384+ const expected = allRows . slice ( - MAX_RETAINED_STREAM_ROWS ) ;
385+ expect ( host . shell . streamLog ) . toEqual ( expected ) ;
386+ const last = host . shell . streamLog [ host . shell . streamLog . length - 1 ] ;
387+ expect ( last ?. pending ) . not . toBe ( true ) ;
388+ expect ( last ?. text ) . toBe ( "done cut-1" ) ;
389+ expect ( host . shell . streamLogBase ) . toBeGreaterThan ( 0 ) ;
390+ expect ( transcriptMarker ( host . shell ) ) . toBeDefined ( ) ;
391+ } finally {
392+ host . dispose ( ) ;
393+ }
394+ } ) ;
395+
396+ test ( "truncated load of an exact-cap tail still paints the eviction marker" , async ( ) => {
397+ const { host, emitter } = await mountHeadless ( ) ;
398+ try {
399+ const turns = Array . from ( { length : MAX_RETAINED_STREAM_ROWS } , ( _ , i ) =>
400+ userTextTurn ( `kept-${ i } ` ) ,
401+ ) ;
402+ const blocks = turnsToContentBlocks ( turns ) ;
403+ emitter . emit ( "history.hydrate" , { blocks, truncated : true } ) ;
404+ expect ( host . shell . streamLog . length ) . toBe ( MAX_RETAINED_STREAM_ROWS ) ;
405+ expect ( host . shell . streamLogBase ) . toBeGreaterThan ( 0 ) ;
406+ expect ( transcriptMarker ( host . shell ) ) . toBeDefined ( ) ;
407+ } finally {
408+ host . dispose ( ) ;
409+ }
410+ } ) ;
411+
263412 test ( "session.title updates the shell header" , async ( ) => {
264413 const { host, emitter } = await mountHeadless ( ) ;
265414 try {
0 commit comments