feat(tui): implement tool call expand states (5 modes) #1219
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user