diff --git a/CHANGELOG.md b/CHANGELOG.md index b3cd2bfd4..55341d793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Changed `wf10_batch.robot` to be less likely to create files, and - **fix(cli/plan): plan correct JSON output envelope fix and BDD test coverage** (#8584 / PR #8662): Restructured `agents plan correct --format json` output to nest correction fields under `data.correction` (e.g., `data.correction.mode`) and populate the spec-required CLI envelope with `command="plan correct"`, `status`, `exit_code`, `timing`, and `messages` fields. Added three BDD scenarios in `features/tdd_plan_correct_json_output.feature` validating the envelope structure for both revert and append modes. - **fix(cli): add --url flag to resource add for git resource type** (#6322): Added support for the `--url` flag on `agents resource add git` command, allowing users to specify a remote URL for git resources. The flag is validated to only apply to git resource types. Includes Behave BDD tests in `features/resource_cli_git_url_flag.feature` and Robot Framework integration tests verifying correct URL validation and CLI behavior. - **Session create JSON envelope** (#6441): Fixed `agents session create --format json` returning a flat `data` dict instead of the spec-required nested structure with `data.session`, `data.settings`, and `data.actor_details` sub-objects. The `command` field is now populated correctly. Extended JSON envelope coverage to `agents session list`, `show`, `delete --format json`, `export --output-format json`, and `import --format json` so all session commands emit a structured `messages[].text` field (`"0 sessions listed"`, `"Session details loaded"`, `"Session deleted"`, `"Export completed"`, `"Import completed"`). +- **feat(tui): conversation content pruning** (#6350): Added `ConversationStream` to the TUI layer implementing hysteresis-based line-count pruning. When the rendered conversation exceeds `trigger_line_count` (`prune_low_mark + prune_excess`, defaults 1 500 + 1 000 = 2 500 lines), the oldest non-protected blocks are removed until total lines falls back to `prune_low_mark`. A styled pruning note is inserted at the head of the visible conversation. Pruning thresholds are configurable via `~/.config/cleveragents/tui-settings.json` (`ui.prune_low_mark`, `ui.prune_excess`). Includes Behave BDD tests, Robot Framework integration tests, and ASV performance benchmarks. - **fix(resources): remove unsupported executable resource type and fix resource list columns** (#3077 / PR #3248): Removed `executable` from `LSP_RESOURCE_TYPES` and `BUILTIN_TYPE_NAMES` (the specification defines no such built-in type). Updated `agents resource list` CLI table columns from `[ID, Name, Type, Status, Kind, Location, Description]` to the spec-required `[Name, ID, Type, Phys/Virt, Children, Projects]`. Deleted orphaned `examples/resource-types/executable.yaml`. Lifecycle state for container resources is now displayed as a note below the resource table. - **fix(cli): add Read-Only and Writes columns to tool list output** (#1476): Rewrote `list_tools()` in `src/cleveragents/cli/commands/tool.py` to render exactly the 5 diff --git a/benchmarks/conversation_stream_bench.py b/benchmarks/conversation_stream_bench.py new file mode 100644 index 000000000..3c6b2d2a2 --- /dev/null +++ b/benchmarks/conversation_stream_bench.py @@ -0,0 +1,99 @@ +"""ASV benchmarks for ConversationStream pruning performance. + +Measures the performance of: +- ConversationStream.add_block() with no pruning (baseline) +- ConversationStream.add_block() under heavy load (pruning fires on every block) +- ConversationStream.render() on a pruned stream +- ConversationStream.clear() followed by repopulation +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +try: + from cleveragents.tui.conversation import ConversationSettings, ConversationStream +except ModuleNotFoundError: + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + from cleveragents.tui.conversation import ConversationSettings, ConversationStream + + +def _make_stream( + prune_low_mark: int = 1_500, + prune_excess: int = 1_000, + preserve_recent_lines: int = 500, +) -> ConversationStream: + settings = ConversationSettings( + prune_low_mark=prune_low_mark, + prune_excess=prune_excess, + preserve_recent_lines=preserve_recent_lines, + ) + return ConversationStream(settings=settings) + + +_BLOCK_10_LINES = "\n".join(f"bench line {i}" for i in range(10)) +_BLOCK_50_LINES = "\n".join(f"bench line {i}" for i in range(50)) + + +class ConversationStreamAddBlockSuite: + """Benchmark ConversationStream.add_block under varying load.""" + + def setup(self): + # Pre-built stream near (but not at) the default trigger threshold. + self.stream_near_threshold = _make_stream() + for i in range(240): + self.stream_near_threshold.add_block(_BLOCK_10_LINES, block_type="message") + # (240 * 10 = 2400 lines, trigger = 2500 — still below threshold) + + def time_add_block_no_prune(self): + """Baseline: add a single block to an empty stream (no pruning).""" + stream = _make_stream() + stream.add_block(_BLOCK_10_LINES, block_type="message") + + def time_add_block_triggers_prune(self): + """Add one block past the threshold so pruning fires once.""" + stream = _make_stream() + for i in range(241): + stream.add_block(_BLOCK_10_LINES, block_type="message") + + def time_add_block_heavy_load(self): + """Add 500 blocks to a tight-threshold stream (pruning fires repeatedly).""" + stream = _make_stream(prune_low_mark=100, prune_excess=50) + for _ in range(500): + stream.add_block(_BLOCK_10_LINES, block_type="message") + + def time_add_block_large_blocks(self): + """Add 50-line blocks; fewer blocks needed to trigger pruning.""" + stream = _make_stream(prune_low_mark=100, prune_excess=50) + for _ in range(100): + stream.add_block(_BLOCK_50_LINES, block_type="message") + + +class ConversationStreamRenderSuite: + """Benchmark ConversationStream.render() after pruning.""" + + def setup(self): + self.stream = _make_stream(prune_low_mark=100, prune_excess=50) + for i in range(200): + self.stream.add_block(_BLOCK_10_LINES, block_type="message") + + def time_render_pruned_stream(self): + """Benchmark render() on a stream that has been pruned.""" + self.stream.render() + + +class ConversationStreamClearSuite: + """Benchmark ConversationStream.clear() and repopulation.""" + + def setup(self): + self.block = _BLOCK_10_LINES + + def time_clear_and_repopulate(self): + """Benchmark clear() followed by adding 50 blocks.""" + stream = _make_stream() + for _ in range(50): + stream.add_block(self.block, block_type="message") + stream.clear() + for _ in range(50): + stream.add_block(self.block, block_type="message") diff --git a/features/steps/tui_conversation_pruning_steps.py b/features/steps/tui_conversation_pruning_steps.py index 9b20b58ba..243d1b2ba 100644 --- a/features/steps/tui_conversation_pruning_steps.py +++ b/features/steps/tui_conversation_pruning_steps.py @@ -5,23 +5,27 @@ from __future__ import annotations from behave import then, when -@when('I deliver {count:d} conversation messages of {line_count:d} lines each') +@when("I deliver {count:d} conversation messages of {line_count:d} lines each") def step_deliver_messages(context, count, line_count): app = context._tui_app for index in range(1, count + 1): lines = [f"message-{index} line-{line}" for line in range(1, line_count + 1)] - payload = '\n'.join(lines) - app._append_conversation_block(payload, block_type='message') + payload = "\n".join(lines) + app._append_conversation_block(payload, block_type="message") @then('the conversation widget should not contain "{text}"') def step_conversation_not_contains(context, text): mock_static = context._tui_mock_static - conversation = context._tui_app.query_one('#conversation', mock_static) - assert text not in conversation._text, f"Unexpected '{text}' in conversation: {conversation._text!r}" + conversation = context._tui_app.query_one("#conversation", mock_static) + assert text not in conversation._text, ( + f"Unexpected '{text}' in conversation: {conversation._text!r}" + ) -@then('the session transcript should contain {count:d} entries') +@then("the session transcript should contain {count:d} entries") def step_transcript_entry_count(context, count): transcript = context._tui_app._session.transcript - assert len(transcript) == count, f"Expected {count} transcript entries, found {len(transcript)}" + assert len(transcript) == count, ( + f"Expected {count} transcript entries, found {len(transcript)}" + ) diff --git a/robot/helper_tui_conversation_pruning.py b/robot/helper_tui_conversation_pruning.py new file mode 100644 index 000000000..3f2641b80 --- /dev/null +++ b/robot/helper_tui_conversation_pruning.py @@ -0,0 +1,152 @@ +"""Helper script for tui_conversation_pruning.robot integration tests. + +Each subcommand is a self-contained check that prints a sentinel on success. +Tests ConversationStream pruning behaviour directly — no Textual event loop required. +""" + +# ruff: noqa: E402 +from __future__ import annotations + +import sys +from pathlib import Path + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.tui.conversation import ( + DEFAULT_PRUNE_EXCESS, + DEFAULT_PRUNE_LOW_MARK, + ConversationSettings, + ConversationStream, + load_conversation_settings, +) + + +def _make_stream( + prune_low_mark: int = 100, + prune_excess: int = 50, + preserve_recent_lines: int = 10, +) -> ConversationStream: + settings = ConversationSettings( + prune_low_mark=prune_low_mark, + prune_excess=prune_excess, + preserve_recent_lines=preserve_recent_lines, + ) + return ConversationStream(settings=settings) + + +def _multiline_block(n_lines: int, label: str = "line") -> str: + return "\n".join(f"{label} {i}" for i in range(n_lines)) + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +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 200 lines total + for i in range(20): + stream.add_block(_multiline_block(10, f"msg{i}"), block_type="message") + + assert stream.total_lines <= 100, ( + f"Expected total_lines <= prune_low_mark (100), got {stream.total_lines}" + ) + print("tui-prune-trigger-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) + for i in range(20): + stream.add_block(_multiline_block(10, f"msg{i}"), block_type="message") + + blocks = stream.blocks + assert len(blocks) > 0, "Expected at least one block after pruning" + first = blocks[0] + assert first.block_type == "note", ( + f"Expected first block to be 'note', got {first.block_type!r}" + ) + assert "(Earlier messages pruned" in first.text, ( + f"Expected pruned-note text in first block, got: {first.text!r}" + ) + print("tui-prune-note-inserted-ok") + + +def cmd_prune_protected() -> None: + """Verify protected blocks survive pruning.""" + stream = _make_stream(prune_low_mark=100, prune_excess=50) + protected_text = "protected welcome block" + stream.add_block(protected_text, block_type="welcome", protected=True) + + for i in range(20): + stream.add_block(_multiline_block(10, f"msg{i}"), block_type="message") + + texts = [b.text for b in stream.blocks] + assert protected_text in texts, ( + f"Protected block was removed during pruning. Block types: " + f"{[b.block_type for b in stream.blocks]}" + ) + print("tui-prune-protected-ok") + + +def cmd_clear_resets_state() -> None: + """Verify clear() resets blocks, total_lines, and the note-active state.""" + stream = _make_stream(prune_low_mark=100, prune_excess=50) + for i in range(20): + stream.add_block(_multiline_block(10, f"msg{i}"), block_type="message") + + assert len(stream.blocks) > 0, "Expected blocks before clear" + + stream.clear() + + assert len(stream.blocks) == 0, ( + f"Expected 0 blocks after clear, got {len(stream.blocks)}" + ) + assert stream.total_lines == 0, ( + f"Expected total_lines == 0 after clear, got {stream.total_lines}" + ) + # After clear, a single small block should not trigger pruning + stream.add_block("hello", block_type="message") + assert len(stream.blocks) == 1, ( + f"Expected 1 block after clear + add, got {len(stream.blocks)}" + ) + print("tui-clear-resets-state-ok") + + +def cmd_settings_defaults() -> None: + """Verify load_conversation_settings returns defaults when config file is absent.""" + import tempfile + + absent_path = Path(tempfile.mkdtemp()) / "no-such-file.json" + settings = load_conversation_settings(config_path=absent_path) + + assert settings.prune_low_mark == DEFAULT_PRUNE_LOW_MARK, ( + f"prune_low_mark: {settings.prune_low_mark} != {DEFAULT_PRUNE_LOW_MARK}" + ) + assert settings.prune_excess == DEFAULT_PRUNE_EXCESS, ( + f"Expected prune_excess={DEFAULT_PRUNE_EXCESS}, got {settings.prune_excess}" + ) + print("tui-settings-defaults-ok") + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +COMMANDS = { + "prune-trigger": cmd_prune_trigger, + "prune-note-inserted": cmd_prune_note_inserted, + "prune-protected": cmd_prune_protected, + "clear-resets-state": cmd_clear_resets_state, + "settings-defaults": cmd_settings_defaults, +} + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + print(f"Usage: {sys.argv[0]} [{' | '.join(COMMANDS)}]", file=sys.stderr) + sys.exit(1) + COMMANDS[sys.argv[1]]() diff --git a/robot/tui_conversation_pruning.robot b/robot/tui_conversation_pruning.robot new file mode 100644 index 000000000..75f8e93d3 --- /dev/null +++ b/robot/tui_conversation_pruning.robot @@ -0,0 +1,69 @@ +*** Settings *** +Documentation Integration tests for TUI conversation content pruning. +... +... Verifies that: +... - ConversationStream enforces prune_low_mark / prune_excess thresholds +... - Oldest non-protected blocks are removed when the threshold is exceeded +... - A pruning note is inserted at position 0 after pruning +... - Protected blocks survive pruning +... - clear() resets all state +... - load_conversation_settings returns defaults when config file absent +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment With Database Isolation +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_tui_conversation_pruning.py + +*** Test Cases *** +ConversationStream Prunes Oldest Blocks When Threshold Exceeded + [Documentation] Verify pruning fires when total_lines exceeds trigger_line_count + ... and total_lines is brought back down to prune_low_mark. + [Tags] tui_conversation_pruning tdd_issue tdd_issue_6350 + ${result}= Run Process ${PYTHON} ${HELPER} prune-trigger + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-prune-trigger-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 + ${result}= Run Process ${PYTHON} ${HELPER} prune-note-inserted + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-prune-note-inserted-ok + +ConversationStream Respects Protected Blocks During Pruning + [Documentation] Verify protected blocks are never removed during pruning. + [Tags] tui_conversation_pruning tdd_issue tdd_issue_6350 + ${result}= Run Process ${PYTHON} ${HELPER} prune-protected + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-prune-protected-ok + +ConversationStream Clear Resets All State + [Documentation] Verify clear() resets blocks, total_lines, and note state. + [Tags] tui_conversation_pruning tdd_issue tdd_issue_6350 + ${result}= Run Process ${PYTHON} ${HELPER} clear-resets-state + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-clear-resets-state-ok + +load_conversation_settings Returns Defaults When File Absent + [Documentation] Verify load_conversation_settings returns ConversationSettings() + ... defaults when the config file does not exist. + [Tags] tui_conversation_pruning tdd_issue tdd_issue_6350 + ${result}= Run Process ${PYTHON} ${HELPER} settings-defaults + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-settings-defaults-ok diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 0de40612e..7b0cf0227 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -317,9 +317,7 @@ if _TEXTUAL_AVAILABLE: def compose(self) -> Any: yield _Header(show_clock=True) with _Vertical(id="main-column"): - yield _Static( - self._conversation_stream.render(), id="conversation" - ) + yield _Static(self._conversation_stream.render(), id="conversation") yield HelpPanelOverlay(id="help-panel") yield ReferencePickerOverlay(id="reference-picker") yield SlashCommandOverlay(id="slash-overlay") @@ -549,9 +547,7 @@ if _TEXTUAL_AVAILABLE: # Append the user message to the conversation stream so pruning # applies, then render with a transient "Thinking..." indicator. - self._append_conversation_block( - f"You: {expanded}", block_type="message" - ) + self._append_conversation_block(f"You: {expanded}", block_type="message") conversation.update( self._conversation_stream.render() + "\n\nā³ Thinking..." ) @@ -610,9 +606,7 @@ if _TEXTUAL_AVAILABLE: assistant_text = parts[1] if len(parts) == 2 else outcome else: assistant_text = outcome - self._append_conversation_block( - assistant_text, block_type="message" - ) + self._append_conversation_block(assistant_text, block_type="message") worker = self.run_worker( _dispatch_llm, thread=True, exclusive=True, name="llm-dispatch" diff --git a/src/cleveragents/tui/conversation.py b/src/cleveragents/tui/conversation.py index c29b2a305..6527634ca 100644 --- a/src/cleveragents/tui/conversation.py +++ b/src/cleveragents/tui/conversation.py @@ -1,4 +1,5 @@ """Conversation stream utilities for the CleverAgents TUI.""" + from __future__ import annotations import json @@ -28,7 +29,15 @@ def _normalise_positive(value: int, *, minimum: int, maximum: int) -> int: @dataclass(slots=True) class ConversationSettings: - """Runtime configuration controlling conversation pruning behaviour.""" + """Runtime configuration controlling conversation pruning behaviour. + + Two thresholds implement hysteresis to prevent thrashing at the boundary: + ``prune_excess`` is added to ``prune_low_mark`` to form + ``trigger_line_count`` (the level at which pruning fires). Pruning then + removes blocks until the count falls back to ``prune_low_mark``, so a + stream hovering near the boundary is not pruned on every single added + block. + """ prune_low_mark: int = DEFAULT_PRUNE_LOW_MARK prune_excess: int = DEFAULT_PRUNE_EXCESS @@ -74,6 +83,8 @@ class ConversationStream: self._settings = settings or ConversationSettings() self._blocks: list[ConversationBlock] = [] self._total_lines = 0 + # Invariant: when True, _blocks[0] is a ConversationBlock with + # block_type == "note". Maintained by _insert_pruned_note(). self._note_active = False @property @@ -209,15 +220,20 @@ class ConversationStream: def load_conversation_settings(config_path: Path | None = None) -> ConversationSettings: - """Load pruning settings from the TUI settings file if available.""" + """Load pruning settings from the TUI settings file if available. + Reads ``ui.prune_low_mark`` and ``ui.prune_excess`` from + ``~/.config/cleveragents/tui-settings.json`` (or *config_path* when + supplied). Returns ``ConversationSettings()`` defaults on any read or + parse failure so the TUI always starts in a valid state. + """ path = config_path or Path.home() / ".config" / "cleveragents" / "tui-settings.json" if not path.exists(): return ConversationSettings() try: data = json.loads(path.read_text(encoding="utf-8")) - except Exception: + except (OSError, json.JSONDecodeError): return ConversationSettings() ui_config = data.get("ui", {}) if isinstance(data, dict) else {} @@ -230,5 +246,5 @@ def load_conversation_settings(config_path: Path | None = None) -> ConversationS prune_excess=int(excess), preserve_recent_lines=DEFAULT_PRESERVE_RECENT_LINES, ) - except Exception: + except (ValueError, TypeError): return ConversationSettings()