feat(tui): implement tool call expand states (5 modes) #1219

Closed
hurui200320 wants to merge 1 commits from feature/m8-tui-tool-call-expand into master
6 changed files with 1320 additions and 0 deletions
+6
View File
@@ -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
+453
View File
@@ -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]
+384
View File
@@ -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
+125
View File
@@ -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
+16
View File
@@ -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",
]
@@ -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