fix(tui): count conversation separators for pruning
CI / load-versions (pull_request) Successful in 16s
CI / push-validation (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 50s
CI / quality (pull_request) Successful in 57s
CI / typecheck (pull_request) Successful in 1m3s
CI / security (pull_request) Successful in 1m4s
CI / build (pull_request) Successful in 43s
CI / helm (pull_request) Successful in 40s
CI / unit_tests (pull_request) Successful in 7m7s
CI / docker (pull_request) Successful in 2m11s
CI / integration_tests (pull_request) Successful in 11m38s
CI / coverage (pull_request) Successful in 10m18s
CI / status-check (pull_request) Successful in 3s
CI / load-versions (pull_request) Successful in 16s
CI / push-validation (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 50s
CI / quality (pull_request) Successful in 57s
CI / typecheck (pull_request) Successful in 1m3s
CI / security (pull_request) Successful in 1m4s
CI / build (pull_request) Successful in 43s
CI / helm (pull_request) Successful in 40s
CI / unit_tests (pull_request) Successful in 7m7s
CI / docker (pull_request) Successful in 2m11s
CI / integration_tests (pull_request) Successful in 11m38s
CI / coverage (pull_request) Successful in 10m18s
CI / status-check (pull_request) Successful in 3s
This commit is contained in:
@@ -186,6 +186,30 @@ def step_assert_lines_bound(context, limit):
|
||||
)
|
||||
|
||||
|
||||
def _rendered_line_count(stream: ConversationStream) -> int:
|
||||
rendered = stream.render()
|
||||
if not rendered:
|
||||
return 0
|
||||
return rendered.count("\n") + 1
|
||||
|
||||
|
||||
@then("the stream total_lines should equal the rendered line count")
|
||||
def step_assert_total_matches_rendered_lines(context):
|
||||
rendered_line_count = _rendered_line_count(context._conv_stream)
|
||||
assert context._conv_stream.total_lines == rendered_line_count, (
|
||||
f"expected total_lines to match rendered line count {rendered_line_count}, "
|
||||
f"got {context._conv_stream.total_lines}"
|
||||
)
|
||||
|
||||
|
||||
@then("the stream rendered line count should be less than or equal to {limit:d}")
|
||||
def step_assert_rendered_line_count_bound(context, limit):
|
||||
rendered_line_count = _rendered_line_count(context._conv_stream)
|
||||
assert rendered_line_count <= limit, (
|
||||
f"expected rendered line count <= {limit}, got {rendered_line_count}"
|
||||
)
|
||||
|
||||
|
||||
@then('the stream rendered text should contain "{text}"')
|
||||
def step_assert_render_contains(context, text):
|
||||
rendered = context._conv_stream.render()
|
||||
|
||||
@@ -89,6 +89,15 @@ Feature: TUI conversation module coverage
|
||||
Then the stream rendered text should contain "Protected welcome"
|
||||
And the stream total_lines should be less than or equal to 350
|
||||
|
||||
@tdd_issue @tdd_issue_6350
|
||||
Scenario: ConversationStream pruning counts rendered separators between blocks
|
||||
Given a fresh ConversationStream with low_mark 100 excess 50 preserve_recent_lines 0
|
||||
When I deliver 100 plain blocks of 1 lines each through add_block
|
||||
Then the stream should have exactly 1 note block
|
||||
And the stream rendered text should not contain "plain-1-line-1"
|
||||
And the stream total_lines should equal the rendered line count
|
||||
And the stream rendered line count should be less than or equal to 150
|
||||
|
||||
@tdd_issue @tdd_issue_6350
|
||||
Scenario: ConversationStream pruning updates the note in place rather than re-inserting
|
||||
Given a fresh ConversationStream with low_mark 200 excess 50 preserve_recent_lines 100
|
||||
|
||||
@@ -40,6 +40,13 @@ def _multiline_block(n_lines: int, label: str = "line") -> str:
|
||||
return "\n".join(f"{label} {i}" for i in range(n_lines))
|
||||
|
||||
|
||||
def _rendered_line_count(stream: ConversationStream) -> int:
|
||||
rendered = stream.render()
|
||||
if not rendered:
|
||||
return 0
|
||||
return rendered.count("\n") + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommands
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -48,9 +55,9 @@ def _multiline_block(n_lines: int, label: str = "line") -> str:
|
||||
def cmd_prune_trigger() -> None:
|
||||
"""Verify pruning fires and total_lines returns to <= prune_low_mark."""
|
||||
stream = _make_stream(prune_low_mark=100, prune_excess=50)
|
||||
# trigger_line_count = 150; push 210 lines to trigger pruning twice,
|
||||
# trigger_line_count = 150; push enough rendered lines to trigger pruning twice,
|
||||
# ensuring total_lines ends at prune_low_mark or below.
|
||||
for i in range(21):
|
||||
for i in range(24):
|
||||
stream.add_block(_multiline_block(10, f"msg{i}"), block_type="message")
|
||||
|
||||
assert stream.total_lines <= 100, (
|
||||
@@ -59,6 +66,33 @@ def cmd_prune_trigger() -> None:
|
||||
print("tui-prune-trigger-ok")
|
||||
|
||||
|
||||
def cmd_separator_aware_prune() -> None:
|
||||
"""Verify rendered inter-block separators count toward pruning thresholds."""
|
||||
stream = _make_stream(
|
||||
prune_low_mark=100,
|
||||
prune_excess=50,
|
||||
preserve_recent_lines=0,
|
||||
)
|
||||
|
||||
for i in range(100):
|
||||
stream.add_block(f"one-line-{i}", block_type="message")
|
||||
|
||||
rendered = stream.render()
|
||||
assert stream.total_lines == _rendered_line_count(stream), (
|
||||
"Expected total_lines to match rendered line count, "
|
||||
f"got total_lines={stream.total_lines}, rendered={_rendered_line_count(stream)}"
|
||||
)
|
||||
assert stream.total_lines <= 150, (
|
||||
"Expected rendered lines to stay within trigger threshold, "
|
||||
f"got {stream.total_lines}"
|
||||
)
|
||||
assert "one-line-0" not in rendered, "Expected oldest one-line block to be pruned"
|
||||
assert any(block.block_type == "note" for block in stream.blocks), (
|
||||
"Expected pruning note after separator-aware pruning"
|
||||
)
|
||||
print("tui-separator-aware-prune-ok")
|
||||
|
||||
|
||||
def cmd_prune_note_inserted() -> None:
|
||||
"""Verify a note block is at index 0 with the expected text after pruning."""
|
||||
stream = _make_stream(prune_low_mark=100, prune_excess=50)
|
||||
@@ -140,6 +174,7 @@ def cmd_settings_defaults() -> None:
|
||||
|
||||
COMMANDS = {
|
||||
"prune-trigger": cmd_prune_trigger,
|
||||
"separator-aware-prune": cmd_separator_aware_prune,
|
||||
"prune-note-inserted": cmd_prune_note_inserted,
|
||||
"prune-protected": cmd_prune_protected,
|
||||
"clear-resets-state": cmd_clear_resets_state,
|
||||
|
||||
@@ -27,6 +27,17 @@ ConversationStream Prunes Oldest Blocks When Threshold Exceeded
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} tui-prune-trigger-ok
|
||||
|
||||
ConversationStream Counts Rendered Separators When Pruning
|
||||
[Documentation] Verify many one-line blocks prune based on rendered blank
|
||||
... separators, not only block-local line counts.
|
||||
[Tags] tui_conversation_pruning tdd_issue tdd_issue_6350
|
||||
${result}= Run Process ${PYTHON} ${HELPER} separator-aware-prune
|
||||
... cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} tui-separator-aware-prune-ok
|
||||
|
||||
ConversationStream Inserts Note Block After Pruning
|
||||
[Documentation] Verify a pruned-note block is inserted at index 0 after pruning.
|
||||
[Tags] tui_conversation_pruning tdd_issue tdd_issue_6350
|
||||
|
||||
@@ -83,6 +83,7 @@ class ConversationStream:
|
||||
self._settings = settings or ConversationSettings()
|
||||
self._blocks: list[ConversationBlock] = []
|
||||
self._total_lines = 0
|
||||
self._visible_blocks = 0
|
||||
# Invariant: when True, _blocks[0] is a ConversationBlock with
|
||||
# block_type == "note". Maintained by _insert_pruned_note().
|
||||
self._note_active = False
|
||||
@@ -105,6 +106,7 @@ class ConversationStream:
|
||||
self._blocks = [
|
||||
ConversationBlock(text=welcome_text, block_type="welcome", protected=True)
|
||||
]
|
||||
self._visible_blocks = 1 if welcome_text else 0
|
||||
self._total_lines = self._blocks[0].line_count()
|
||||
self._note_active = False
|
||||
|
||||
@@ -113,6 +115,7 @@ class ConversationStream:
|
||||
|
||||
self._blocks.clear()
|
||||
self._total_lines = 0
|
||||
self._visible_blocks = 0
|
||||
self._note_active = False
|
||||
|
||||
def extend(self, blocks: Iterable[ConversationBlock]) -> None:
|
||||
@@ -143,7 +146,7 @@ class ConversationStream:
|
||||
markup=markup,
|
||||
)
|
||||
self._blocks.append(block)
|
||||
self._total_lines += block.line_count()
|
||||
self._add_block_lines(block)
|
||||
self._prune_if_needed()
|
||||
|
||||
def render(self) -> str:
|
||||
@@ -165,6 +168,15 @@ class ConversationStream:
|
||||
if self._total_lines <= self._settings.trigger_line_count:
|
||||
return
|
||||
|
||||
pruned = self._prune_oldest_blocks_to_low_mark()
|
||||
|
||||
if pruned:
|
||||
self._insert_pruned_note()
|
||||
self._prune_oldest_blocks_to_low_mark()
|
||||
|
||||
def _prune_oldest_blocks_to_low_mark(self) -> bool:
|
||||
"""Remove oldest eligible blocks until rendered lines reach the low mark."""
|
||||
|
||||
preserve_start = self._determine_preserve_start()
|
||||
pruned = False
|
||||
index = 0
|
||||
@@ -175,13 +187,11 @@ class ConversationStream:
|
||||
if block.protected:
|
||||
index += 1
|
||||
continue
|
||||
self._total_lines -= block.line_count()
|
||||
self._remove_block_lines(block)
|
||||
self._blocks.pop(index)
|
||||
preserve_start -= 1
|
||||
pruned = True
|
||||
|
||||
if pruned:
|
||||
self._insert_pruned_note()
|
||||
return pruned
|
||||
|
||||
def _determine_preserve_start(self) -> int:
|
||||
"""Return the index from which blocks must be preserved."""
|
||||
@@ -190,9 +200,15 @@ class ConversationStream:
|
||||
return len(self._blocks)
|
||||
|
||||
preserved_lines = 0
|
||||
preserved_visible_blocks = 0
|
||||
index = len(self._blocks) - 1
|
||||
while index >= 0 and preserved_lines < self._settings.preserve_recent_lines:
|
||||
preserved_lines += self._blocks[index].line_count()
|
||||
block = self._blocks[index]
|
||||
if block.text:
|
||||
preserved_lines += block.line_count()
|
||||
if preserved_visible_blocks:
|
||||
preserved_lines += 1
|
||||
preserved_visible_blocks += 1
|
||||
index -= 1
|
||||
return max(0, index + 1)
|
||||
|
||||
@@ -204,8 +220,10 @@ class ConversationStream:
|
||||
if self._note_active and self._blocks:
|
||||
first = self._blocks[0]
|
||||
if first.block_type == "note":
|
||||
previous_line_count = first.line_count()
|
||||
first.text = note_markup
|
||||
first.markup = True
|
||||
self._total_lines += first.line_count() - previous_line_count
|
||||
return
|
||||
|
||||
note_block = ConversationBlock(
|
||||
@@ -215,9 +233,31 @@ class ConversationStream:
|
||||
markup=True,
|
||||
)
|
||||
self._blocks.insert(0, note_block)
|
||||
self._total_lines += note_block.line_count()
|
||||
self._add_block_lines(note_block)
|
||||
self._note_active = True
|
||||
|
||||
def _add_block_lines(self, block: ConversationBlock) -> None:
|
||||
"""Add a block's rendered line contribution to the running total."""
|
||||
|
||||
if not block.text:
|
||||
return
|
||||
|
||||
self._total_lines += block.line_count()
|
||||
if self._visible_blocks:
|
||||
self._total_lines += 1
|
||||
self._visible_blocks += 1
|
||||
|
||||
def _remove_block_lines(self, block: ConversationBlock) -> None:
|
||||
"""Remove a block's rendered line contribution from the running total."""
|
||||
|
||||
if not block.text:
|
||||
return
|
||||
|
||||
self._total_lines -= block.line_count()
|
||||
if self._visible_blocks > 1:
|
||||
self._total_lines -= 1
|
||||
self._visible_blocks -= 1
|
||||
|
||||
|
||||
def load_conversation_settings(config_path: Path | None = None) -> ConversationSettings:
|
||||
"""Load pruning settings from the TUI settings file if available.
|
||||
|
||||
Reference in New Issue
Block a user