@@ -208,6 +208,261 @@ async def _run_in_terminal(func, *args, **kwargs): # noqa: ANN001, ANN002, ANN0
208208 assert invalidations == ["invalidate" ]
209209
210210
211+ @pytest .mark .asyncio
212+ async def test_incremental_commits_throttled_between_ticks (monkeypatch ) -> None :
213+ """Status-loop commits are rate-limited so run_in_terminal teardown is rare."""
214+ from pythinker_code .ui .shell .visualize ._blocks import _ContentBlock
215+ from pythinker_code .ui .shell .visualize ._interactive import (
216+ _INCREMENTAL_COMMIT_MIN_INTERVAL_S ,
217+ )
218+
219+ terminal_handoffs : list [str ] = []
220+
221+ class _PromptSession :
222+ def invalidate (self ) -> None :
223+ pass
224+
225+ async def _run_in_terminal (func , * args , ** kwargs ): # noqa: ANN001, ANN002, ANN003
226+ terminal_handoffs .append ("run" )
227+ func ()
228+
229+ monkeypatch .setattr (_interactive_mod , "run_in_terminal" , _run_in_terminal )
230+ monkeypatch .setattr (
231+ _live_view_mod ,
232+ "emit_scrollback_block" ,
233+ lambda _console , renderable : None ,
234+ )
235+
236+ clock = {"now" : 1000.0 }
237+ monkeypatch .setattr (_interactive_mod .time , "monotonic" , lambda : clock ["now" ])
238+
239+ view = _PromptLiveView (
240+ StatusUpdate (),
241+ prompt_session = cast (Any , _PromptSession ()),
242+ steer = lambda _content : None ,
243+ )
244+
245+ def _fresh_block () -> _ContentBlock :
246+ block = _ContentBlock (is_think = False )
247+ block .append ("Paragraph one.\n \n tail" )
248+ assert block ._committed_renderables
249+ return block
250+
251+ # First tick: due immediately (last-commit time starts at 0.0) -> one handoff.
252+ view ._current_content_block = _fresh_block ()
253+ assert await view ._maybe_emit_incremental_commits () is True
254+
255+ # Second tick within the interval: suppressed, no extra handoff, content kept.
256+ view ._current_content_block = _fresh_block ()
257+ assert await view ._maybe_emit_incremental_commits () is False
258+ assert view ._current_content_block ._committed_renderables # not taken/stranded
259+
260+ # Advance past the interval: emits again.
261+ clock ["now" ] += _INCREMENTAL_COMMIT_MIN_INTERVAL_S + 0.01
262+ assert await view ._maybe_emit_incremental_commits () is True
263+
264+ assert terminal_handoffs == ["run" , "run" ]
265+
266+
267+ @pytest .mark .asyncio
268+ async def test_transition_drain_bypasses_commit_throttle (monkeypatch ) -> None :
269+ """The direct (unthrottled) emit path always flushes, even within the interval."""
270+ from pythinker_code .ui .shell .visualize ._blocks import _ContentBlock
271+
272+ terminal_handoffs : list [str ] = []
273+
274+ class _PromptSession :
275+ def invalidate (self ) -> None :
276+ pass
277+
278+ async def _run_in_terminal (func , * args , ** kwargs ): # noqa: ANN001, ANN002, ANN003
279+ terminal_handoffs .append ("run" )
280+ func ()
281+
282+ monkeypatch .setattr (_interactive_mod , "run_in_terminal" , _run_in_terminal )
283+ monkeypatch .setattr (
284+ _live_view_mod ,
285+ "emit_scrollback_block" ,
286+ lambda _console , renderable : None ,
287+ )
288+
289+ clock = {"now" : 1000.0 }
290+ monkeypatch .setattr (_interactive_mod .time , "monotonic" , lambda : clock ["now" ])
291+
292+ view = _PromptLiveView (
293+ StatusUpdate (),
294+ prompt_session = cast (Any , _PromptSession ()),
295+ steer = lambda _content : None ,
296+ )
297+
298+ # Throttled call consumes the budget.
299+ block_a = _ContentBlock (is_think = False )
300+ block_a .append ("First.\n \n tail" )
301+ view ._current_content_block = block_a
302+ assert await view ._maybe_emit_incremental_commits () is True
303+
304+ # Within the interval, the DIRECT method still flushes (used by transition/finalize).
305+ block_b = _ContentBlock (is_think = False )
306+ block_b .append ("Second.\n \n tail" )
307+ view ._current_content_block = block_b
308+ assert await view ._emit_incremental_content_commits () is True
309+
310+ assert terminal_handoffs == ["run" , "run" ]
311+
312+
313+ @pytest .mark .asyncio
314+ async def test_many_completed_paragraphs_batch_into_one_terminal_handoff (monkeypatch ) -> None :
315+ """N completed paragraphs in one tick coalesce into a SINGLE teardown, not N."""
316+ from pythinker_code .ui .shell .visualize ._blocks import _ContentBlock
317+
318+ terminal_handoffs : list [str ] = []
319+ printed : list [object ] = []
320+
321+ class _PromptSession :
322+ def invalidate (self ) -> None :
323+ pass
324+
325+ async def _run_in_terminal (func , * args , ** kwargs ): # noqa: ANN001, ANN002, ANN003
326+ terminal_handoffs .append ("run" )
327+ func ()
328+
329+ monkeypatch .setattr (_interactive_mod , "run_in_terminal" , _run_in_terminal )
330+ monkeypatch .setattr (
331+ _live_view_mod ,
332+ "emit_scrollback_block" ,
333+ lambda _console , renderable : printed .append (renderable ),
334+ )
335+ monkeypatch .setattr (_interactive_mod .time , "monotonic" , lambda : 1000.0 )
336+
337+ view = _PromptLiveView (
338+ StatusUpdate (),
339+ prompt_session = cast (Any , _PromptSession ()),
340+ steer = lambda _content : None ,
341+ )
342+ block = _ContentBlock (is_think = False )
343+ for paragraph in ("p1.\n \n " , "p2.\n \n " , "p3.\n \n " ):
344+ block .append (paragraph )
345+ block .append ("tail" ) # three committable paragraphs
346+ assert len (block ._committed_renderables ) >= 3
347+ view ._current_content_block = block
348+
349+ assert await view ._maybe_emit_incremental_commits () is True
350+ # One teardown for all three paragraphs; each paragraph printed exactly once.
351+ assert terminal_handoffs == ["run" ]
352+ assert len (printed ) >= 3
353+
354+
355+ @pytest .mark .asyncio
356+ async def test_suppressed_incremental_commit_remains_visible_in_live_preview (monkeypatch ) -> None :
357+ """A throttled-away commit is NOT taken from the block, so the preview still shows it."""
358+ from pythinker_code .ui .shell .visualize ._blocks import _ContentBlock
359+
360+ async def _run_in_terminal (func , * args , ** kwargs ): # noqa: ANN001, ANN002, ANN003
361+ func ()
362+
363+ class _PromptSession :
364+ def invalidate (self ) -> None :
365+ pass
366+
367+ monkeypatch .setattr (_interactive_mod , "run_in_terminal" , _run_in_terminal )
368+ monkeypatch .setattr (
369+ _live_view_mod , "emit_scrollback_block" , lambda _console , renderable : None
370+ )
371+ monkeypatch .setattr (_interactive_mod .time , "monotonic" , lambda : 1000.0 )
372+
373+ view = _PromptLiveView (
374+ StatusUpdate (),
375+ prompt_session = cast (Any , _PromptSession ()),
376+ steer = lambda _content : None ,
377+ )
378+ view ._last_incremental_commit_at = 1000.0 # interval not yet elapsed -> suppress
379+
380+ block = _ContentBlock (is_think = False )
381+ block .append ("Visible paragraph.\n \n tail" )
382+ before = list (block ._committed_renderables )
383+ assert before # something is committable
384+ view ._current_content_block = block
385+
386+ assert await view ._maybe_emit_incremental_commits () is False
387+ # Suppressed: renderables retained verbatim, still rendering in the preview.
388+ assert block ._committed_renderables == before
389+
390+
391+ @pytest .mark .asyncio
392+ async def test_throttled_committed_blocks_eventually_flush_on_direct_drain (monkeypatch ) -> None :
393+ """After suppression, the direct drain (transition/turn-end) clears the block."""
394+ from pythinker_code .ui .shell .visualize ._blocks import _ContentBlock
395+
396+ async def _run_in_terminal (func , * args , ** kwargs ): # noqa: ANN001, ANN002, ANN003
397+ func ()
398+
399+ class _PromptSession :
400+ def invalidate (self ) -> None :
401+ pass
402+
403+ monkeypatch .setattr (_interactive_mod , "run_in_terminal" , _run_in_terminal )
404+ monkeypatch .setattr (
405+ _live_view_mod , "emit_scrollback_block" , lambda _console , renderable : None
406+ )
407+ monkeypatch .setattr (_interactive_mod .time , "monotonic" , lambda : 1000.0 )
408+
409+ view = _PromptLiveView (
410+ StatusUpdate (),
411+ prompt_session = cast (Any , _PromptSession ()),
412+ steer = lambda _content : None ,
413+ )
414+ view ._last_incremental_commit_at = 1000.0 # suppress the throttled path
415+
416+ block = _ContentBlock (is_think = False )
417+ block .append ("Pending.\n \n tail" )
418+ view ._current_content_block = block
419+ assert await view ._maybe_emit_incremental_commits () is False
420+ assert block ._committed_renderables # still pending
421+
422+ # Finalize/transition path drains directly and leaves nothing stranded.
423+ assert await view ._emit_incremental_content_commits () is True
424+ assert not block ._committed_renderables
425+
426+
427+ @pytest .mark .asyncio
428+ async def test_throttled_commits_do_not_duplicate_scrollback (monkeypatch ) -> None :
429+ """A suppressed-then-drained commit prints each renderable exactly once (no dupes)."""
430+ from pythinker_code .ui .shell .visualize ._blocks import _ContentBlock
431+
432+ printed : list [object ] = []
433+
434+ async def _run_in_terminal (func , * args , ** kwargs ): # noqa: ANN001, ANN002, ANN003
435+ func ()
436+
437+ class _PromptSession :
438+ def invalidate (self ) -> None :
439+ pass
440+
441+ monkeypatch .setattr (_interactive_mod , "run_in_terminal" , _run_in_terminal )
442+ monkeypatch .setattr (
443+ _live_view_mod ,
444+ "emit_scrollback_block" ,
445+ lambda _console , renderable : printed .append (id (renderable )),
446+ )
447+ monkeypatch .setattr (_interactive_mod .time , "monotonic" , lambda : 1000.0 )
448+
449+ view = _PromptLiveView (
450+ StatusUpdate (),
451+ prompt_session = cast (Any , _PromptSession ()),
452+ steer = lambda _content : None ,
453+ )
454+ view ._last_incremental_commit_at = 1000.0 # suppress first
455+
456+ block = _ContentBlock (is_think = False )
457+ block .append ("Once.\n \n tail" )
458+ view ._current_content_block = block
459+
460+ assert await view ._maybe_emit_incremental_commits () is False # suppressed, nothing printed
461+ assert await view ._emit_incremental_content_commits () is True # direct drain prints once
462+ # No renderable printed twice across the suppressed call + the drain.
463+ assert len (printed ) == len (set (printed ))
464+
465+
211466def test_render_pinned_status_tail_returns_spinner_when_turn_active () -> None :
212467 import time as _time
213468
0 commit comments