@@ -159,6 +159,222 @@ describe("SessionLogWriter", () => {
159159 } ) ;
160160 } ) ;
161161
162+ describe ( "_doFlush does not prematurely coalesce" , ( ) => {
163+ it ( "does not coalesce buffered chunks during a timed flush" , async ( ) => {
164+ const sessionId = "s1" ;
165+ logWriter . register ( sessionId , { taskId : "t1" , runId : sessionId } ) ;
166+
167+ // Buffer some chunks (no non-chunk event to trigger coalescing)
168+ logWriter . appendRawLine (
169+ sessionId ,
170+ makeSessionUpdate ( "agent_message_chunk" , {
171+ content : { type : "text" , text : "Hello " } ,
172+ } ) ,
173+ ) ;
174+ logWriter . appendRawLine (
175+ sessionId ,
176+ makeSessionUpdate ( "agent_message_chunk" , {
177+ content : { type : "text" , text : "world" } ,
178+ } ) ,
179+ ) ;
180+
181+ // Flush without any non-chunk event arriving — simulates
182+ // the 500ms debounce timer firing mid-stream
183+ await logWriter . flush ( sessionId ) ;
184+
185+ // No entries should have been sent — chunks are still buffered
186+ expect ( mockAppendLog ) . not . toHaveBeenCalled ( ) ;
187+
188+ // Now a non-chunk event arrives, triggering natural coalescing
189+ logWriter . appendRawLine (
190+ sessionId ,
191+ makeSessionUpdate ( "usage_update" , { used : 100 } ) ,
192+ ) ;
193+
194+ await logWriter . flush ( sessionId ) ;
195+
196+ expect ( mockAppendLog ) . toHaveBeenCalledTimes ( 1 ) ;
197+ const entries : StoredNotification [ ] = mockAppendLog . mock . calls [ 0 ] [ 2 ] ;
198+ expect ( entries ) . toHaveLength ( 2 ) ; // coalesced agent_message + usage_update
199+ const coalesced = entries [ 0 ] . notification ;
200+ expect ( coalesced . params ?. update ) . toEqual ( {
201+ sessionUpdate : "agent_message" ,
202+ content : { type : "text" , text : "Hello world" } ,
203+ } ) ;
204+ } ) ;
205+ } ) ;
206+
207+ describe ( "flushAll coalesces on shutdown" , ( ) => {
208+ it ( "coalesces remaining chunks before flushing" , async ( ) => {
209+ const sessionId = "s1" ;
210+ logWriter . register ( sessionId , { taskId : "t1" , runId : sessionId } ) ;
211+
212+ logWriter . appendRawLine (
213+ sessionId ,
214+ makeSessionUpdate ( "agent_message_chunk" , {
215+ content : { type : "text" , text : "partial response" } ,
216+ } ) ,
217+ ) ;
218+
219+ await logWriter . flushAll ( ) ;
220+
221+ expect ( mockAppendLog ) . toHaveBeenCalledTimes ( 1 ) ;
222+ const entries : StoredNotification [ ] = mockAppendLog . mock . calls [ 0 ] [ 2 ] ;
223+ expect ( entries ) . toHaveLength ( 1 ) ;
224+ const coalesced = entries [ 0 ] . notification ;
225+ expect ( coalesced . params ?. update ) . toEqual ( {
226+ sessionUpdate : "agent_message" ,
227+ content : { type : "text" , text : "partial response" } ,
228+ } ) ;
229+ } ) ;
230+ } ) ;
231+
232+ describe ( "flush with coalesce option" , ( ) => {
233+ it ( "drains chunk buffer when coalesce is true" , async ( ) => {
234+ const sessionId = "s1" ;
235+ logWriter . register ( sessionId , { taskId : "t1" , runId : sessionId } ) ;
236+
237+ logWriter . appendRawLine (
238+ sessionId ,
239+ makeSessionUpdate ( "agent_message_chunk" , {
240+ content : { type : "text" , text : "complete text" } ,
241+ } ) ,
242+ ) ;
243+
244+ await logWriter . flush ( sessionId , { coalesce : true } ) ;
245+
246+ expect ( mockAppendLog ) . toHaveBeenCalledTimes ( 1 ) ;
247+ const entries : StoredNotification [ ] = mockAppendLog . mock . calls [ 0 ] [ 2 ] ;
248+ const coalesced = entries [ 0 ] . notification ;
249+ expect ( coalesced . params ?. update ) . toEqual ( {
250+ sessionUpdate : "agent_message" ,
251+ content : { type : "text" , text : "complete text" } ,
252+ } ) ;
253+ } ) ;
254+
255+ it ( "does not coalesce when coalesce is false" , async ( ) => {
256+ const sessionId = "s1" ;
257+ logWriter . register ( sessionId , { taskId : "t1" , runId : sessionId } ) ;
258+
259+ logWriter . appendRawLine (
260+ sessionId ,
261+ makeSessionUpdate ( "agent_message_chunk" , {
262+ content : { type : "text" , text : "buffered" } ,
263+ } ) ,
264+ ) ;
265+
266+ await logWriter . flush ( sessionId , { coalesce : false } ) ;
267+
268+ expect ( mockAppendLog ) . not . toHaveBeenCalled ( ) ;
269+ } ) ;
270+ } ) ;
271+
272+ describe ( "direct agent_message supersedes chunks" , ( ) => {
273+ it ( "discards buffered chunks when a direct agent_message arrives" , async ( ) => {
274+ const sessionId = "s1" ;
275+ logWriter . register ( sessionId , { taskId : "t1" , runId : sessionId } ) ;
276+
277+ // Buffer partial chunks
278+ logWriter . appendRawLine (
279+ sessionId ,
280+ makeSessionUpdate ( "agent_message_chunk" , {
281+ content : { type : "text" , text : "partial " } ,
282+ } ) ,
283+ ) ;
284+ logWriter . appendRawLine (
285+ sessionId ,
286+ makeSessionUpdate ( "agent_message_chunk" , {
287+ content : { type : "text" , text : "text" } ,
288+ } ) ,
289+ ) ;
290+
291+ // Direct agent_message arrives — authoritative full text
292+ logWriter . appendRawLine (
293+ sessionId ,
294+ makeSessionUpdate ( "agent_message" , {
295+ content : { type : "text" , text : "complete full response" } ,
296+ } ) ,
297+ ) ;
298+
299+ await logWriter . flush ( sessionId ) ;
300+
301+ expect ( mockAppendLog ) . toHaveBeenCalledTimes ( 1 ) ;
302+ const entries : StoredNotification [ ] = mockAppendLog . mock . calls [ 0 ] [ 2 ] ;
303+ // Only the direct agent_message — no coalesced partial entry
304+ expect ( entries ) . toHaveLength ( 1 ) ;
305+ const coalesced = entries [ 0 ] . notification ;
306+ expect ( coalesced . params ?. update ) . toEqual ( {
307+ sessionUpdate : "agent_message" ,
308+ content : { type : "text" , text : "complete full response" } ,
309+ } ) ;
310+ expect ( logWriter . getLastAgentMessage ( sessionId ) ) . toBe (
311+ "complete full response" ,
312+ ) ;
313+ } ) ;
314+
315+ it ( "is additive with earlier coalesced text in multi-message turns" , async ( ) => {
316+ const sessionId = "s1" ;
317+ logWriter . register ( sessionId , { taskId : "t1" , runId : sessionId } ) ;
318+
319+ // First assistant message: chunks coalesced by a tool_call event
320+ logWriter . appendRawLine (
321+ sessionId ,
322+ makeSessionUpdate ( "agent_message_chunk" , {
323+ content : { type : "text" , text : "first message" } ,
324+ } ) ,
325+ ) ;
326+ logWriter . appendRawLine (
327+ sessionId ,
328+ makeSessionUpdate ( "tool_call" , { toolCallId : "tc1" } ) ,
329+ ) ;
330+ // "first message" is now coalesced into currentTurnMessages
331+
332+ // Second assistant message arrives as direct agent_message
333+ // (e.g., after tool result, no active chunk buffer)
334+ logWriter . appendRawLine (
335+ sessionId ,
336+ makeSessionUpdate ( "agent_message" , {
337+ content : { type : "text" , text : "second message" } ,
338+ } ) ,
339+ ) ;
340+
341+ const response = logWriter . getFullAgentResponse ( sessionId ) ;
342+ // Both messages are preserved — direct message is additive
343+ expect ( response ) . toBe ( "first message\n\nsecond message" ) ;
344+ } ) ;
345+
346+ it ( "persisted log does not contain stale entries when chunks are superseded" , async ( ) => {
347+ const sessionId = "s1" ;
348+ logWriter . register ( sessionId , { taskId : "t1" , runId : sessionId } ) ;
349+
350+ // Chunks buffered, then direct agent_message supersedes before coalescing
351+ logWriter . appendRawLine (
352+ sessionId ,
353+ makeSessionUpdate ( "agent_message_chunk" , {
354+ content : { type : "text" , text : "partial" } ,
355+ } ) ,
356+ ) ;
357+ logWriter . appendRawLine (
358+ sessionId ,
359+ makeSessionUpdate ( "agent_message" , {
360+ content : { type : "text" , text : "complete" } ,
361+ } ) ,
362+ ) ;
363+
364+ await logWriter . flush ( sessionId ) ;
365+
366+ expect ( mockAppendLog ) . toHaveBeenCalledTimes ( 1 ) ;
367+ const entries : StoredNotification [ ] = mockAppendLog . mock . calls [ 0 ] [ 2 ] ;
368+ // Only the direct agent_message — no coalesced partial entry
369+ expect ( entries ) . toHaveLength ( 1 ) ;
370+ const persisted = entries [ 0 ] . notification ;
371+ expect ( persisted . params ?. update ) . toEqual ( {
372+ sessionUpdate : "agent_message" ,
373+ content : { type : "text" , text : "complete" } ,
374+ } ) ;
375+ } ) ;
376+ } ) ;
377+
162378 describe ( "register" , ( ) => {
163379 it ( "does not re-register existing sessions" , ( ) => {
164380 const sessionId = "s1" ;
0 commit comments