c4cda5d6d8
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 17s
CI / typecheck (pull_request) Successful in 31s
CI / security (pull_request) Successful in 45s
CI / unit_tests (pull_request) Successful in 1m42s
CI / docker (pull_request) Successful in 39s
CI / integration_tests (pull_request) Successful in 3m3s
CI / coverage (pull_request) Successful in 4m13s
CI / benchmark-regression (pull_request) Has been cancelled
- Fix rationale test to assert goal text appears in rationale rather than only checking non-empty (P1-1) - Align side_effects value to specification: use ["spawn_subplan"] instead of ["emit_decision"] per docs/specification.md §18259 (P2-1) - Remove dead TYPE_CHECKING import guard that contained only pass (P2-2) - Replace frozenset with tuple for _VALID_CONTEXT_VIEWS to ensure deterministic JSON schema enum ordering and eliminate CI noise (P2-3) - Simplify validation error message and schema unpacking now that context_view values are in a pre-sorted tuple
362 lines
12 KiB
Python
362 lines
12 KiB
Python
"""Step definitions for plan_subplan_tool.feature.
|
|
|
|
Tests the builtin/plan-subplan tool: payload validation, defaults,
|
|
decision emission, rationale generation, and ToolRunner integration.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from behave import given, then, when # type: ignore[import-untyped]
|
|
from behave.runner import Context # type: ignore[import-untyped]
|
|
from ulid import ULID
|
|
|
|
from cleveragents.domain.models.core.decision import Decision
|
|
from cleveragents.tool.builtins.subplan_tool import (
|
|
PLAN_SUBPLAN_SPEC,
|
|
make_plan_subplan_spec,
|
|
)
|
|
from cleveragents.tool.registry import ToolRegistry
|
|
from cleveragents.tool.runner import ToolRunner
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PLAN_ID = str(ULID())
|
|
_SEQ = 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stub decision service
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _StubDecisionService:
|
|
"""In-memory stub that records decisions without a database."""
|
|
|
|
def __init__(self) -> None:
|
|
self.recorded: list[Decision] = []
|
|
|
|
def record_decision(self, decision: Decision) -> Decision:
|
|
self.recorded.append(decision)
|
|
return decision
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: build minimal valid inputs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _inputs(
|
|
goal: str = "Implement feature X",
|
|
resource_scopes: list[str] | None = None,
|
|
project_ref: str | None = None,
|
|
parallel: bool = False,
|
|
merge_strategy: str | None = None,
|
|
max_parallel: int | None = None,
|
|
dependencies: list[str] | None = None,
|
|
context_view: str | None = None,
|
|
) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"goal": goal,
|
|
"plan_id": _PLAN_ID,
|
|
"sequence_number": _SEQ,
|
|
"parallel": parallel,
|
|
}
|
|
if resource_scopes is not None:
|
|
payload["resource_scopes"] = resource_scopes
|
|
if project_ref is not None:
|
|
payload["project_ref"] = project_ref
|
|
if merge_strategy is not None:
|
|
payload["merge_strategy"] = merge_strategy
|
|
if max_parallel is not None:
|
|
payload["max_parallel"] = max_parallel
|
|
if dependencies is not None:
|
|
payload["dependencies"] = dependencies
|
|
if context_view is not None:
|
|
payload["context_view"] = context_view
|
|
return payload
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan_subplan tool handler")
|
|
def step_given_handler(ctx: Context) -> None:
|
|
ctx.subplan_handler = PLAN_SUBPLAN_SPEC.handler
|
|
|
|
|
|
@given("a plan_subplan tool handler with a stub decision service")
|
|
def step_given_handler_with_service(ctx: Context) -> None:
|
|
ctx.stub_service = _StubDecisionService()
|
|
spec = make_plan_subplan_spec(decision_service=ctx.stub_service)
|
|
ctx.subplan_handler = spec.handler
|
|
|
|
|
|
@given("a ToolRunner with the plan_subplan tool registered")
|
|
def step_given_runner(ctx: Context) -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(PLAN_SUBPLAN_SPEC)
|
|
ctx.runner = ToolRunner(registry)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I invoke plan_subplan with goal "{goal}" and resource_scopes {scopes}')
|
|
def step_when_with_scopes(ctx: Context, goal: str, scopes: str) -> None:
|
|
ctx.result = ctx.subplan_handler(
|
|
_inputs(goal=goal, resource_scopes=json.loads(scopes))
|
|
)
|
|
|
|
|
|
@when('I invoke plan_subplan with goal "{goal}" and project_ref "{ref}"')
|
|
def step_when_with_project_ref(ctx: Context, goal: str, ref: str) -> None:
|
|
ctx.result = ctx.subplan_handler(_inputs(goal=goal, project_ref=ref))
|
|
|
|
|
|
@when("I invoke parallel plan_subplan with dependencies {deps}")
|
|
def step_when_parallel_with_deps(ctx: Context, deps: str) -> None:
|
|
ctx.result = ctx.subplan_handler(
|
|
_inputs(
|
|
goal="Parallel work",
|
|
resource_scopes=["src/"],
|
|
parallel=True,
|
|
dependencies=json.loads(deps),
|
|
)
|
|
)
|
|
|
|
|
|
@when('I invoke plan_subplan with goal "{goal}" and no resource scope or project ref')
|
|
def step_when_no_scope(ctx: Context, goal: str) -> None:
|
|
ctx.result = ctx.subplan_handler(
|
|
{"goal": goal, "plan_id": _PLAN_ID, "sequence_number": 0}
|
|
)
|
|
|
|
|
|
@when("I invoke plan_subplan with empty goal and resource_scopes {scopes}")
|
|
def step_when_empty_goal(ctx: Context, scopes: str) -> None:
|
|
ctx.result = ctx.subplan_handler(
|
|
_inputs(goal="", resource_scopes=json.loads(scopes))
|
|
)
|
|
|
|
|
|
@when("I invoke parallel plan_subplan with max_parallel {n:d}")
|
|
def step_when_bad_max_parallel(ctx: Context, n: int) -> None:
|
|
ctx.result = ctx.subplan_handler(
|
|
_inputs(resource_scopes=["src/"], parallel=True, max_parallel=n)
|
|
)
|
|
|
|
|
|
@when('I invoke plan_subplan with invalid merge_strategy "{strategy}"')
|
|
def step_when_bad_strategy(ctx: Context, strategy: str) -> None:
|
|
ctx.result = ctx.subplan_handler(
|
|
_inputs(resource_scopes=["src/"], merge_strategy=strategy)
|
|
)
|
|
|
|
|
|
@when('I invoke plan_subplan with invalid context_view "{view}"')
|
|
def step_when_bad_context_view(ctx: Context, view: str) -> None:
|
|
ctx.result = ctx.subplan_handler(
|
|
_inputs(resource_scopes=["src/"], context_view=view)
|
|
)
|
|
|
|
|
|
@when("I invoke plan_subplan with a parent_decision_id and resource_scopes {scopes}")
|
|
def step_when_with_parent_decision(ctx: Context, scopes: str) -> None:
|
|
parent_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
|
inp = _inputs(resource_scopes=json.loads(scopes))
|
|
inp["parent_decision_id"] = parent_id
|
|
ctx.result = ctx.subplan_handler(inp)
|
|
|
|
|
|
@when('I invoke plan_subplan with invalid plan_id "{pid}" and resource_scopes {scopes}')
|
|
def step_when_invalid_plan_id(ctx: Context, pid: str, scopes: str) -> None:
|
|
inp = _inputs(resource_scopes=json.loads(scopes))
|
|
inp["plan_id"] = pid
|
|
ctx.result = ctx.subplan_handler(inp)
|
|
|
|
|
|
@when(
|
|
'I invoke plan_subplan with invalid dependency "{dep}" and resource_scopes {scopes}'
|
|
)
|
|
def step_when_invalid_dep(ctx: Context, dep: str, scopes: str) -> None:
|
|
ctx.result = ctx.subplan_handler(
|
|
_inputs(resource_scopes=json.loads(scopes), dependencies=[dep])
|
|
)
|
|
|
|
|
|
@when(
|
|
'I invoke plan_subplan with invalid parent_decision_id "{pid}" and resource_scopes {scopes}'
|
|
)
|
|
def step_when_invalid_parent_decision_id(ctx: Context, pid: str, scopes: str) -> None:
|
|
inp = _inputs(resource_scopes=json.loads(scopes))
|
|
inp["parent_decision_id"] = pid
|
|
ctx.result = ctx.subplan_handler(inp)
|
|
|
|
|
|
@when("I invoke plan_subplan with empty resource scope item")
|
|
def step_when_empty_scope_item(ctx: Context) -> None:
|
|
ctx.result = ctx.subplan_handler(_inputs(resource_scopes=[""]))
|
|
|
|
|
|
@when("I invoke parallel plan_subplan with no max_parallel override")
|
|
def step_when_default_max_parallel(ctx: Context) -> None:
|
|
ctx.result = ctx.subplan_handler(_inputs(resource_scopes=["src/"], parallel=True))
|
|
|
|
|
|
@when('I invoke plan_subplan with context_view "{view}" and resource_scopes {scopes}')
|
|
def step_when_context_view(ctx: Context, view: str, scopes: str) -> None:
|
|
ctx.result = ctx.subplan_handler(
|
|
_inputs(resource_scopes=json.loads(scopes), context_view=view)
|
|
)
|
|
|
|
|
|
@when("I register the plan_subplan tool in a ToolRegistry")
|
|
def step_when_register(ctx: Context) -> None:
|
|
ctx.registry = ToolRegistry()
|
|
ctx.registry.register(PLAN_SUBPLAN_SPEC)
|
|
|
|
|
|
@when("I run plan_subplan via the ToolRunner with valid inputs")
|
|
def step_when_run_via_runner(ctx: Context) -> None:
|
|
ctx.runner_result = ctx.runner.execute(
|
|
"builtin/plan-subplan",
|
|
_inputs(goal="Runner test", resource_scopes=["src/"]),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the plan_subplan result should succeed")
|
|
def step_then_success(ctx: Context) -> None:
|
|
r = ctx.result
|
|
assert isinstance(r, dict), f"Expected dict, got {type(r)}"
|
|
assert r.get("success") is True, f"Expected success=True, got: {r}"
|
|
|
|
|
|
@then("the plan_subplan result should fail")
|
|
def step_then_fail(ctx: Context) -> None:
|
|
r = ctx.result
|
|
assert isinstance(r, dict), f"Expected dict, got {type(r)}"
|
|
assert r.get("success") is False, f"Expected success=False, got: {r}"
|
|
|
|
|
|
@then('the plan_subplan result decision_type should be "{dtype}"')
|
|
def step_then_dtype(ctx: Context, dtype: str) -> None:
|
|
r = ctx.result
|
|
assert r.get("decision_type") == dtype, (
|
|
f"Expected decision_type={dtype!r}, got: {r.get('decision_type')!r}"
|
|
)
|
|
|
|
|
|
@then('the plan_subplan error should mention "{keyword}"')
|
|
def step_then_error_keyword(ctx: Context, keyword: str) -> None:
|
|
err = (r := ctx.result).get("error") or ""
|
|
assert keyword.lower() in err.lower(), (
|
|
f"Expected error to mention {keyword!r} in {r}"
|
|
)
|
|
|
|
|
|
@then('the plan_subplan result output merge_strategy should be "{value}"')
|
|
def step_then_merge_strategy(ctx: Context, value: str) -> None:
|
|
r = ctx.result
|
|
assert r.get("merge_strategy") == value, (
|
|
f"Expected merge_strategy={value!r}, got: {r.get('merge_strategy')!r}"
|
|
)
|
|
|
|
|
|
@then("the plan_subplan result output max_parallel should be {n:d}")
|
|
def step_then_max_parallel(ctx: Context, n: int) -> None:
|
|
r = ctx.result
|
|
assert r.get("max_parallel") == n, (
|
|
f"Expected max_parallel={n}, got: {r.get('max_parallel')!r}"
|
|
)
|
|
|
|
|
|
@then("the plan_subplan result output parallel should be false")
|
|
def step_then_not_parallel(ctx: Context) -> None:
|
|
r = ctx.result
|
|
assert r.get("parallel") is False, (
|
|
f"Expected parallel=False, got: {r.get('parallel')!r}"
|
|
)
|
|
|
|
|
|
@then("the plan_subplan result output dependencies should be empty")
|
|
def step_then_empty_deps(ctx: Context) -> None:
|
|
r = ctx.result
|
|
assert r.get("dependencies") == [], f"Expected [], got: {r.get('dependencies')!r}"
|
|
|
|
|
|
@then("the plan_subplan result rationale should mention the goal")
|
|
def step_then_rationale_goal(ctx: Context) -> None:
|
|
r = ctx.result
|
|
rationale = r.get("rationale") or ""
|
|
goal = r.get("goal") or ""
|
|
assert goal and goal in rationale, (
|
|
f"Expected rationale to contain goal {goal!r}, got: {rationale!r}"
|
|
)
|
|
|
|
|
|
@then('the plan_subplan result rationale should mention "{keyword}"')
|
|
def step_then_rationale_keyword(ctx: Context, keyword: str) -> None:
|
|
rationale = (ctx.result.get("rationale") or "").lower()
|
|
assert keyword.lower() in rationale, (
|
|
f"Expected rationale to mention {keyword!r}, got: {rationale!r}"
|
|
)
|
|
|
|
|
|
@then('the plan_subplan result output context_view should be "{view}"')
|
|
def step_then_context_view(ctx: Context, view: str) -> None:
|
|
r = ctx.result
|
|
assert r.get("context_view") == view, (
|
|
f"Expected context_view={view!r}, got: {r.get('context_view')!r}"
|
|
)
|
|
|
|
|
|
@then('the plan_subplan result output parent_decision_id should be "{value}"')
|
|
def step_then_parent_decision_id(ctx: Context, value: str) -> None:
|
|
r = ctx.result
|
|
assert r.get("parent_decision_id") == value, (
|
|
f"Expected parent_decision_id={value!r}, got: {r.get('parent_decision_id')!r}"
|
|
)
|
|
|
|
|
|
@then("the stub decision service should have recorded {n:d} decision")
|
|
def step_then_service_recorded(ctx: Context, n: int) -> None:
|
|
stub: _StubDecisionService = ctx.stub_service
|
|
assert len(stub.recorded) == n, (
|
|
f"Expected {n} recorded decisions, got {len(stub.recorded)}"
|
|
)
|
|
|
|
|
|
@then('the recorded decision type should be "{dtype}"')
|
|
def step_then_recorded_dtype(ctx: Context, dtype: str) -> None:
|
|
stub: _StubDecisionService = ctx.stub_service
|
|
assert stub.recorded, "No decisions were recorded"
|
|
assert str(stub.recorded[0].decision_type) == dtype, (
|
|
f"Expected {dtype!r}, got {stub.recorded[0].decision_type!r}"
|
|
)
|
|
|
|
|
|
@then('the registry should contain "{name}"')
|
|
def step_then_registry_contains(ctx: Context, name: str) -> None:
|
|
spec = ctx.registry.get(name)
|
|
assert spec is not None, f"Expected '{name}' in registry"
|
|
|
|
|
|
@then("the ToolRunner result should succeed")
|
|
def step_then_runner_success(ctx: Context) -> None:
|
|
result = ctx.runner_result
|
|
assert result.success, f"Expected success=True, error={result.error}"
|