From 4ea39aa87352f82f498eb2fceb82958a58cc5637 Mon Sep 17 00:00:00 2001 From: Rui Hu Date: Tue, 31 Mar 2026 05:08:04 +0000 Subject: [PATCH] feat(tui): implement tool call expand states (5 modes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the tools.expand TUI setting per the specification (§ Conversation Block Details > Tool Call States). The new ToolCallBlock presentation model supports a 4-state visual state machine (pending, completed-collapsed, completed-expanded, failed) driven by a 5-value ToolExpandMode enum (never, always, success, fail, both). Key design decisions: - ToolExpandMode (StrEnum) with default FAIL, matching the specification choices setting (type: choices, default: fail). - ToolCallSetting frozen dataclass captures the schema descriptor for tools.expand (key, type, default, choices, description). - should_auto_expand() pure function encapsulates the expand decision logic, including kind:"read" suppression (read-kind tool calls never auto-expand regardless of mode). Explicit exhaustiveness guard raises ValueError for unhandled modes. - ToolCallBlock dataclass manages state transitions via complete(), fail(), and toggle() methods with strict argument validation and state guards. Not thread-safe; intended for single-threaded (UI-thread) use only. - Rendering helpers (indicator, chevron, render_header, render) produce the specification visual indicators. Tests: - 60 Behave scenarios covering all 5 modes, read suppression, manual toggle, error handling, and rendering. - 3 Robot integration tests verifying end-to-end lifecycle, five-mode behavior, and render output correctness. - Overall coverage: 97%, tool_call_block.py at 99%. ISSUES CLOSED: #1000 --- CHANGELOG.md | 6 + features/steps/tui_tool_call_block_steps.py | 453 ++++++++++++++++++ features/tui_tool_call_block.feature | 384 +++++++++++++++ robot/tui_tool_call_block.robot | 125 +++++ src/cleveragents/tui/widgets/__init__.py | 16 + .../tui/widgets/tool_call_block.py | 336 +++++++++++++ 6 files changed, 1320 insertions(+) create mode 100644 features/steps/tui_tool_call_block_steps.py create mode 100644 features/tui_tool_call_block.feature create mode 100644 robot/tui_tool_call_block.robot create mode 100644 src/cleveragents/tui/widgets/tool_call_block.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 36aab2df1..ee7ee6143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,12 @@ the same path in a subprocess context. Tests simulate the divergent- container condition (fresh ``CLEVERAGENTS_HOME`` with empty database). ASV benchmark measures active-plan filtering overhead. (#1035) +- Added `ToolCallBlock` presentation model and `tools.expand` TUI setting + with 5 expand modes (`never`, `always`, `success`, `fail`, `both`). + Implements the specification's tool call visual state machine + (pending → completed-collapsed / completed-expanded / failed) with + `kind: "read"` suppression, manual toggle, and rendering helpers. + Includes 60 Behave scenarios and 3 Robot integration tests. (#1000) - Added missing `LspServerConfig` model fields per specification: `description` (max 1000 chars), `transport` (`LspTransport` enum with `stdio`/`tcp`, default `stdio`), `initialization` (dict for LSP diff --git a/features/steps/tui_tool_call_block_steps.py b/features/steps/tui_tool_call_block_steps.py new file mode 100644 index 000000000..2b11f7e8f --- /dev/null +++ b/features/steps/tui_tool_call_block_steps.py @@ -0,0 +1,453 @@ +"""Step definitions for tui_tool_call_block.feature. + +Covers ToolExpandMode enum, ToolCallSetting schema, ToolCallBlock +widget visual states, auto-expand logic, kind:read suppression, +manual toggle, error handling, and rendering. +""" + +from __future__ import annotations + +from behave import given, then, when + +from cleveragents.tui.widgets.tool_call_block import ( + DEFAULT_TOOL_EXPAND_MODE, + TOOL_EXPAND_CHOICES, + ToolCallBlock, + ToolCallSetting, + ToolCallState, + ToolExpandMode, + should_auto_expand, +) + +# ------------------------------------------------------------------ +# ToolExpandMode enum +# ------------------------------------------------------------------ + + +@given("the ToolExpandMode enum is loaded") +def step_load_enum(context: object) -> None: + pass + + +@then("it should have exactly {count:d} members") +def step_enum_member_count(context: object, count: int) -> None: + assert len(ToolExpandMode) == count + + +@then('its values should be "never", "always", "success", "fail", "both"') +def step_enum_values(context: object) -> None: + expected = {"never", "always", "success", "fail", "both"} + actual = {m.value for m in ToolExpandMode} + assert actual == expected, f"Expected {expected}, got {actual}" + + +@then('the default mode should be "{mode}"') +def step_default_mode(context: object, mode: str) -> None: + assert DEFAULT_TOOL_EXPAND_MODE.value == mode + + +# ------------------------------------------------------------------ +# ToolCallSetting +# ------------------------------------------------------------------ + + +@given("a ToolCallSetting instance") +def step_create_setting(context: object) -> None: + context.setting = ToolCallSetting() # type: ignore[attr-defined] + + +@then('the key should be "{key}"') +def step_setting_key(context: object, key: str) -> None: + assert context.setting.key == key # type: ignore[attr-defined] + + +@then('the setting_type should be "{stype}"') +def step_setting_type(context: object, stype: str) -> None: + assert context.setting.setting_type == stype # type: ignore[attr-defined] + + +@then('the setting default value should be "{default}"') +def step_setting_default(context: object, default: str) -> None: + assert context.setting.default == default # type: ignore[attr-defined] + + +@then("the choices should contain all 5 modes") +def step_setting_choices(context: object) -> None: + choices = context.setting.choices # type: ignore[attr-defined] + assert len(choices) == 5 + for m in ToolExpandMode: + assert m.value in choices + + +# ------------------------------------------------------------------ +# ToolCallState enum +# ------------------------------------------------------------------ + + +@given("the ToolCallState enum is loaded") +def step_load_state_enum(context: object) -> None: + pass + + +@then("it should have exactly {count:d} states") +def step_state_count(context: object, count: int) -> None: + assert len(ToolCallState) == count + + +@then( + 'its values should include "pending", "completed_collapsed",' + ' "completed_expanded", "failed"' +) +def step_state_values(context: object) -> None: + expected = {"pending", "completed_collapsed", "completed_expanded", "failed"} + actual = {s.value for s in ToolCallState} + assert actual == expected + + +# ------------------------------------------------------------------ +# ToolCallBlock creation +# ------------------------------------------------------------------ + + +@given('a new ToolCallBlock named "{name}"') +def step_create_block_no_kind(context: object, name: str) -> None: + context.block = ToolCallBlock(tool_name=name) # type: ignore[attr-defined] + + +@given('a ToolCallBlock named "{name}" with kind "{kind}"') +def step_create_block(context: object, name: str, kind: str) -> None: + context.block = ToolCallBlock( # type: ignore[attr-defined] + tool_name=name, tool_kind=kind + ) + + +@then('its state should be "{state}"') +def step_check_initial_state(context: object, state: str) -> None: + assert context.block.state.value == state # type: ignore[attr-defined] + + +@then("its indicator should contain the hourglass") +def step_pending_indicator(context: object) -> None: + assert "\u231b" in context.block.indicator # type: ignore[attr-defined] + + +@then("its chevron should be the collapsed arrow") +def step_pending_chevron(context: object) -> None: + assert context.block.chevron == "\u25b6" # type: ignore[attr-defined] + + +@then("its render should include the tool name") +def step_render_has_name(context: object) -> None: + rendered = context.block.render() # type: ignore[attr-defined] + assert context.block.tool_name in rendered # type: ignore[attr-defined] + + +# ------------------------------------------------------------------ +# Complete / fail with modes +# ------------------------------------------------------------------ + + +@when('the tool call completes successfully with mode "{mode}"') +def step_complete_success(context: object, mode: str) -> None: + expand_mode = ToolExpandMode(mode) + context.block.complete( # type: ignore[attr-defined] + result="Tool output result text", + expand_mode=expand_mode, + ) + + +@when('the tool call fails with mode "{mode}"') +def step_complete_fail(context: object, mode: str) -> None: + expand_mode = ToolExpandMode(mode) + context.block.fail( # type: ignore[attr-defined] + error="Error: tool execution failed", + expand_mode=expand_mode, + ) + + +@then('the block state should be "{state}"') +def step_block_state(context: object, state: str) -> None: + assert ( + context.block.state.value == state # type: ignore[attr-defined] + ), f"Expected state '{state}', got '{context.block.state.value}'" # type: ignore[attr-defined] + + +@then("the indicator should be the success checkmark") +def step_success_indicator(context: object) -> None: + assert "\u2714" in context.block.indicator # type: ignore[attr-defined] + + +@then('the indicator should contain "failed"') +def step_failed_indicator(context: object) -> None: + assert "failed" in context.block.indicator # type: ignore[attr-defined] + assert "\u2717" in context.block.indicator # type: ignore[attr-defined] + + +@then("the chevron should be collapsed") +def step_chevron_collapsed(context: object) -> None: + assert context.block.chevron == "\u25b6" # type: ignore[attr-defined] + + +@then("the chevron should be expanded") +def step_chevron_expanded(context: object) -> None: + assert context.block.chevron == "\u25bc" # type: ignore[attr-defined] + + +@then("the render should include the result content") +def step_render_has_content(context: object) -> None: + rendered = context.block.render() # type: ignore[attr-defined] + assert "Tool output result text" in rendered + + +# ------------------------------------------------------------------ +# Manual toggle +# ------------------------------------------------------------------ + + +@when("the user toggles the block") +def step_toggle(context: object) -> None: + context.block.toggle() # type: ignore[attr-defined] + + +@then("toggling the pending block should raise ValueError") +def step_toggle_pending_raises(context: object) -> None: + try: + context.block.toggle() # type: ignore[attr-defined] + raise AssertionError("Expected ValueError not raised") + except ValueError: + pass + + +# ------------------------------------------------------------------ +# Error handling +# ------------------------------------------------------------------ + + +@then("completing again should raise ValueError") +def step_complete_again_raises(context: object) -> None: + try: + context.block.complete( # type: ignore[attr-defined] + result="retry", expand_mode=ToolExpandMode.ALWAYS + ) + raise AssertionError("Expected ValueError not raised") + except ValueError: + pass + + +@then("failing again should raise ValueError") +def step_fail_again_raises(context: object) -> None: + try: + context.block.fail( # type: ignore[attr-defined] + error="retry", expand_mode=ToolExpandMode.ALWAYS + ) + raise AssertionError("Expected ValueError not raised") + except ValueError: + pass + + +# ------------------------------------------------------------------ +# should_auto_expand validation +# ------------------------------------------------------------------ + + +@then("calling should_auto_expand with invalid mode raises TypeError") +def step_invalid_mode_type(context: object) -> None: + try: + should_auto_expand( + mode="bad", # type: ignore[arg-type] + succeeded=True, + tool_kind="execute", + ) + raise AssertionError("Expected TypeError not raised") + except TypeError: + pass + + +@then("calling should_auto_expand with invalid succeeded raises TypeError") +def step_invalid_succeeded_type(context: object) -> None: + try: + should_auto_expand( + mode=ToolExpandMode.ALWAYS, + succeeded="yes", # type: ignore[arg-type] + tool_kind="execute", + ) + raise AssertionError("Expected TypeError not raised") + except TypeError: + pass + + +@then("calling should_auto_expand with invalid tool_kind raises TypeError") +def step_invalid_tool_kind_type(context: object) -> None: + try: + should_auto_expand( + mode=ToolExpandMode.ALWAYS, + succeeded=True, + tool_kind=42, # type: ignore[arg-type] + ) + raise AssertionError("Expected TypeError not raised") + except TypeError: + pass + + +# ------------------------------------------------------------------ +# Complete / fail argument validation +# ------------------------------------------------------------------ + + +@then("completing with non-string result should raise TypeError") +def step_complete_bad_result(context: object) -> None: + try: + context.block.complete( # type: ignore[attr-defined] + result=42, # type: ignore[arg-type] + expand_mode=ToolExpandMode.ALWAYS, + ) + raise AssertionError("Expected TypeError not raised") + except TypeError: + pass + + +@then("completing with non-enum expand_mode should raise TypeError") +def step_complete_bad_mode(context: object) -> None: + try: + context.block.complete( # type: ignore[attr-defined] + result="ok", + expand_mode="always", # type: ignore[arg-type] + ) + raise AssertionError("Expected TypeError not raised") + except TypeError: + pass + + +@then("failing with non-string error should raise TypeError") +def step_fail_bad_error(context: object) -> None: + try: + context.block.fail( # type: ignore[attr-defined] + error=42, # type: ignore[arg-type] + expand_mode=ToolExpandMode.ALWAYS, + ) + raise AssertionError("Expected TypeError not raised") + except TypeError: + pass + + +@then("failing with non-enum expand_mode should raise TypeError") +def step_fail_bad_mode(context: object) -> None: + try: + context.block.fail( # type: ignore[attr-defined] + error="err", + expand_mode="fail", # type: ignore[arg-type] + ) + raise AssertionError("Expected TypeError not raised") + except TypeError: + pass + + +# ------------------------------------------------------------------ +# Rendering +# ------------------------------------------------------------------ + + +@then("the render header should contain the tool icon") +def step_header_has_icon(context: object) -> None: + header = context.block.render_header() # type: ignore[attr-defined] + assert "\U0001f527" in header + + +@then('the render header should contain "{text}"') +def step_header_contains(context: object, text: str) -> None: + header = context.block.render_header() # type: ignore[attr-defined] + assert text in header + + +@then("the full render should include the result text") +def step_full_render_includes(context: object) -> None: + rendered = context.block.render() # type: ignore[attr-defined] + assert "Tool output result text" in rendered + + +@then("the full render should not include the result text") +def step_full_render_excludes(context: object) -> None: + rendered = context.block.render() # type: ignore[attr-defined] + assert "Tool output result text" not in rendered + + +@then("the full render should include the error text") +def step_full_render_includes_error(context: object) -> None: + rendered = context.block.render() # type: ignore[attr-defined] + assert "Error: tool execution failed" in rendered + + +# ------------------------------------------------------------------ +# TOOL_EXPAND_CHOICES +# ------------------------------------------------------------------ + + +@given("the TOOL_EXPAND_CHOICES constant is loaded") +def step_load_choices(context: object) -> None: + context.choices = TOOL_EXPAND_CHOICES # type: ignore[attr-defined] + + +@then("it should be a tuple of 5 strings matching the enum values") +def step_choices_match(context: object) -> None: + choices = context.choices # type: ignore[attr-defined] + assert isinstance(choices, tuple) + assert len(choices) == 5 + for val in choices: + assert isinstance(val, str) + enum_vals = {m.value for m in ToolExpandMode} + assert set(choices) == enum_vals + + +# ------------------------------------------------------------------ +# is_failed property +# ------------------------------------------------------------------ + + +@then("is_failed should return False") +def step_is_not_failed(context: object) -> None: + assert context.block.is_failed is False # type: ignore[attr-defined] + + +@then("is_failed should return True") +def step_is_failed(context: object) -> None: + assert context.block.is_failed is True # type: ignore[attr-defined] + + +# ------------------------------------------------------------------ +# is_expanded property +# ------------------------------------------------------------------ + + +@when('the tool call completes with empty result and mode "{mode}"') +def step_complete_empty_result(context: object, mode: str) -> None: + expand_mode = ToolExpandMode(mode) + context.block.complete( # type: ignore[attr-defined] + result="", + expand_mode=expand_mode, + ) + + +@when('the tool call fails with empty error and mode "{mode}"') +def step_fail_empty_error(context: object, mode: str) -> None: + expand_mode = ToolExpandMode(mode) + context.block.fail( # type: ignore[attr-defined] + error="", + expand_mode=expand_mode, + ) + + +@then("the full render should include the header only with newline") +def step_render_header_with_newline(context: object) -> None: + rendered = context.block.render() # type: ignore[attr-defined] + header = context.block.render_header() # type: ignore[attr-defined] + assert rendered == f"{header}\n" + + +@then("is_expanded should return False") +def step_is_not_expanded(context: object) -> None: + assert context.block.is_expanded is False # type: ignore[attr-defined] + + +@then("is_expanded should return True") +def step_is_expanded(context: object) -> None: + assert context.block.is_expanded is True # type: ignore[attr-defined] diff --git a/features/tui_tool_call_block.feature b/features/tui_tool_call_block.feature new file mode 100644 index 000000000..0de9078fb --- /dev/null +++ b/features/tui_tool_call_block.feature @@ -0,0 +1,384 @@ +@tui @tool_call_block +Feature: TUI Tool Call Block expand states + The tools.expand setting controls auto-expand behavior on tool call + completion. Five modes exist: never, always, success, fail (default), + and both. Tool calls with kind "read" never auto-expand regardless + of setting. Users can manually toggle any non-pending block. + + # --- ToolExpandMode enum --- + + Scenario: ToolExpandMode has exactly 5 members + Given the ToolExpandMode enum is loaded + Then it should have exactly 5 members + And its values should be "never", "always", "success", "fail", "both" + + Scenario: Default tool expand mode is fail + Given the ToolExpandMode enum is loaded + Then the default mode should be "fail" + + # --- ToolCallSetting schema descriptor --- + + Scenario: ToolCallSetting has correct schema metadata + Given a ToolCallSetting instance + Then the key should be "tools.expand" + And the setting_type should be "choices" + And the setting default value should be "fail" + And the choices should contain all 5 modes + + # --- ToolCallState visual states --- + + Scenario: ToolCallState has 4 visual states + Given the ToolCallState enum is loaded + Then it should have exactly 4 states + And its values should include "pending", "completed_collapsed", "completed_expanded", "failed" + + # --- ToolCallBlock creation --- + + Scenario: New tool call block starts in pending state + Given a new ToolCallBlock named "local/code-analysis" + Then its state should be "pending" + And its indicator should contain the hourglass + And its chevron should be the collapsed arrow + And its render should include the tool name + + # --- Expand mode: never --- + + Scenario: Never mode keeps successful tool calls collapsed + Given a ToolCallBlock named "local/analyze" with kind "execute" + When the tool call completes successfully with mode "never" + Then the block state should be "completed_collapsed" + And the indicator should be the success checkmark + And the chevron should be collapsed + + Scenario: Never mode keeps failed tool calls collapsed + Given a ToolCallBlock named "local/analyze" with kind "execute" + When the tool call fails with mode "never" + Then the block state should be "failed" + And the indicator should contain "failed" + And the chevron should be collapsed + + # --- Expand mode: always --- + + Scenario: Always mode auto-expands successful tool calls + Given a ToolCallBlock named "local/build" with kind "execute" + When the tool call completes successfully with mode "always" + Then the block state should be "completed_expanded" + And the chevron should be expanded + And the render should include the result content + + Scenario: Always mode auto-expands failed tool calls + Given a ToolCallBlock named "local/build" with kind "execute" + When the tool call fails with mode "always" + Then the block state should be "completed_expanded" + And the indicator should contain "failed" + And the chevron should be expanded + + # --- Expand mode: success --- + + Scenario: Success mode auto-expands only successes + Given a ToolCallBlock named "local/lint" with kind "execute" + When the tool call completes successfully with mode "success" + Then the block state should be "completed_expanded" + And the indicator should be the success checkmark + And the chevron should be expanded + + Scenario: Success mode keeps failures collapsed + Given a ToolCallBlock named "local/lint" with kind "execute" + When the tool call fails with mode "success" + Then the block state should be "failed" + And the indicator should contain "failed" + And the chevron should be collapsed + + # --- Expand mode: fail (default) --- + + Scenario: Fail mode keeps successes collapsed + Given a ToolCallBlock named "local/check" with kind "execute" + When the tool call completes successfully with mode "fail" + Then the block state should be "completed_collapsed" + And the indicator should be the success checkmark + And the chevron should be collapsed + + Scenario: Fail mode auto-expands failures + Given a ToolCallBlock named "local/check" with kind "execute" + When the tool call fails with mode "fail" + Then the block state should be "completed_expanded" + And the indicator should contain "failed" + And the chevron should be expanded + + # --- Expand mode: both --- + + Scenario: Both mode auto-expands successes + Given a ToolCallBlock named "local/deploy" with kind "execute" + When the tool call completes successfully with mode "both" + Then the block state should be "completed_expanded" + And the indicator should be the success checkmark + And the chevron should be expanded + + Scenario: Both mode auto-expands failures + Given a ToolCallBlock named "local/deploy" with kind "execute" + When the tool call fails with mode "both" + Then the block state should be "completed_expanded" + And the indicator should contain "failed" + And the chevron should be expanded + + # --- kind: read suppression --- + + Scenario: Read kind never auto-expands with always mode + Given a ToolCallBlock named "local/read-file" with kind "read" + When the tool call completes successfully with mode "always" + Then the block state should be "completed_collapsed" + + Scenario: Read kind never auto-expands failures with both mode + Given a ToolCallBlock named "local/list-dir" with kind "read" + When the tool call fails with mode "both" + Then the block state should be "failed" + + Scenario: Read kind never auto-expands with success mode + Given a ToolCallBlock named "local/cat" with kind "read" + When the tool call completes successfully with mode "success" + Then the block state should be "completed_collapsed" + + Scenario: Read kind never auto-expands failures with fail mode + Given a ToolCallBlock named "local/cat" with kind "read" + When the tool call fails with mode "fail" + Then the block state should be "failed" + + Scenario: Read kind never auto-expands with never mode success + Given a ToolCallBlock named "local/cat" with kind "read" + When the tool call completes successfully with mode "never" + Then the block state should be "completed_collapsed" + + Scenario: Read kind never auto-expands with never mode failure + Given a ToolCallBlock named "local/cat" with kind "read" + When the tool call fails with mode "never" + Then the block state should be "failed" + + Scenario: Read kind never auto-expands with always mode failure + Given a ToolCallBlock named "local/cat" with kind "read" + When the tool call fails with mode "always" + Then the block state should be "failed" + + Scenario: Read kind never auto-expands with success mode failure + Given a ToolCallBlock named "local/cat" with kind "read" + When the tool call fails with mode "success" + Then the block state should be "failed" + + Scenario: Read kind never auto-expands with both mode success + Given a ToolCallBlock named "local/cat" with kind "read" + When the tool call completes successfully with mode "both" + Then the block state should be "completed_collapsed" + + Scenario: Read kind never auto-expands with fail mode success + Given a ToolCallBlock named "local/cat" with kind "read" + When the tool call completes successfully with mode "fail" + Then the block state should be "completed_collapsed" + + # --- Case-sensitive kind matching --- + + Scenario: Mixed-case kind "Read" is not suppressed + Given a ToolCallBlock named "local/read-file" with kind "Read" + When the tool call completes successfully with mode "always" + Then the block state should be "completed_expanded" + + Scenario: Upper-case kind "READ" is not suppressed + Given a ToolCallBlock named "local/read-file" with kind "READ" + When the tool call completes successfully with mode "always" + Then the block state should be "completed_expanded" + + Scenario: Kind with trailing space "read " is not suppressed + Given a ToolCallBlock named "local/read-file" with kind "read " + When the tool call completes successfully with mode "always" + Then the block state should be "completed_expanded" + + # --- Manual toggle --- + + Scenario: Toggle expands a collapsed completed block + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call completes successfully with mode "never" + And the user toggles the block + Then the block state should be "completed_expanded" + + Scenario: Toggle collapses an expanded completed block + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call completes successfully with mode "always" + And the user toggles the block + Then the block state should be "completed_collapsed" + + Scenario: Toggle expands a failed block and back + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call fails with mode "never" + And the user toggles the block + Then the block state should be "completed_expanded" + And the indicator should contain "failed" + When the user toggles the block + Then the block state should be "failed" + And the indicator should contain "failed" + + Scenario: Multi-toggle failed block preserves failure indicator + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call fails with mode "fail" + Then the block state should be "completed_expanded" + And the indicator should contain "failed" + When the user toggles the block + Then the block state should be "failed" + And the indicator should contain "failed" + When the user toggles the block + Then the block state should be "completed_expanded" + And the indicator should contain "failed" + + Scenario: Multi-toggle successful block preserves success indicator + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call completes successfully with mode "always" + Then the block state should be "completed_expanded" + And the indicator should be the success checkmark + When the user toggles the block + Then the block state should be "completed_collapsed" + And the indicator should be the success checkmark + When the user toggles the block + Then the block state should be "completed_expanded" + And the indicator should be the success checkmark + + Scenario: Toggle on pending block raises an error + Given a ToolCallBlock named "local/test" with kind "execute" + Then toggling the pending block should raise ValueError + + # --- Error handling --- + + Scenario: Complete on non-pending block raises ValueError + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call completes successfully with mode "always" + Then completing again should raise ValueError + + Scenario: Fail on non-pending block raises ValueError + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call completes successfully with mode "always" + Then failing again should raise ValueError + + Scenario: Complete on failed block raises ValueError + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call fails with mode "never" + Then completing again should raise ValueError + + Scenario: Fail on failed block raises ValueError + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call fails with mode "never" + Then failing again should raise ValueError + + # --- should_auto_expand validation --- + + Scenario: should_auto_expand rejects invalid mode type + Then calling should_auto_expand with invalid mode raises TypeError + + Scenario: should_auto_expand rejects invalid succeeded type + Then calling should_auto_expand with invalid succeeded raises TypeError + + Scenario: should_auto_expand rejects invalid tool_kind type + Then calling should_auto_expand with invalid tool_kind raises TypeError + + # --- complete/fail argument validation --- + + Scenario: Complete rejects non-string result + Given a ToolCallBlock named "local/test" with kind "execute" + Then completing with non-string result should raise TypeError + + Scenario: Complete rejects non-ToolExpandMode expand_mode + Given a ToolCallBlock named "local/test" with kind "execute" + Then completing with non-enum expand_mode should raise TypeError + + Scenario: Fail rejects non-string error + Given a ToolCallBlock named "local/test" with kind "execute" + Then failing with non-string error should raise TypeError + + Scenario: Fail rejects non-ToolExpandMode expand_mode + Given a ToolCallBlock named "local/test" with kind "execute" + Then failing with non-enum expand_mode should raise TypeError + + # --- Rendering --- + + Scenario: Render header shows tool icon and name in all states + Given a ToolCallBlock named "local/analyze" with kind "execute" + Then the render header should contain the tool icon + And the render header should contain "local/analyze" + + Scenario: Expanded render includes result content + Given a ToolCallBlock named "local/build" with kind "execute" + When the tool call completes successfully with mode "always" + Then the full render should include the result text + + Scenario: Collapsed render excludes result content + Given a ToolCallBlock named "local/build" with kind "execute" + When the tool call completes successfully with mode "never" + Then the full render should not include the result text + + Scenario: Expanded failed render includes error content + Given a ToolCallBlock named "local/build" with kind "execute" + When the tool call fails with mode "fail" + Then the full render should include the error text + + Scenario: Render after toggle hides content + Given a ToolCallBlock named "local/build" with kind "execute" + When the tool call completes successfully with mode "always" + Then the full render should include the result text + When the user toggles the block + Then the full render should not include the result text + + # --- TOOL_EXPAND_CHOICES constant --- + + Scenario: TOOL_EXPAND_CHOICES tuple contains all 5 mode strings + Given the TOOL_EXPAND_CHOICES constant is loaded + Then it should be a tuple of 5 strings matching the enum values + + # --- is_failed property --- + + Scenario: is_failed returns False for pending block + Given a ToolCallBlock named "local/test" with kind "execute" + Then is_failed should return False + + Scenario: is_failed returns True after failure + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call fails with mode "never" + Then is_failed should return True + + Scenario: is_failed returns True after failure auto-expand + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call fails with mode "fail" + Then is_failed should return True + + Scenario: is_failed returns False after success + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call completes successfully with mode "always" + Then is_failed should return False + + Scenario: is_failed remains True after toggling failed block + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call fails with mode "fail" + And the user toggles the block + Then is_failed should return True + When the user toggles the block + Then is_failed should return True + + # --- is_expanded property --- + + Scenario: is_expanded returns False when collapsed + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call completes successfully with mode "never" + Then is_expanded should return False + + Scenario: is_expanded returns True when expanded + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call completes successfully with mode "always" + Then is_expanded should return True + + # --- Edge cases: empty content --- + + Scenario: Complete with empty result string succeeds + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call completes with empty result and mode "always" + Then the block state should be "completed_expanded" + And the full render should include the header only with newline + + Scenario: Fail with empty error string succeeds + Given a ToolCallBlock named "local/test" with kind "execute" + When the tool call fails with empty error and mode "fail" + Then the block state should be "completed_expanded" + And the full render should include the header only with newline diff --git a/robot/tui_tool_call_block.robot b/robot/tui_tool_call_block.robot new file mode 100644 index 000000000..3cf80f36c --- /dev/null +++ b/robot/tui_tool_call_block.robot @@ -0,0 +1,125 @@ +*** Settings *** +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Test Cases *** +ToolCallBlock Widget Integration Smoke Test + [Documentation] Verify ToolCallBlock, ToolExpandMode, and auto-expand logic + ... can be imported and exercised in a single Python process. + [Tags] tui tool_call_block + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.tui.widgets.tool_call_block import ( + ... ToolCallBlock, ToolCallState, ToolExpandMode, + ... ToolCallSetting, should_auto_expand, + ... DEFAULT_TOOL_EXPAND_MODE, TOOL_EXPAND_CHOICES, + ... ) + ... # Enum membership + ... assert len(ToolExpandMode) == 5 + ... assert DEFAULT_TOOL_EXPAND_MODE == ToolExpandMode.FAIL + ... assert len(TOOL_EXPAND_CHOICES) == 5 + ... # Setting schema + ... s = ToolCallSetting() + ... assert s.key == "tools.expand" + ... assert s.setting_type == "choices" + ... assert s.default == "fail" + ... # Block lifecycle — success with always + ... b1 = ToolCallBlock(tool_name="local/build", tool_kind="execute") + ... assert b1.state == ToolCallState.PENDING + ... b1.complete(result="OK", expand_mode=ToolExpandMode.ALWAYS) + ... assert b1.state == ToolCallState.COMPLETED_EXPANDED + ... assert b1.is_expanded + ... # Toggle collapses + ... b1.toggle() + ... assert b1.state == ToolCallState.COMPLETED_COLLAPSED + ... # Block lifecycle — fail with fail mode + ... b2 = ToolCallBlock(tool_name="local/lint", tool_kind="execute") + ... b2.fail(error="lint error", expand_mode=ToolExpandMode.FAIL) + ... assert b2.state == ToolCallState.COMPLETED_EXPANDED + ... assert "failed" in b2.indicator, f"Expected 'failed' in indicator, got {b2.indicator!r}" + ... assert b2.is_failed is True + ... # Toggle preserves failure identity (restores FAILED state) + ... b2.toggle() + ... assert b2.state == ToolCallState.FAILED + ... assert "failed" in b2.indicator, "Failure indicator lost after toggle" + ... assert b2.is_failed is True + ... b2.toggle() + ... assert b2.state == ToolCallState.COMPLETED_EXPANDED + ... assert "failed" in b2.indicator, "Failure indicator lost after double toggle" + ... # Expanded failed block render includes error content + ... assert "lint error" in b2.render(), "Error content missing from expanded failed block render" + ... # kind:read suppression + ... b3 = ToolCallBlock(tool_name="local/read-file", tool_kind="read") + ... b3.complete(result="file contents", expand_mode=ToolExpandMode.ALWAYS) + ... assert b3.state == ToolCallState.COMPLETED_COLLAPSED + ... # Render output + ... assert "local/read-file" in b3.render() + ... print("tool-call-block-integration-ok") + ${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tool-call-block-integration-ok + +ToolCallBlock Five Modes Expand Behavior + [Documentation] Verify all 5 expand modes produce correct auto-expand results. + [Tags] tui tool_call_block + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.tui.widgets.tool_call_block import ( + ... ToolCallBlock, ToolCallState, ToolExpandMode, should_auto_expand, + ... ) + ... # never mode + ... assert not should_auto_expand(mode=ToolExpandMode.NEVER, succeeded=True, tool_kind="execute") + ... assert not should_auto_expand(mode=ToolExpandMode.NEVER, succeeded=False, tool_kind="execute") + ... # always mode + ... assert should_auto_expand(mode=ToolExpandMode.ALWAYS, succeeded=True, tool_kind="execute") + ... assert should_auto_expand(mode=ToolExpandMode.ALWAYS, succeeded=False, tool_kind="execute") + ... # success mode + ... assert should_auto_expand(mode=ToolExpandMode.SUCCESS, succeeded=True, tool_kind="execute") + ... assert not should_auto_expand(mode=ToolExpandMode.SUCCESS, succeeded=False, tool_kind="execute") + ... # fail mode (default) + ... assert not should_auto_expand(mode=ToolExpandMode.FAIL, succeeded=True, tool_kind="execute") + ... assert should_auto_expand(mode=ToolExpandMode.FAIL, succeeded=False, tool_kind="execute") + ... # both mode + ... assert should_auto_expand(mode=ToolExpandMode.BOTH, succeeded=True, tool_kind="execute") + ... assert should_auto_expand(mode=ToolExpandMode.BOTH, succeeded=False, tool_kind="execute") + ... # read suppression across all modes + ... assert all(not should_auto_expand(mode=m, succeeded=True, tool_kind="read") for m in ToolExpandMode) + ... assert all(not should_auto_expand(mode=m, succeeded=False, tool_kind="read") for m in ToolExpandMode) + ... print("five-modes-ok") + ${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} five-modes-ok + +ToolCallBlock Render Output Correctness + [Documentation] Verify rendered output contains correct indicators and chevrons. + [Tags] tui tool_call_block + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.tui.widgets.tool_call_block import ( + ... ToolCallBlock, ToolCallState, ToolExpandMode, + ... ) + ... # Pending state + ... bp = ToolCallBlock(tool_name="local/check", tool_kind="execute") + ... assert "\\u231b" in bp.render_header() + ... assert "\\u25b6" in bp.render_header() + ... # Completed expanded + ... be = ToolCallBlock(tool_name="local/check", tool_kind="execute") + ... be.complete(result="All good", expand_mode=ToolExpandMode.ALWAYS) + ... assert "\\u2714" in be.render_header() + ... assert "\\u25bc" in be.render_header() + ... assert "All good" in be.render() + ... # Failed state (collapsed) + ... bf = ToolCallBlock(tool_name="local/check", tool_kind="execute") + ... bf.fail(error="broken", expand_mode=ToolExpandMode.NEVER) + ... assert "\\u2717" in bf.render_header() + ... assert "failed" in bf.render_header() + ... assert "\\u25b6" in bf.render_header() + ... # Failed state (auto-expanded) — must still show failure indicator + ... bf2 = ToolCallBlock(tool_name="local/check", tool_kind="execute") + ... bf2.fail(error="broken", expand_mode=ToolExpandMode.FAIL) + ... assert "\\u2717" in bf2.render_header(), "Auto-expanded failed block missing failure indicator" + ... assert "failed" in bf2.render_header(), "Auto-expanded failed block missing 'failed' text" + ... assert "\\u25bc" in bf2.render_header(), "Auto-expanded failed block should show expanded chevron" + ... assert bf2.is_failed is True + ... print("render-ok") + ${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} render-ok diff --git a/src/cleveragents/tui/widgets/__init__.py b/src/cleveragents/tui/widgets/__init__.py index 51776ce71..e6b8f12c8 100644 --- a/src/cleveragents/tui/widgets/__init__.py +++ b/src/cleveragents/tui/widgets/__init__.py @@ -5,12 +5,28 @@ from cleveragents.tui.widgets.persona_bar import PersonaBar from cleveragents.tui.widgets.prompt import PromptInput, PromptSubmitted from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay +from cleveragents.tui.widgets.tool_call_block import ( + DEFAULT_TOOL_EXPAND_MODE, + TOOL_EXPAND_CHOICES, + ToolCallBlock, + ToolCallSetting, + ToolCallState, + ToolExpandMode, + should_auto_expand, +) __all__ = [ + "DEFAULT_TOOL_EXPAND_MODE", + "TOOL_EXPAND_CHOICES", "HelpPanelOverlay", "PersonaBar", "PromptInput", "PromptSubmitted", "ReferencePickerOverlay", "SlashCommandOverlay", + "ToolCallBlock", + "ToolCallSetting", + "ToolCallState", + "ToolExpandMode", + "should_auto_expand", ] diff --git a/src/cleveragents/tui/widgets/tool_call_block.py b/src/cleveragents/tui/widgets/tool_call_block.py new file mode 100644 index 000000000..c00145388 --- /dev/null +++ b/src/cleveragents/tui/widgets/tool_call_block.py @@ -0,0 +1,336 @@ +"""Tool call block widget with expand/collapse states. + +Implements the ``tools.expand`` TUI setting per the specification +(§ Conversation Block Details > Tool Call States). Each tool call +progresses through *pending → completed / failed* and the expand mode +determines which completed blocks auto-reveal their result content. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum + +# ------------------------------------------------------------------ +# ToolExpandMode enum — 5 specification-defined values +# ------------------------------------------------------------------ + + +class ToolExpandMode(StrEnum): + """Controls which tool call results auto-expand on completion. + + Corresponds to the ``tools.expand`` choices setting with default + ``fail``. + """ + + NEVER = "never" + ALWAYS = "always" + SUCCESS = "success" + FAIL = "fail" + BOTH = "both" + + +DEFAULT_TOOL_EXPAND_MODE: ToolExpandMode = ToolExpandMode.FAIL + +TOOL_EXPAND_CHOICES: tuple[str, ...] = tuple(m.value for m in ToolExpandMode) + + +# ------------------------------------------------------------------ +# ToolCallState — visual state machine +# ------------------------------------------------------------------ + + +class ToolCallState(StrEnum): + """Visual state of a single tool call block.""" + + PENDING = "pending" + COMPLETED_COLLAPSED = "completed_collapsed" + COMPLETED_EXPANDED = "completed_expanded" + FAILED = "failed" + + +# ------------------------------------------------------------------ +# Visual indicators per the specification mockups +# ------------------------------------------------------------------ + +_PENDING_INDICATOR = "\u231b" # ⌛ +_SUCCESS_INDICATOR = "\u2714" # ✔ +_FAILED_INDICATOR = "\u2717" # ✗ +_CHEVRON_COLLAPSED = "\u25b6" # ▶ +_CHEVRON_EXPANDED = "\u25bc" # ▼ + + +# ------------------------------------------------------------------ +# ToolCallSetting — settings schema entry +# ------------------------------------------------------------------ + + +@dataclass(slots=True, frozen=True) +class ToolCallSetting: + """Schema descriptor for the ``tools.expand`` setting.""" + + key: str = "tools.expand" + setting_type: str = "choices" + default: str = "fail" + choices: tuple[str, ...] = TOOL_EXPAND_CHOICES + description: str = "When to auto-expand tool call results" + + +# ------------------------------------------------------------------ +# Auto-expand decision logic +# ------------------------------------------------------------------ + + +def should_auto_expand( + *, + mode: ToolExpandMode, + succeeded: bool, + tool_kind: str, +) -> bool: + """Decide whether a completed tool call should auto-expand. + + Parameters + ---------- + mode: + The active ``tools.expand`` mode. + succeeded: + ``True`` when the tool call completed successfully, ``False`` + when it failed. + tool_kind: + The tool's ``kind`` metadata value. Tool calls whose kind is + ``"read"`` never auto-expand regardless of *mode*. + + Returns + ------- + bool + ``True`` if the block should auto-expand its result content. + """ + if not isinstance(mode, ToolExpandMode): + raise TypeError(f"mode must be a ToolExpandMode, got {type(mode).__name__}") + if not isinstance(succeeded, bool): + raise TypeError(f"succeeded must be a bool, got {type(succeeded).__name__}") + if not isinstance(tool_kind, str): + raise TypeError(f"tool_kind must be a str, got {type(tool_kind).__name__}") + + # Specification rule: kind "read" never auto-expands. + if tool_kind == "read": + return False + + if mode == ToolExpandMode.NEVER: + return False + if mode == ToolExpandMode.ALWAYS: + return True + # Equivalent to ALWAYS today; semantically distinct for future extension. + if mode == ToolExpandMode.BOTH: + return True + if mode == ToolExpandMode.SUCCESS: + return succeeded + if mode == ToolExpandMode.FAIL: + return not succeeded + raise ValueError(f"Unhandled ToolExpandMode: {mode!r}") + + +# ------------------------------------------------------------------ +# ToolCallBlock — presentation widget (framework-agnostic model) +# ------------------------------------------------------------------ + + +@dataclass(slots=True) +class ToolCallBlock: + """Presentation model for a single tool call in the conversation. + + The block progresses through the visual state machine: + ``pending → completed-collapsed | completed-expanded | failed``. + + The auto-expand decision is applied at completion time via + :meth:`complete` / :meth:`fail`. After that the user can + freely :meth:`toggle` the block regardless of the auto-expand + setting. + + The success/failure outcome is tracked independently from the + expand/collapse visual state via ``_succeeded``, ensuring that + the indicator always reflects the correct outcome symbol + regardless of toggle history. + + Not thread-safe; intended for single-threaded (UI-thread) use only. + Multi-field updates in :meth:`complete`, :meth:`fail`, and + :meth:`toggle` are non-atomic. + """ + + tool_name: str + tool_kind: str = "" + state: ToolCallState = ToolCallState.PENDING + # TODO: Callers must truncate large content; no size limit enforced here. + result_content: str = "" + _succeeded: bool | None = field(default=None, repr=False, init=False) + + # -- public mutation methods --- + + def complete( + self, + *, + result: str, + expand_mode: ToolExpandMode, + ) -> None: + """Transition from *pending* to a completed visual state. + + Parameters + ---------- + result: + The textual output produced by the tool call. + expand_mode: + The active ``tools.expand`` setting value. + """ + if self.state != ToolCallState.PENDING: + raise ValueError( + f"Cannot complete a tool call in state {self.state!r}; " + "only PENDING tool calls can be completed." + ) + if not isinstance(result, str): + raise TypeError(f"result must be a str, got {type(result).__name__}") + if not isinstance(expand_mode, ToolExpandMode): + raise TypeError( + f"expand_mode must be a ToolExpandMode, got " + f"{type(expand_mode).__name__}" + ) + + self.result_content = result + self._succeeded = True + expand = should_auto_expand( + mode=expand_mode, + succeeded=True, + tool_kind=self.tool_kind, + ) + self.state = ( + ToolCallState.COMPLETED_EXPANDED + if expand + else ToolCallState.COMPLETED_COLLAPSED + ) + + def fail( + self, + *, + error: str, + expand_mode: ToolExpandMode, + ) -> None: + """Transition from *pending* to a failed visual state. + + Failed blocks use ``ToolCallState.FAILED`` when collapsed or + ``ToolCallState.COMPLETED_EXPANDED`` when the expand mode says + to auto-reveal failures. + + Parameters + ---------- + error: + The error message from the tool call. + expand_mode: + The active ``tools.expand`` setting value. + """ + if self.state != ToolCallState.PENDING: + raise ValueError( + f"Cannot fail a tool call in state {self.state!r}; " + "only PENDING tool calls can fail." + ) + if not isinstance(error, str): + raise TypeError(f"error must be a str, got {type(error).__name__}") + if not isinstance(expand_mode, ToolExpandMode): + raise TypeError( + f"expand_mode must be a ToolExpandMode, got " + f"{type(expand_mode).__name__}" + ) + + self.result_content = error + self._succeeded = False + expand = should_auto_expand( + mode=expand_mode, + succeeded=False, + tool_kind=self.tool_kind, + ) + # The spec mockup shows only 4 visual states (Failed is always + # collapsed). However, the behaviour table requires auto-expanded + # failures for modes like "fail" and "both". We reuse + # COMPLETED_EXPANDED with ``_succeeded=False`` to represent a + # "failed but expanded" block; the independent ``_succeeded`` + # field ensures the failure indicator is always rendered correctly. + self.state = ( + ToolCallState.COMPLETED_EXPANDED if expand else ToolCallState.FAILED + ) + + def toggle(self) -> None: + """Manually toggle expand/collapse on a non-pending block. + + Users can toggle any completed or failed block regardless of + the auto-expand setting. + """ + if self.state == ToolCallState.PENDING: + raise ValueError("Cannot toggle a PENDING tool call block.") + if self.state == ToolCallState.COMPLETED_EXPANDED: + if self._succeeded is False: + self.state = ToolCallState.FAILED + else: + self.state = ToolCallState.COMPLETED_COLLAPSED + elif self.state in ( + ToolCallState.COMPLETED_COLLAPSED, + ToolCallState.FAILED, + ): + self.state = ToolCallState.COMPLETED_EXPANDED + + # -- rendering helpers --- + + @property + def indicator(self) -> str: + """Return the status indicator character for the current state. + + The indicator reflects the *outcome* (success or failure), not + the expand/collapse visual state. This ensures that toggling a + failed block between expanded and collapsed never loses the + failure indicator. + """ + if self.state == ToolCallState.PENDING: + return _PENDING_INDICATOR + if self._succeeded is None: + return _PENDING_INDICATOR + if self._succeeded is False: + return f"{_FAILED_INDICATOR} failed" + return _SUCCESS_INDICATOR + + @property + def is_failed(self) -> bool: + """Return whether the tool call failed. + + This allows callers to programmatically distinguish success + content from error content stored in :attr:`result_content`. + Returns ``False`` for pending blocks (no outcome yet). + """ + return self._succeeded is False + + @property + def chevron(self) -> str: + """Return the expand/collapse chevron for the current state.""" + if self.state == ToolCallState.COMPLETED_EXPANDED: + return _CHEVRON_EXPANDED + return _CHEVRON_COLLAPSED + + @property + def is_expanded(self) -> bool: + """Return whether the block is currently showing result content.""" + return self.state == ToolCallState.COMPLETED_EXPANDED + + def render_header(self) -> str: + """Render the one-line header shown in all states.""" + # TODO: Escape with rich.markup.escape() when Rich rendering layer integrates. + return f"\U0001f527 {self.tool_name} {self.indicator} {self.chevron}" + + def render(self) -> str: + """Render the full block (header + optional result content). + + When the block is expanded, result content is always shown — + even when empty — so that the expanded chevron (``▼``) is + visually consistent with the visible content area. + """ + header = self.render_header() + if self.is_expanded: + # TODO: Escape with rich.markup.escape() when Rich + # rendering layer integrates. + return f"{header}\n{self.result_content}" + return header -- 2.52.0