forked from HAL9000/cleveragents-core
7e55d522b4
- What was implemented
- Added execute: str | None = Field(default=None, description="Hook for tool execution") to ToolLifecycle in src/cleveragents/domain/models/core/tool.py, completing the four-stage lifecycle (discover / activate / execute / deactivate) as required by the spec.
- Updated Tool.as_cli_dict() to render the lifecycle block (including execute) when set, and to omit the lifecycle block entirely when no hooks are configured, keeping CLI output clean for tools without lifecycle hooks.
- Created features/tool_lifecycle_execute_hook.feature with 8 Behave scenarios covering:
- issue-capture TDD test
- all four hooks (discover, activate, execute, deactivate)
- execute defaults to None
- execute set independently
- from_config parsing
- as_cli_dict rendering
- Created features/steps/tool_lifecycle_execute_hook_steps.py with all step definitions implementing Given/When/Then for the scenarios.
- Key design decisions
- The execute field follows the same pattern as the existing discover, activate, and deactivate fields: str | None with Field(default=None).
- as_cli_dict() renders the lifecycle block only when at least one hook is set (non-None), ensuring clean output for tools that do not utilize lifecycle hooks.
- The issue-capture TDD scenario uses @tdd_issue and @tdd_issue_2820 tags (without @tdd_expected_fail since the fix is in place), aligning with the test-driven approach described in the metadata.
- Impact and considerations
- Changes are additive and backward-compatible; tools configured without lifecycle hooks will continue to render without a lifecycle block.
- Introduces Behave-based acceptance tests to validate the four-stage lifecycle handling and CLI rendering.
- Affects only ToolLifecycle modeling, CLI rendering, and associated tests; no changes to external APIs.
ISSUES CLOSED: #2820
260 lines
9.8 KiB
Python
260 lines
9.8 KiB
Python
"""Step definitions for ToolLifecycle execute hook tests (issue #2820)."""
|
|
|
|
from typing import Any
|
|
|
|
from behave import then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.domain.models.core.tool import (
|
|
Tool,
|
|
ToolLifecycle,
|
|
ToolSource,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_builtin_tool(**overrides: Any) -> Tool:
|
|
"""Create a minimal builtin Tool with optional overrides."""
|
|
defaults: dict[str, Any] = {
|
|
"name": "local/test-tool",
|
|
"description": "A test tool",
|
|
"source": ToolSource.BUILTIN,
|
|
}
|
|
defaults.update(overrides)
|
|
return Tool(**defaults)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps — ToolLifecycle construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I create a tool lifecycle with execute hook "{execute}"')
|
|
def step_create_lifecycle_with_execute(context: Context, execute: str) -> None:
|
|
"""Create a ToolLifecycle with only the execute hook set."""
|
|
context.tool_lifecycle = ToolLifecycle(execute=execute)
|
|
context.tool_model_error = None
|
|
|
|
|
|
@when("I create a tool lifecycle with all four hooks set")
|
|
def step_create_lifecycle_all_four(context: Context) -> None:
|
|
"""Create a ToolLifecycle with all four lifecycle hooks set."""
|
|
context.tool_lifecycle = ToolLifecycle(
|
|
discover="hooks.discover",
|
|
activate="hooks.activate",
|
|
execute="hooks.execute",
|
|
deactivate="hooks.deactivate",
|
|
)
|
|
context.tool_model_error = None
|
|
|
|
|
|
@when("I create a tool lifecycle with no hooks")
|
|
def step_create_lifecycle_no_hooks(context: Context) -> None:
|
|
"""Create a ToolLifecycle with no hooks (all default to None)."""
|
|
context.tool_lifecycle = ToolLifecycle()
|
|
context.tool_model_error = None
|
|
|
|
|
|
@when('I create a tool lifecycle with only execute hook "{execute}"')
|
|
def step_create_lifecycle_only_execute(context: Context, execute: str) -> None:
|
|
"""Create a ToolLifecycle with only the execute hook set (all others None)."""
|
|
context.tool_lifecycle = ToolLifecycle(execute=execute)
|
|
context.tool_model_error = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps — Tool.from_config() with lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I load a tool from config with lifecycle execute "{execute}"')
|
|
def step_load_tool_from_config_with_lifecycle_execute(
|
|
context: Context, execute: str
|
|
) -> None:
|
|
"""Load a Tool from config dict that includes a lifecycle.execute hook."""
|
|
config: dict[str, Any] = {
|
|
"name": "local/lifecycle-tool",
|
|
"description": "Tool with lifecycle execute hook",
|
|
"source": "builtin",
|
|
"lifecycle": {
|
|
"execute": execute,
|
|
},
|
|
}
|
|
context.tool_model = Tool.from_config(config)
|
|
context.tool_model_error = None
|
|
|
|
|
|
@when("I load a tool from config with lifecycle execute None")
|
|
def step_load_tool_from_config_with_lifecycle_execute_none(context: Context) -> None:
|
|
"""Load a Tool from config dict that includes a lifecycle with execute=None."""
|
|
config: dict[str, Any] = {
|
|
"name": "local/lifecycle-tool-none",
|
|
"description": "Tool with lifecycle execute=None",
|
|
"source": "builtin",
|
|
"lifecycle": {
|
|
"discover": "hooks.discover",
|
|
},
|
|
}
|
|
context.tool_model = Tool.from_config(config)
|
|
context.tool_model_error = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps — Tool.as_cli_dict() with lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I create a tool with lifecycle execute "{execute}" and call as_cli_dict')
|
|
def step_create_tool_with_lifecycle_execute_and_cli_dict(
|
|
context: Context, execute: str
|
|
) -> None:
|
|
"""Create a Tool with a lifecycle.execute hook and call as_cli_dict."""
|
|
tool = _make_builtin_tool(
|
|
lifecycle=ToolLifecycle(execute=execute),
|
|
)
|
|
context.tool_cli_dict = tool.as_cli_dict()
|
|
context.tool_model_error = None
|
|
|
|
|
|
@when("I create a tool with no lifecycle and call as_cli_dict")
|
|
def step_create_tool_no_lifecycle_and_cli_dict(context: Context) -> None:
|
|
"""Create a Tool with no lifecycle and call as_cli_dict."""
|
|
tool = _make_builtin_tool()
|
|
context.tool_cli_dict = tool.as_cli_dict()
|
|
context.tool_model_error = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — ToolLifecycle field assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the tool lifecycle execute should be "{expected}"')
|
|
def step_check_lifecycle_execute(context: Context, expected: str) -> None:
|
|
"""Assert the lifecycle execute hook equals the expected value."""
|
|
# Support both context.tool_lifecycle and context.tool_model.lifecycle
|
|
if hasattr(context, "tool_lifecycle"):
|
|
actual = context.tool_lifecycle.execute
|
|
else:
|
|
assert context.tool_model.lifecycle is not None, (
|
|
"Expected tool_model.lifecycle to be set, but it is None"
|
|
)
|
|
actual = context.tool_model.lifecycle.execute
|
|
assert actual == expected, (
|
|
f"Expected lifecycle.execute == {expected!r}, got {actual!r}"
|
|
)
|
|
|
|
|
|
@then("the tool lifecycle execute should be None")
|
|
def step_check_lifecycle_execute_none(context: Context) -> None:
|
|
"""Assert the lifecycle execute hook is None."""
|
|
if hasattr(context, "tool_lifecycle"):
|
|
actual = context.tool_lifecycle.execute
|
|
elif context.tool_model.lifecycle is not None:
|
|
actual = context.tool_model.lifecycle.execute
|
|
else:
|
|
actual = None
|
|
assert actual is None, f"Expected lifecycle.execute to be None, got {actual!r}"
|
|
|
|
|
|
@then("the tool lifecycle discover should be None")
|
|
def step_check_lifecycle_discover_none(context: Context) -> None:
|
|
"""Assert the lifecycle discover hook is None."""
|
|
if hasattr(context, "tool_lifecycle"):
|
|
actual = context.tool_lifecycle.discover
|
|
elif context.tool_model.lifecycle is not None:
|
|
actual = context.tool_model.lifecycle.discover
|
|
else:
|
|
actual = None
|
|
assert actual is None, f"Expected lifecycle.discover to be None, got {actual!r}"
|
|
|
|
|
|
@then('the tool lifecycle activate should be "{expected}"')
|
|
def step_check_lifecycle_activate(context: Context, expected: str) -> None:
|
|
"""Assert the lifecycle activate hook equals the expected value."""
|
|
if hasattr(context, "tool_lifecycle"):
|
|
actual = context.tool_lifecycle.activate
|
|
else:
|
|
assert context.tool_model.lifecycle is not None
|
|
actual = context.tool_model.lifecycle.activate
|
|
assert actual == expected, (
|
|
f"Expected lifecycle.activate == {expected!r}, got {actual!r}"
|
|
)
|
|
|
|
|
|
@then("the tool lifecycle activate should be None")
|
|
def step_check_lifecycle_activate_none(context: Context) -> None:
|
|
"""Assert the lifecycle activate hook is None."""
|
|
if hasattr(context, "tool_lifecycle"):
|
|
actual = context.tool_lifecycle.activate
|
|
elif context.tool_model.lifecycle is not None:
|
|
actual = context.tool_model.lifecycle.activate
|
|
else:
|
|
actual = None
|
|
assert actual is None, f"Expected lifecycle.activate to be None, got {actual!r}"
|
|
|
|
|
|
@then('the tool lifecycle deactivate should be "{expected}"')
|
|
def step_check_lifecycle_deactivate(context: Context, expected: str) -> None:
|
|
"""Assert the lifecycle deactivate hook equals the expected value."""
|
|
if hasattr(context, "tool_lifecycle"):
|
|
actual = context.tool_lifecycle.deactivate
|
|
else:
|
|
assert context.tool_model.lifecycle is not None
|
|
actual = context.tool_model.lifecycle.deactivate
|
|
assert actual == expected, (
|
|
f"Expected lifecycle.deactivate == {expected!r}, got {actual!r}"
|
|
)
|
|
|
|
|
|
@then("the tool lifecycle deactivate should be None")
|
|
def step_check_lifecycle_deactivate_none(context: Context) -> None:
|
|
"""Assert the lifecycle deactivate hook is None."""
|
|
if hasattr(context, "tool_lifecycle"):
|
|
actual = context.tool_lifecycle.deactivate
|
|
elif context.tool_model.lifecycle is not None:
|
|
actual = context.tool_model.lifecycle.deactivate
|
|
else:
|
|
actual = None
|
|
assert actual is None, f"Expected lifecycle.deactivate to be None, got {actual!r}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — as_cli_dict lifecycle assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the tool cli dict lifecycle should have key "{key}"')
|
|
def step_check_cli_dict_lifecycle_has_key(context: Context, key: str) -> None:
|
|
"""Assert the lifecycle dict in as_cli_dict contains the given key."""
|
|
assert "lifecycle" in context.tool_cli_dict, (
|
|
"Expected 'lifecycle' key in cli dict, but it was absent"
|
|
)
|
|
lc = context.tool_cli_dict["lifecycle"]
|
|
assert key in lc, (
|
|
f"Expected lifecycle dict to have key {key!r}, got keys: {list(lc.keys())}"
|
|
)
|
|
|
|
|
|
@then('the tool cli dict lifecycle execute should be "{expected}"')
|
|
def step_check_cli_dict_lifecycle_execute(context: Context, expected: str) -> None:
|
|
"""Assert the lifecycle.execute value in as_cli_dict equals expected."""
|
|
assert "lifecycle" in context.tool_cli_dict, (
|
|
"Expected 'lifecycle' key in cli dict, but it was absent"
|
|
)
|
|
actual = context.tool_cli_dict["lifecycle"].get("execute")
|
|
assert actual == expected, (
|
|
f"Expected lifecycle.execute == {expected!r}, got {actual!r}"
|
|
)
|
|
|
|
|
|
@then('the tool cli dict should not have key "{key}"')
|
|
def step_check_cli_dict_missing_key(context: Context, key: str) -> None:
|
|
"""Assert the cli dict does NOT contain the given key."""
|
|
assert key not in context.tool_cli_dict, (
|
|
f"Expected cli dict NOT to have key {key!r}, but it was present"
|
|
)
|