"""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}"