@@ -19,7 +19,9 @@ import {
1919 type ProductHostConfig ,
2020} from "./product-host.js" ;
2121import { buildModelsFirstCatalog , modelOptionId } from "./model-catalog.js" ;
22+ import { hydrateHistoryRows } from "./history-hydrate.js" ;
2223import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js" ;
24+ import { enterSubagentObserve } from "./shell/observe.js" ;
2325
2426function makeFakeSessionPort ( ) : {
2527 readonly sends : string [ ] ;
@@ -187,6 +189,80 @@ describe("mountProductHost", () => {
187189 }
188190 } ) ;
189191
192+ test ( "history.hydrate keeps a tool pair merged atomically across the cap boundary (CL-9008)" , async ( ) => {
193+ const { host, emitter } = await mountHeadless ( ) ;
194+ try {
195+ const blocks = [
196+ ...Array . from ( { length : 605 } , ( _ , i ) => ( {
197+ type : "text" ,
198+ content : `row-${ i } ` ,
199+ } ) ) ,
200+ {
201+ type : "tool_call" ,
202+ name : "spawn_agent" ,
203+ arguments : '{"description":"Fix CL-9008"}' ,
204+ callId : "cut-1" ,
205+ } ,
206+ {
207+ type : "tool_result" ,
208+ name : "spawn_agent" ,
209+ content : "done cut-1" ,
210+ callId : "cut-1" ,
211+ } ,
212+ ] ;
213+ emitter . emit ( "history.hydrate" , blocks ) ;
214+ // Folding merges the pair inside hydration, so the row-level slice keeps
215+ // and drops whole merged rows: 605 texts + 1 merged row → newest 600.
216+ // A block-level slice would split the pair and paint 599 rows instead.
217+ const expected = hydrateHistoryRows ( blocks ) . slice (
218+ - MAX_RETAINED_STREAM_ROWS ,
219+ ) ;
220+ expect ( expected . length ) . toBe ( MAX_RETAINED_STREAM_ROWS ) ;
221+ expect ( host . shell . streamLog ) . toEqual ( expected ) ;
222+ expect ( host . shell . streamLog [ 0 ] ) . toEqual ( {
223+ role : "assistant" ,
224+ text : "row-6" ,
225+ } ) ;
226+ const last = host . shell . streamLog [ host . shell . streamLog . length - 1 ] ;
227+ expect ( last ?. pending ) . not . toBe ( true ) ;
228+ expect ( last ?. text ) . toBe ( "done cut-1" ) ;
229+ expect ( host . shell . streamLogBase ) . toBe ( 0 ) ;
230+ } finally {
231+ host . dispose ( ) ;
232+ }
233+ } ) ;
234+
235+ test ( "history.hydrate in observe mode lands the capped tail on the parent log (CL-9008)" , async ( ) => {
236+ const { host, emitter } = await mountHeadless ( ) ;
237+ try {
238+ enterSubagentObserve ( host . shell , {
239+ sessionId : "child-keeper" ,
240+ agentId : "explorer" ,
241+ description : "observe-mode hydrate keeper" ,
242+ lines : [ ] ,
243+ } ) ;
244+ expect ( host . shell . parentStreamLog ) . toEqual ( [ ] ) ;
245+ const visibleBefore = host . shell . streamLog . length ;
246+ const total = MAX_RETAINED_STREAM_ROWS + 200 ;
247+ const blocks = Array . from ( { length : total } , ( _ , i ) => ( {
248+ type : "text" ,
249+ content : `obs-row-${ i } ` ,
250+ } ) ) ;
251+ emitter . emit ( "history.hydrate" , blocks ) ;
252+ // Observe routes hydrate rows to the parent snapshot only; the child
253+ // view on screen is untouched.
254+ expect ( host . shell . streamLog . length ) . toBe ( visibleBefore ) ;
255+ const expected = hydrateHistoryRows ( blocks ) . slice (
256+ - MAX_RETAINED_STREAM_ROWS ,
257+ ) ;
258+ expect ( host . shell . parentStreamLog ) . toEqual ( expected ) ;
259+ expect ( host . shell . parentStreamLog ?. length ) . toBe ( MAX_RETAINED_STREAM_ROWS ) ;
260+ expect ( host . shell . parentStreamLogBase ) . toBe ( 0 ) ;
261+ } finally {
262+ host . dispose ( ) ;
263+ }
264+ } ) ;
265+
190266 test ( "session.title updates the shell header" , async ( ) => {
191267 const { host, emitter } = await mountHeadless ( ) ;
192268 try {
0 commit comments