ca3399e177
ci.yml / fix(acms): invoke SkeletonCompressor in ContextAssembler.assemble() to propagate skeleton context to child plans (pull_request) Failing after 0s
CI / lint (pull_request) Failing after 31s
CI / quality (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 51s
CI / security (pull_request) Successful in 58s
CI / coverage (pull_request) Has been skipped
CI / build (pull_request) Successful in 19s
CI / helm (pull_request) Successful in 24s
CI / unit_tests (pull_request) Failing after 6m58s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 17m14s
CI / integration_tests (pull_request) Failing after 22m37s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
Added skeleton_fragments: tuple[ContextFragment, ...] field to ContextPayload in context_fragment.py - Enables carrying compressed skeleton fragments along with normal context. Extended ACMSPipeline.assemble() in acms_service.py - Introduced skeleton_ratio: float = 0.15 (default matching spec) and parent_fragments: tuple[ContextFragment, ...] | None = None parameters. - These same parameters are also added to ContextAssemblyPipeline.assemble() in acms_pipeline.py for consistency. Skeleton compression integration - In Phase 3 of both assemble() methods, computed skeleton_budget = int(budget.available_tokens * skeleton_ratio) and invoked self._skeleton_compressor.compress(parent_fragments, skeleton_budget). - Compressed skeleton fragments are included in the returned ContextPayload.skeleton_fragments, enabling propagation of skeleton context to child plans. Tests and behavior coverage - Added a TDD issue-capture Behave scenario (@tdd_issue @tdd_issue_3563) to demonstrate the fix. - Added four Behave unit test scenarios asserting: compressor invocation, correct arguments, skeleton presence in output, and skeleton_ratio budget enforcement. - Added a Robot Framework integration test: parent plan accumulates context → child plan spawned → child plan context contains non-empty skeleton. - Added skeleton-context-inheritance command to helper_acms_pipeline.py to support testing and manual verification. Key design decisions - skeleton_ratio defaults to 0.15 to align with the spec's --skeleton-ratio default. - parent_fragments is None by default to maintain backward compatibility (no skeleton compression when no parent context). - skeleton_budget is computed as skeleton_budget = int(budget.available_tokens * skeleton_ratio), deriving the skeleton budget from the total token budget. - Both ACMSPipeline and ContextAssemblyPipeline are fixed to maintain consistency across the codepath. ISSUES CLOSED: #3563
1198 lines
42 KiB
Python
1198 lines
42 KiB
Python
"""Step definitions for features/acms_pipeline.feature.
|
|
|
|
Tests the ACMS v1 context assembly pipeline domain models and service
|
|
directly in-memory -- no database required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from pydantic import ValidationError
|
|
|
|
from cleveragents.application.services.acms_service import (
|
|
ACMSPipeline,
|
|
DefaultBudgetAllocator,
|
|
DefaultSkeletonCompressor,
|
|
DefaultStrategySelector,
|
|
RecencyStrategy,
|
|
RelevanceStrategy,
|
|
StrategyCapabilities,
|
|
TieredStrategy,
|
|
)
|
|
from cleveragents.domain.models.acms.crp import ContextRequest
|
|
from cleveragents.domain.models.core.context_fragment import (
|
|
ContextBudget,
|
|
ContextFragment,
|
|
ContextPayload,
|
|
FragmentProvenance,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Default provenance for test fragments.
|
|
_DEFAULT_PROVENANCE = FragmentProvenance(resource_uri="test://default")
|
|
|
|
|
|
def _make_fragment(**kwargs: Any) -> ContextFragment:
|
|
"""Create a ContextFragment with sensible test defaults.
|
|
|
|
Callers can override any field. ``uko_node``, ``token_count``, and
|
|
``provenance`` get defaults so that most test steps don't need to
|
|
specify them explicitly.
|
|
"""
|
|
kwargs.setdefault("uko_node", "test://default")
|
|
kwargs.setdefault("token_count", 0)
|
|
kwargs.setdefault("provenance", _DEFAULT_PROVENANCE)
|
|
return ContextFragment(**kwargs)
|
|
|
|
|
|
class _ReverseContextStrategy:
|
|
"""Test strategy that reverses fragment order and fits to budget."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "reverse"
|
|
|
|
@property
|
|
def capabilities(self) -> StrategyCapabilities:
|
|
return StrategyCapabilities()
|
|
|
|
def can_handle(self, request: dict[str, Any]) -> float:
|
|
return 0.5
|
|
|
|
def assemble(
|
|
self,
|
|
fragments: Sequence[ContextFragment],
|
|
budget: ContextBudget,
|
|
) -> Sequence[ContextFragment]:
|
|
reversed_frags = list(reversed(fragments))
|
|
result: list[ContextFragment] = []
|
|
total = 0
|
|
available = budget.available_tokens
|
|
for frag in reversed_frags:
|
|
if total >= available:
|
|
break
|
|
if total + frag.token_count <= available:
|
|
result.append(frag)
|
|
total += frag.token_count
|
|
return result
|
|
|
|
def explain(self) -> str:
|
|
return "Reverses fragment order for testing."
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given — ContextFragment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given(
|
|
'a context fragment with uko_node "{uko_node}" and content "{content}" and token_count {tokens:d}'
|
|
)
|
|
def step_fragment_with_defaults(
|
|
context: Context, uko_node: str, content: str, tokens: int
|
|
) -> None:
|
|
context.fragment = ContextFragment(
|
|
uko_node=uko_node,
|
|
content=content,
|
|
token_count=tokens,
|
|
provenance=FragmentProvenance(resource_uri=uko_node),
|
|
)
|
|
|
|
|
|
@given("a context fragment with all fields:")
|
|
def step_fragment_with_all_fields(context: Context) -> None:
|
|
data: dict[str, str] = {}
|
|
for row in context.table:
|
|
data[row["field"]] = row["value"]
|
|
meta: dict[str, str] = {}
|
|
if "meta_key" in data and "meta_val" in data:
|
|
meta[data["meta_key"]] = data["meta_val"]
|
|
resource_uri = data.get("resource_uri", data["uko_node"])
|
|
context.fragment = ContextFragment(
|
|
uko_node=data["uko_node"],
|
|
content=data["content"],
|
|
relevance_score=float(data["score"]),
|
|
token_count=int(data["tokens"]),
|
|
detail_depth=int(data.get("detail_depth", "0")),
|
|
tier=data["tier"],
|
|
metadata=meta,
|
|
provenance=FragmentProvenance(resource_uri=resource_uri),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given — ContextBudget
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a context budget with max_tokens {max_tok:d} and reserved_tokens {res:d}")
|
|
def step_budget(context: Context, max_tok: int, res: int) -> None:
|
|
context.budget = ContextBudget(max_tokens=max_tok, reserved_tokens=res)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given — Fragment collections
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the following context fragments:")
|
|
def step_fragments_table(context: Context) -> None:
|
|
context.fragments = []
|
|
for row in context.table:
|
|
uko_node = row["uko_node"]
|
|
frag = ContextFragment(
|
|
uko_node=uko_node,
|
|
content=row["content"],
|
|
relevance_score=float(row["score"]),
|
|
token_count=int(row["tokens"]),
|
|
provenance=FragmentProvenance(resource_uri=uko_node),
|
|
)
|
|
context.fragments.append(frag)
|
|
|
|
|
|
@given("context fragments with different timestamps:")
|
|
def step_fragments_with_timestamps(context: Context) -> None:
|
|
context.fragments = []
|
|
for row in context.table:
|
|
uko_node = row["uko_node"]
|
|
frag = ContextFragment(
|
|
uko_node=uko_node,
|
|
content=row["content"],
|
|
token_count=int(row["tokens"]),
|
|
created_at=datetime.fromisoformat(row["created_at"]),
|
|
provenance=FragmentProvenance(resource_uri=uko_node),
|
|
)
|
|
context.fragments.append(frag)
|
|
|
|
|
|
@given("the following tiered context fragments:")
|
|
def step_tiered_fragments(context: Context) -> None:
|
|
context.fragments = []
|
|
for row in context.table:
|
|
uko_node = row["uko_node"]
|
|
frag = ContextFragment(
|
|
uko_node=uko_node,
|
|
content=row["content"],
|
|
relevance_score=float(row["score"]),
|
|
token_count=int(row["tokens"]),
|
|
tier=row["tier"],
|
|
provenance=FragmentProvenance(resource_uri=uko_node),
|
|
)
|
|
context.fragments.append(frag)
|
|
|
|
|
|
@given("no context fragments")
|
|
def step_no_fragments(context: Context) -> None:
|
|
context.fragments = []
|
|
|
|
|
|
@given("a custom context strategy that reverses fragment order")
|
|
def step_register_custom_strategy(context: Context) -> None:
|
|
if not hasattr(context, "pipeline"):
|
|
context.pipeline = ACMSPipeline()
|
|
context.pipeline.register_strategy("reverse", _ReverseContextStrategy())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When — Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the ACMS pipeline modules are available")
|
|
def step_acms_modules_available(context: Context) -> None:
|
|
# Just ensures the imports are loadable; nothing to store.
|
|
pass
|
|
|
|
|
|
@when("I create a budget with reserved_tokens equal to max_tokens")
|
|
def step_budget_reserved_equals_max(context: Context) -> None:
|
|
context.acms_error = None
|
|
try:
|
|
ContextBudget(max_tokens=100, reserved_tokens=100)
|
|
except ValidationError as exc:
|
|
context.acms_error = exc
|
|
|
|
|
|
@when("I create a budget with reserved_tokens greater than max_tokens")
|
|
def step_budget_reserved_greater_than_max(context: Context) -> None:
|
|
context.acms_error = None
|
|
try:
|
|
ContextBudget(max_tokens=100, reserved_tokens=200)
|
|
except ValidationError as exc:
|
|
context.acms_error = exc
|
|
|
|
|
|
@when("I create a budget with reserved_tokens {res:d} and max_tokens {max_tok:d}")
|
|
def step_budget_specific(context: Context, res: int, max_tok: int) -> None:
|
|
context.acms_error = None
|
|
try:
|
|
ContextBudget(max_tokens=max_tok, reserved_tokens=res)
|
|
except ValidationError as exc:
|
|
context.acms_error = exc
|
|
|
|
|
|
@when('I create a fragment with invalid tier "{tier}"')
|
|
def step_invalid_tier(context: Context, tier: str) -> None:
|
|
context.acms_error = None
|
|
try:
|
|
_make_fragment(content="test", tier=tier, token_count=1)
|
|
except ValidationError as exc:
|
|
context.acms_error = exc
|
|
|
|
|
|
@when("I create a fragment with relevance score {score}")
|
|
def step_create_fragment_bad_score(context: Context, score: str) -> None:
|
|
context.acms_error = None
|
|
try:
|
|
_make_fragment(content="test", relevance_score=float(score), token_count=1)
|
|
except ValidationError as exc:
|
|
context.acms_error = exc
|
|
|
|
|
|
@when("I create a fragment with token count {tokens:d}")
|
|
def step_create_fragment_bad_tokens(context: Context, tokens: int) -> None:
|
|
context.acms_error = None
|
|
try:
|
|
_make_fragment(content="test", token_count=tokens)
|
|
except ValidationError as exc:
|
|
context.acms_error = exc
|
|
|
|
|
|
@when("I create a fragment with empty uko_node")
|
|
def step_create_fragment_empty_uko_node(context: Context) -> None:
|
|
context.acms_error = None
|
|
try:
|
|
ContextFragment(
|
|
uko_node="",
|
|
content="test",
|
|
token_count=1,
|
|
provenance=_DEFAULT_PROVENANCE,
|
|
)
|
|
except ValidationError as exc:
|
|
context.acms_error = exc
|
|
|
|
|
|
@when("I create a fragment with detail depth {depth:d}")
|
|
def step_create_fragment_bad_depth(context: Context, depth: int) -> None:
|
|
context.acms_error = None
|
|
try:
|
|
_make_fragment(content="test", detail_depth=depth, token_count=1)
|
|
except ValidationError as exc:
|
|
context.acms_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When — Assembly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I assemble with strategy "{strategy}"')
|
|
def step_assemble_with_strategy(context: Context, strategy: str) -> None:
|
|
if not hasattr(context, "pipeline"):
|
|
context.pipeline = ACMSPipeline()
|
|
context.assemble_error = None
|
|
try:
|
|
context.payload = context.pipeline.assemble(
|
|
plan_id="01JQTESTPN00000000000000AA",
|
|
fragments=list(context.fragments),
|
|
budget=context.budget,
|
|
strategy=strategy,
|
|
)
|
|
except ValueError as exc:
|
|
context.assemble_error = exc
|
|
|
|
|
|
@when("I assemble without specifying a strategy")
|
|
def step_assemble_default_strategy(context: Context) -> None:
|
|
if not hasattr(context, "pipeline"):
|
|
context.pipeline = ACMSPipeline()
|
|
context.payload = context.pipeline.assemble(
|
|
plan_id="01JQTESTPN00000000000000AA",
|
|
fragments=list(context.fragments),
|
|
budget=context.budget,
|
|
)
|
|
|
|
|
|
@when('I register the reverse strategy as "{name}"')
|
|
def step_register_reverse_as(context: Context, name: str) -> None:
|
|
if not hasattr(context, "pipeline"):
|
|
context.pipeline = ACMSPipeline()
|
|
context.pipeline.register_strategy(name, _ReverseContextStrategy())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then — Fragment assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the fragment content should be "{content}"')
|
|
def step_fragment_content(context: Context, content: str) -> None:
|
|
assert context.fragment.content == content
|
|
|
|
|
|
@then("the fragment relevance score should be {score}")
|
|
def step_fragment_score(context: Context, score: str) -> None:
|
|
assert context.fragment.relevance_score == float(score)
|
|
|
|
|
|
@then("the fragment token count should be {tokens:d}")
|
|
def step_fragment_tokens(context: Context, tokens: int) -> None:
|
|
assert context.fragment.token_count == tokens
|
|
|
|
|
|
@then("the fragment detail depth should be {depth:d}")
|
|
def step_fragment_detail_depth(context: Context, depth: int) -> None:
|
|
assert context.fragment.detail_depth == depth
|
|
|
|
|
|
@then("the fragment metadata should be empty")
|
|
def step_fragment_metadata_empty(context: Context) -> None:
|
|
assert context.fragment.metadata == {}
|
|
|
|
|
|
@then("the fragment id should be set")
|
|
def step_fragment_id_set(context: Context) -> None:
|
|
assert context.fragment.fragment_id
|
|
assert len(context.fragment.fragment_id) > 0
|
|
|
|
|
|
@then("the fragment created_at should be a datetime")
|
|
def step_fragment_created_at_is_datetime(context: Context) -> None:
|
|
assert isinstance(context.fragment.created_at, datetime)
|
|
|
|
|
|
@then("the fragment provenance resource_uri should be set")
|
|
def step_fragment_provenance_set(context: Context) -> None:
|
|
assert context.fragment.provenance.resource_uri
|
|
assert len(context.fragment.provenance.resource_uri) > 0
|
|
|
|
|
|
@then('the fragment metadata key "{key}" should be "{value}"')
|
|
def step_fragment_metadata_key(context: Context, key: str, value: str) -> None:
|
|
assert context.fragment.metadata[key] == value
|
|
|
|
|
|
@then('the fragment should have field "{field_name}"')
|
|
def step_fragment_has_field(context: Context, field_name: str) -> None:
|
|
assert hasattr(context.fragment, field_name), (
|
|
f"ContextFragment missing field: {field_name}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then — Budget assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the available tokens should be {tokens:d}")
|
|
def step_available_tokens(context: Context, tokens: int) -> None:
|
|
assert context.budget.available_tokens == tokens
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then — Validation assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("an ACMS validation error should be raised")
|
|
def step_acms_validation_error_raised(context: Context) -> None:
|
|
error = getattr(context, "acms_error", None)
|
|
assert error is not None, "Expected a validation error but none was raised"
|
|
|
|
|
|
@then("an ACMS strategy error should be raised")
|
|
def step_acms_strategy_error_raised(context: Context) -> None:
|
|
assert context.assemble_error is not None, (
|
|
"Expected a strategy error but none was raised"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then — Payload assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the payload should contain {count:d} fragments")
|
|
def step_payload_fragment_count(context: Context, count: int) -> None:
|
|
assert len(context.payload.fragments) == count
|
|
|
|
|
|
@then('the first fragment content should be "{content}"')
|
|
def step_first_fragment_content(context: Context, content: str) -> None:
|
|
assert context.payload.fragments[0].content == content
|
|
|
|
|
|
@then('the second fragment content should be "{content}"')
|
|
def step_second_fragment_content(context: Context, content: str) -> None:
|
|
assert context.payload.fragments[1].content == content
|
|
|
|
|
|
@then("the payload total tokens should be {tokens:d}")
|
|
def step_payload_total_tokens(context: Context, tokens: int) -> None:
|
|
assert context.payload.total_tokens == tokens
|
|
|
|
|
|
@then("the payload should be within budget")
|
|
def step_payload_within_budget(context: Context) -> None:
|
|
assert context.payload.is_within_budget
|
|
|
|
|
|
@then('the payload strategies used should include "{strategy}"')
|
|
def step_payload_strategies_used(context: Context, strategy: str) -> None:
|
|
assert strategy in context.payload.strategies_used, (
|
|
f"Expected {strategy!r} in strategies_used={context.payload.strategies_used}"
|
|
)
|
|
|
|
|
|
@then("the payload remaining tokens should be {tokens:d}")
|
|
def step_payload_remaining_tokens(context: Context, tokens: int) -> None:
|
|
assert context.payload.remaining_tokens == tokens
|
|
|
|
|
|
@then("the payload budget used should be {fraction}")
|
|
def step_payload_budget_used(context: Context, fraction: str) -> None:
|
|
assert abs(context.payload.budget_used - float(fraction)) < 1e-4, (
|
|
f"Expected budget_used={fraction}, got {context.payload.budget_used}"
|
|
)
|
|
|
|
|
|
@then("the payload context hash should be non-empty")
|
|
def step_payload_context_hash(context: Context) -> None:
|
|
assert context.payload.context_hash, "Expected non-empty context_hash"
|
|
assert len(context.payload.context_hash) == 64, (
|
|
f"Expected SHA-256 hex (64 chars), got {len(context.payload.context_hash)}"
|
|
)
|
|
|
|
|
|
@then("the payload provenance map should map each fragment ID")
|
|
def step_payload_provenance_map(context: Context) -> None:
|
|
for frag in context.payload.fragments:
|
|
assert frag.fragment_id in context.payload.provenance_map, (
|
|
f"Fragment {frag.fragment_id} not in provenance_map"
|
|
)
|
|
|
|
|
|
@then('the payload plan_id should be "{plan_id}"')
|
|
def step_payload_plan_id(context: Context, plan_id: str) -> None:
|
|
assert context.payload.plan_id == plan_id, (
|
|
f"Expected plan_id={plan_id!r}, got {context.payload.plan_id!r}"
|
|
)
|
|
|
|
|
|
@then("the payload payload_id should be a non-empty ULID")
|
|
def step_payload_payload_id(context: Context) -> None:
|
|
pid = context.payload.payload_id
|
|
assert pid, "payload_id is empty"
|
|
assert len(pid) == 26, f"Expected ULID (26 chars), got {len(pid)} chars: {pid!r}"
|
|
|
|
|
|
@then("the payload assembled_at should be a recent UTC datetime")
|
|
def step_payload_assembled_at(context: Context) -> None:
|
|
assembled = context.payload.assembled_at
|
|
assert isinstance(assembled, datetime), (
|
|
f"assembled_at is not datetime, got {type(assembled)}"
|
|
)
|
|
# Should be within the last 60 seconds.
|
|
now = datetime.now(UTC)
|
|
delta = now - assembled
|
|
assert delta < timedelta(seconds=60), (
|
|
f"assembled_at is too old: {assembled} (now={now})"
|
|
)
|
|
|
|
|
|
@then('the payload should have field "{field_name}"')
|
|
def step_payload_has_field(context: Context, field_name: str) -> None:
|
|
assert hasattr(context.payload, field_name), (
|
|
f"ContextPayload missing field: {field_name}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T1 — DI injection steps for pipeline components
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _TrackingSelector:
|
|
"""Strategy selector that records whether it was called."""
|
|
|
|
def __init__(self) -> None:
|
|
self.called = False
|
|
|
|
def select(
|
|
self,
|
|
strategies: Sequence[Any],
|
|
request: dict[str, Any],
|
|
) -> list[tuple[Any, float]]:
|
|
self.called = True
|
|
# Delegate to default behaviour.
|
|
return DefaultStrategySelector().select(strategies, request)
|
|
|
|
|
|
class _HalvingAllocator:
|
|
"""Budget allocator that halves the total budget for each candidate."""
|
|
|
|
def __init__(self) -> None:
|
|
self.called = False
|
|
|
|
def allocate(
|
|
self,
|
|
candidates: list[tuple[Any, float]],
|
|
total_budget: int,
|
|
request: ContextRequest | None = None,
|
|
) -> list[tuple[Any, float, int]]:
|
|
self.called = True
|
|
half = total_budget // 2
|
|
return [(s, c, half) for s, c in candidates]
|
|
|
|
|
|
class _ReversingExecutor:
|
|
"""Strategy executor that reverses the fragment order."""
|
|
|
|
def __init__(self) -> None:
|
|
self.called = False
|
|
|
|
def execute(
|
|
self,
|
|
allocations: list[tuple[Any, float, int]],
|
|
fragments: Sequence[ContextFragment],
|
|
budget: ContextBudget,
|
|
) -> Sequence[ContextFragment]:
|
|
self.called = True
|
|
return list(reversed(fragments))
|
|
|
|
|
|
class _UkoDeduplicator:
|
|
"""Deduplicator that keeps only the first fragment per uko_node."""
|
|
|
|
def __init__(self) -> None:
|
|
self.called = False
|
|
|
|
def deduplicate(
|
|
self,
|
|
fragments: Sequence[ContextFragment],
|
|
) -> Sequence[ContextFragment]:
|
|
self.called = True
|
|
seen: set[str] = set()
|
|
result: list[ContextFragment] = []
|
|
for f in fragments:
|
|
if f.uko_node not in seen:
|
|
seen.add(f.uko_node)
|
|
result.append(f)
|
|
return result
|
|
|
|
|
|
class _CustomPreamble:
|
|
"""Preamble generator that produces a known string."""
|
|
|
|
def generate(
|
|
self,
|
|
fragments: Sequence[ContextFragment],
|
|
) -> str | None:
|
|
return f"Custom preamble: {len(fragments)} fragments"
|
|
|
|
|
|
@given("a pipeline with a custom tracking strategy selector")
|
|
def step_pipeline_custom_selector(context: Context) -> None:
|
|
selector = _TrackingSelector()
|
|
context.custom_selector = selector
|
|
context.pipeline = ACMSPipeline(strategy_selector=selector)
|
|
|
|
|
|
@given("a pipeline with a custom budget allocator that halves the budget")
|
|
def step_pipeline_custom_allocator(context: Context) -> None:
|
|
allocator = _HalvingAllocator()
|
|
context.custom_allocator = allocator
|
|
context.pipeline = ACMSPipeline(budget_allocator=allocator)
|
|
|
|
|
|
@given("a pipeline with a custom strategy executor that reverses fragments")
|
|
def step_pipeline_custom_executor(context: Context) -> None:
|
|
executor = _ReversingExecutor()
|
|
context.custom_executor = executor
|
|
context.pipeline = ACMSPipeline(strategy_executor=executor)
|
|
|
|
|
|
@given("a pipeline with a custom deduplicator that removes duplicates by uko_node")
|
|
def step_pipeline_custom_dedup(context: Context) -> None:
|
|
dedup = _UkoDeduplicator()
|
|
context.custom_deduplicator = dedup
|
|
context.pipeline = ACMSPipeline(deduplicator=dedup)
|
|
|
|
|
|
@given("a pipeline with a custom preamble generator")
|
|
def step_pipeline_custom_preamble(context: Context) -> None:
|
|
context.pipeline = ACMSPipeline(preamble_generator=_CustomPreamble())
|
|
|
|
|
|
@then("the custom strategy selector should have been called")
|
|
def step_selector_called(context: Context) -> None:
|
|
assert context.custom_selector.called, "Custom selector was not invoked"
|
|
|
|
|
|
@then("the custom budget allocator should have been called")
|
|
def step_allocator_called(context: Context) -> None:
|
|
assert context.custom_allocator.called, "Custom allocator was not invoked"
|
|
|
|
|
|
@then("the custom strategy executor should have been called")
|
|
def step_executor_called(context: Context) -> None:
|
|
assert context.custom_executor.called, "Custom executor was not invoked"
|
|
|
|
|
|
@then("the custom deduplicator should have been called")
|
|
def step_deduplicator_called(context: Context) -> None:
|
|
assert context.custom_deduplicator.called, "Custom deduplicator was not invoked"
|
|
|
|
|
|
@then('the payload preamble should be "{expected}"')
|
|
def step_preamble_value(context: Context, expected: str) -> None:
|
|
assert context.payload.preamble == expected, (
|
|
f"Expected preamble {expected!r}, got {context.payload.preamble!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T2 — SkeletonCompressor steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I compress fragments with the default skeleton compressor and budget {budget:d}")
|
|
def step_compress_default(context: Context, budget: int) -> None:
|
|
frags = (
|
|
_make_fragment(uko_node="test://a", content="hello", token_count=10),
|
|
_make_fragment(uko_node="test://b", content="world", token_count=10),
|
|
)
|
|
context.original_fragments = frags
|
|
compressor = DefaultSkeletonCompressor()
|
|
context.compressed_fragments = compressor.compress(frags, budget)
|
|
|
|
|
|
@then("the compressed fragments should equal the original fragments")
|
|
def step_compressed_equal(context: Context) -> None:
|
|
assert context.compressed_fragments == context.original_fragments
|
|
|
|
|
|
@when(
|
|
"I compress fragments with a truncating skeleton compressor and budget {budget:d}"
|
|
)
|
|
def step_compress_truncating(context: Context, budget: int) -> None:
|
|
frags = (
|
|
_make_fragment(uko_node="test://a", content="a" * 100, token_count=100),
|
|
_make_fragment(uko_node="test://b", content="b" * 100, token_count=100),
|
|
)
|
|
context.original_fragments = frags
|
|
|
|
class _TruncatingCompressor:
|
|
def compress(
|
|
self,
|
|
fragments: tuple[ContextFragment, ...],
|
|
skeleton_budget: int,
|
|
) -> tuple[ContextFragment, ...]:
|
|
result = []
|
|
remaining = skeleton_budget
|
|
for f in fragments:
|
|
if remaining <= 0:
|
|
break
|
|
# Re-create with truncated content.
|
|
trimmed = f.content[:remaining]
|
|
result.append(
|
|
_make_fragment(
|
|
uko_node=f.uko_node,
|
|
content=trimmed,
|
|
token_count=min(f.token_count, remaining),
|
|
)
|
|
)
|
|
remaining -= min(f.token_count, remaining)
|
|
return tuple(result)
|
|
|
|
compressor = _TruncatingCompressor()
|
|
context.compressed_fragments = compressor.compress(frags, budget)
|
|
|
|
|
|
@then("the compressed fragments should have reduced content")
|
|
def step_compressed_reduced(context: Context) -> None:
|
|
orig_total = sum(len(f.content) for f in context.original_fragments)
|
|
comp_total = sum(len(f.content) for f in context.compressed_fragments)
|
|
assert comp_total < orig_total, (
|
|
f"Expected reduced content: original={orig_total}, compressed={comp_total}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T3 — Multi-candidate allocation steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when(
|
|
"I allocate budget {budget:d} across candidates with confidences {c1:g} and {c2:g}"
|
|
)
|
|
def step_allocate_multi(context: Context, budget: int, c1: float, c2: float) -> None:
|
|
from cleveragents.application.services.acms_service import (
|
|
RecencyStrategy,
|
|
RelevanceStrategy,
|
|
)
|
|
|
|
s1 = RelevanceStrategy()
|
|
s2 = RecencyStrategy()
|
|
allocator = DefaultBudgetAllocator()
|
|
context.allocations = allocator.allocate([(s1, c1), (s2, c2)], budget)
|
|
context.alloc_budget = budget
|
|
|
|
|
|
@then("the first candidate should receive approximately {tokens:d} tokens")
|
|
def step_first_alloc(context: Context, tokens: int) -> None:
|
|
actual = context.allocations[0][2]
|
|
assert abs(actual - tokens) <= 1, (
|
|
f"First candidate got {actual}, expected ~{tokens}"
|
|
)
|
|
|
|
|
|
@then("the second candidate should receive approximately {tokens:d} tokens")
|
|
def step_second_alloc(context: Context, tokens: int) -> None:
|
|
actual = context.allocations[1][2]
|
|
assert abs(actual - tokens) <= 1, (
|
|
f"Second candidate got {actual}, expected ~{tokens}"
|
|
)
|
|
|
|
|
|
@then("the total allocated should not exceed {budget:d}")
|
|
def step_total_alloc(context: Context, budget: int) -> None:
|
|
total = sum(a[2] for a in context.allocations)
|
|
assert total <= budget, f"Total allocated {total} exceeds budget {budget}"
|
|
|
|
|
|
@when("I select strategies with no strategy hint from {count:d} registered strategies")
|
|
def step_select_no_hint(context: Context, count: int) -> None:
|
|
from cleveragents.application.services.acms_service import (
|
|
RecencyStrategy,
|
|
RelevanceStrategy,
|
|
TieredStrategy,
|
|
)
|
|
|
|
strategies = [RelevanceStrategy(), RecencyStrategy(), TieredStrategy()]
|
|
selector = DefaultStrategySelector()
|
|
context.selected = selector.select(strategies[:count], {})
|
|
|
|
|
|
@then("all {count:d} strategies should be returned with confidence scores")
|
|
def step_all_selected(context: Context, count: int) -> None:
|
|
assert len(context.selected) == count, (
|
|
f"Expected {count} selected strategies, got {len(context.selected)}"
|
|
)
|
|
for s, c in context.selected:
|
|
assert c > 0.0, f"Strategy {s.name} has zero confidence"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# plan_id ULID validation steps (SEC-1, SPEC-1, TEST-GAP-1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I create a payload with an empty plan_id")
|
|
def step_create_payload_empty_plan_id(context: Context) -> None:
|
|
context.acms_error = None
|
|
context.test_payload = None
|
|
try:
|
|
context.test_payload = ContextPayload(plan_id="")
|
|
except ValidationError as exc:
|
|
context.acms_error = exc
|
|
|
|
|
|
@when('I create a payload with plan_id "{plan_id}"')
|
|
def step_create_payload_plan_id(context: Context, plan_id: str) -> None:
|
|
context.acms_error = None
|
|
context.test_payload = None
|
|
try:
|
|
context.test_payload = ContextPayload(plan_id=plan_id)
|
|
except ValidationError as exc:
|
|
context.acms_error = exc
|
|
|
|
|
|
@then('the created payload plan_id should be "{plan_id}"')
|
|
def step_created_payload_plan_id(context: Context, plan_id: str) -> None:
|
|
assert context.test_payload is not None, "Payload was not created"
|
|
assert context.test_payload.plan_id == plan_id, (
|
|
f"Expected plan_id={plan_id!r}, got {context.test_payload.plan_id!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Strategy introspection steps (capabilities, can_handle, explain)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_BUILTIN_STRATEGIES: dict[str, Any] = {
|
|
"relevance": RelevanceStrategy(),
|
|
"recency": RecencyStrategy(),
|
|
"tiered": TieredStrategy(),
|
|
}
|
|
|
|
|
|
@when('I inspect the capabilities of "{strategy_name}"')
|
|
def step_inspect_capabilities(context: Context, strategy_name: str) -> None:
|
|
context.inspected_capabilities = _BUILTIN_STRATEGIES[strategy_name].capabilities
|
|
|
|
|
|
@then("the strategy should declare {attr} as {expected}")
|
|
def step_capability_attr(context: Context, attr: str, expected: str) -> None:
|
|
caps = context.inspected_capabilities
|
|
actual = getattr(caps, attr)
|
|
expected_val = expected == "true"
|
|
assert actual == expected_val, f"Expected {attr}={expected_val}, got {actual}"
|
|
|
|
|
|
@when("I query can_handle on all three built-in strategies")
|
|
def step_query_can_handle(context: Context) -> None:
|
|
request: dict[str, Any] = {"strategy": "relevance"}
|
|
context.confidence_map = {
|
|
name: s.can_handle(request) for name, s in _BUILTIN_STRATEGIES.items()
|
|
}
|
|
|
|
|
|
@then('"{a}" should have the highest confidence')
|
|
def step_highest_confidence(context: Context, a: str) -> None:
|
|
conf_a = context.confidence_map[a]
|
|
for name, conf in context.confidence_map.items():
|
|
if name != a:
|
|
assert conf_a > conf, f"Expected {a} ({conf_a}) > {name} ({conf})"
|
|
|
|
|
|
@then('"{a}" should have higher confidence than "{b}"')
|
|
def step_higher_confidence(context: Context, a: str, b: str) -> None:
|
|
assert context.confidence_map[a] > context.confidence_map[b], (
|
|
f"Expected {a} ({context.confidence_map[a]}) > "
|
|
f"{b} ({context.confidence_map[b]})"
|
|
)
|
|
|
|
|
|
@when("I call explain on each built-in strategy")
|
|
def step_call_explain(context: Context) -> None:
|
|
context.explanations = {
|
|
name: s.explain() for name, s in _BUILTIN_STRATEGIES.items()
|
|
}
|
|
|
|
|
|
@then('the "{strategy}" explanation should mention "{keyword}"')
|
|
def step_explain_keyword(context: Context, strategy: str, keyword: str) -> None:
|
|
explanation = context.explanations[strategy]
|
|
assert keyword.lower() in explanation.lower(), (
|
|
f"Expected '{keyword}' in {strategy} explanation: {explanation!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fragment metadata overflow steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I create a fragment with {count:d} metadata entries")
|
|
def step_create_fragment_metadata(context: Context, count: int) -> None:
|
|
context.acms_error = None
|
|
context.test_fragment = None
|
|
metadata = {f"key_{i}": f"val_{i}" for i in range(count)}
|
|
try:
|
|
context.test_fragment = ContextFragment(
|
|
uko_node="project://test/meta.py",
|
|
content="test",
|
|
relevance_score=0.5,
|
|
token_count=10,
|
|
tier="hot",
|
|
provenance=FragmentProvenance(resource_uri="project://test/meta.py"),
|
|
metadata=metadata,
|
|
)
|
|
except ValidationError as exc:
|
|
context.acms_error = exc
|
|
|
|
|
|
@then("the fragment should have {count:d} metadata entries")
|
|
def step_fragment_metadata_count(context: Context, count: int) -> None:
|
|
assert context.test_fragment is not None, "Fragment was not created"
|
|
assert len(context.test_fragment.metadata) == count
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Early plan_id rejection in assemble()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I assemble with plan_id "{plan_id}"')
|
|
def step_assemble_with_plan_id(context: Context, plan_id: str) -> None:
|
|
if not hasattr(context, "pipeline"):
|
|
context.pipeline = ACMSPipeline()
|
|
context.assemble_error = None
|
|
try:
|
|
context.payload = context.pipeline.assemble(
|
|
plan_id=plan_id,
|
|
fragments=list(context.fragments),
|
|
budget=context.budget,
|
|
)
|
|
except (ValueError, ValidationError) as exc:
|
|
context.assemble_error = exc
|
|
|
|
|
|
@then('an ACMS ValueError should be raised mentioning "{keyword}"')
|
|
def step_acms_value_error_mentioning(context: Context, keyword: str) -> None:
|
|
assert context.assemble_error is not None, "No error was raised"
|
|
assert isinstance(context.assemble_error, ValueError), (
|
|
f"Expected ValueError, got {type(context.assemble_error).__name__}"
|
|
)
|
|
assert keyword.lower() in str(context.assemble_error).lower(), (
|
|
f"Expected '{keyword}' in error: {context.assemble_error}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TEST-2 — Provenance map immutability step
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I create a payload with a mutable provenance map and mutate the source")
|
|
def step_create_payload_mutable_provenance(context: Context) -> None:
|
|
inner = {"strategy": "relevance", "extra": "value"}
|
|
source_map = {"frag-1": inner}
|
|
context.test_payload = ContextPayload(
|
|
plan_id="01JQTESTPN00000000000000AB",
|
|
provenance_map=source_map,
|
|
)
|
|
# Mutate the source dict after construction.
|
|
inner["strategy"] = "TAMPERED"
|
|
source_map["frag-2"] = {"injected": "true"}
|
|
|
|
|
|
@then("the payload provenance map should be unchanged")
|
|
def step_provenance_map_unchanged(context: Context) -> None:
|
|
pmap = context.test_payload.provenance_map
|
|
assert "frag-1" in pmap, "frag-1 missing from provenance_map"
|
|
assert "frag-2" not in pmap, "frag-2 was injected into provenance_map"
|
|
assert pmap["frag-1"]["strategy"] == "relevance", (
|
|
f"Expected 'relevance', got {pmap['frag-1']['strategy']!r} — "
|
|
"external mutation leaked into frozen model"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TEST-3 — Allocator rounding edge case steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I allocate budget {budget:d} across {count:d} candidates with equal confidence")
|
|
def step_allocate_equal(context: Context, budget: int, count: int) -> None:
|
|
from cleveragents.application.services.acms_service import (
|
|
RecencyStrategy,
|
|
RelevanceStrategy,
|
|
TieredStrategy,
|
|
)
|
|
|
|
strategies = [RelevanceStrategy(), RecencyStrategy(), TieredStrategy()]
|
|
allocator = DefaultBudgetAllocator()
|
|
candidates = [(s, 0.5) for s in strategies[:count]]
|
|
context.allocations = allocator.allocate(candidates, budget)
|
|
context.alloc_budget = budget
|
|
|
|
|
|
@when("I allocate budget {budget:d} across {count:d} candidates with zero confidence")
|
|
def step_allocate_zero_conf(context: Context, budget: int, count: int) -> None:
|
|
from cleveragents.application.services.acms_service import (
|
|
RecencyStrategy,
|
|
RelevanceStrategy,
|
|
TieredStrategy,
|
|
)
|
|
|
|
strategies = [RelevanceStrategy(), RecencyStrategy(), TieredStrategy()]
|
|
allocator = DefaultBudgetAllocator()
|
|
candidates = [(s, 0.0) for s in strategies[:count]]
|
|
context.allocations = allocator.allocate(candidates, budget)
|
|
context.alloc_budget = budget
|
|
|
|
|
|
@when(
|
|
"I allocate budget {budget:d} across {count:d} candidates with confidences {conf_str}"
|
|
)
|
|
def step_allocate_unequal(
|
|
context: Context, budget: int, count: int, conf_str: str
|
|
) -> None:
|
|
from cleveragents.application.services.acms_service import (
|
|
RecencyStrategy,
|
|
RelevanceStrategy,
|
|
TieredStrategy,
|
|
)
|
|
|
|
confidences = [float(c) for c in conf_str.split()]
|
|
strategies = [RelevanceStrategy(), RecencyStrategy(), TieredStrategy()]
|
|
allocator = DefaultBudgetAllocator()
|
|
candidates = [(s, c) for s, c in zip(strategies[:count], confidences, strict=True)]
|
|
context.allocations = allocator.allocate(candidates, budget)
|
|
context.alloc_budget = budget
|
|
|
|
|
|
@then("the total allocated should equal exactly {budget:d}")
|
|
def step_total_exact(context: Context, budget: int) -> None:
|
|
total = sum(a[2] for a in context.allocations)
|
|
assert total == budget, (
|
|
f"Expected total allocation to be exactly {budget}, got {total}"
|
|
)
|
|
|
|
|
|
@then("each allocation should be {low:d} or {high:d}")
|
|
def step_each_allocation_bounded(context: Context, low: int, high: int) -> None:
|
|
for _strategy, _confidence, tokens in context.allocations:
|
|
assert tokens in {low, high}, (
|
|
f"Expected each allocation to be {low} or {high}, got {tokens}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TEST-4 — Strategy selector fallback step
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _EmptySelector:
|
|
"""Strategy selector that always returns an empty candidate list."""
|
|
|
|
def __init__(self) -> None:
|
|
self.called = False
|
|
|
|
def select(
|
|
self,
|
|
strategies: Sequence[Any],
|
|
request: dict[str, Any],
|
|
) -> list[tuple[Any, float]]:
|
|
self.called = True
|
|
return []
|
|
|
|
|
|
@given("a pipeline with a custom strategy selector that returns no candidates")
|
|
def step_pipeline_empty_selector(context: Context) -> None:
|
|
selector = _EmptySelector()
|
|
context.empty_selector = selector
|
|
context.pipeline = ACMSPipeline(strategy_selector=selector)
|
|
|
|
|
|
@then("the empty strategy selector should have been called")
|
|
def step_empty_selector_called(context: Context) -> None:
|
|
assert context.empty_selector.called, (
|
|
"Expected _EmptySelector.select() to be called, but it was not"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Issue #3563 — SkeletonCompressor invocation in assemble() steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _SpySkeletonCompressor:
|
|
"""Spy skeleton compressor that records calls and returns fragments unchanged."""
|
|
|
|
def __init__(self) -> None:
|
|
self.called = False
|
|
self.received_fragments: tuple[ContextFragment, ...] = ()
|
|
self.received_budget: int = 0
|
|
self.output: tuple[ContextFragment, ...] = ()
|
|
|
|
def compress(
|
|
self,
|
|
fragments: tuple[ContextFragment, ...],
|
|
skeleton_budget: int,
|
|
) -> tuple[ContextFragment, ...]:
|
|
self.called = True
|
|
self.received_fragments = fragments
|
|
self.received_budget = skeleton_budget
|
|
# Return the fragments unchanged (pass-through spy)
|
|
self.output = fragments
|
|
return self.output
|
|
|
|
|
|
@given("a spy skeleton compressor is registered")
|
|
def step_spy_compressor_registered(context: Context) -> None:
|
|
spy = _SpySkeletonCompressor()
|
|
context.spy_compressor = spy
|
|
context.pipeline = ACMSPipeline(skeleton_compressor=spy)
|
|
|
|
|
|
@given("parent fragments for skeleton compression:")
|
|
def step_parent_fragments(context: Context) -> None:
|
|
frags = []
|
|
for row in context.table:
|
|
frags.append(
|
|
_make_fragment(
|
|
uko_node=row["uko_node"],
|
|
content=row["content"],
|
|
relevance_score=float(row["score"]),
|
|
token_count=int(row["tokens"]),
|
|
)
|
|
)
|
|
context.parent_fragments = tuple(frags)
|
|
|
|
|
|
@when("I assemble with parent fragments and skeleton_ratio {ratio:g}")
|
|
def step_assemble_with_parent_fragments(context: Context, ratio: float) -> None:
|
|
if not hasattr(context, "pipeline"):
|
|
context.pipeline = ACMSPipeline()
|
|
context.assemble_error = None
|
|
try:
|
|
context.payload = context.pipeline.assemble(
|
|
plan_id="01JQTESTPN00000000000000AB",
|
|
fragments=list(context.fragments),
|
|
budget=context.budget,
|
|
parent_fragments=context.parent_fragments,
|
|
skeleton_ratio=ratio,
|
|
)
|
|
except (ValueError, ValidationError) as exc:
|
|
context.assemble_error = exc
|
|
|
|
|
|
@then("the spy skeleton compressor should have been called")
|
|
def step_spy_compressor_called(context: Context) -> None:
|
|
assert context.spy_compressor.called, (
|
|
"Expected _SpySkeletonCompressor.compress() to be called, but it was not. "
|
|
"This confirms the bug: SkeletonCompressor is stored but never invoked in assemble()."
|
|
)
|
|
|
|
|
|
@then("the spy compressor should have received the parent fragments")
|
|
def step_spy_compressor_received_fragments(context: Context) -> None:
|
|
assert context.spy_compressor.received_fragments == context.parent_fragments, (
|
|
f"Expected compressor to receive parent_fragments, "
|
|
f"got {context.spy_compressor.received_fragments!r}"
|
|
)
|
|
|
|
|
|
@then("the spy compressor skeleton_budget should be {expected_budget:d}")
|
|
def step_spy_compressor_budget(context: Context, expected_budget: int) -> None:
|
|
actual = context.spy_compressor.received_budget
|
|
assert actual == expected_budget, (
|
|
f"Expected skeleton_budget={expected_budget}, got {actual}. "
|
|
f"skeleton_budget should be int(total_budget * skeleton_ratio)."
|
|
)
|
|
|
|
|
|
@then("the payload skeleton_fragments should be non-empty")
|
|
def step_payload_skeleton_non_empty(context: Context) -> None:
|
|
assert context.payload.skeleton_fragments, (
|
|
"Expected payload.skeleton_fragments to be non-empty, but it was empty. "
|
|
"The assembled context must include compressed skeleton fragments."
|
|
)
|
|
|
|
|
|
@then("the payload skeleton_fragments should equal the spy compressor output")
|
|
def step_payload_skeleton_equals_spy_output(context: Context) -> None:
|
|
assert context.payload.skeleton_fragments == context.spy_compressor.output, (
|
|
f"Expected payload.skeleton_fragments == spy output, "
|
|
f"got {context.payload.skeleton_fragments!r}"
|
|
)
|
|
|
|
|
|
@then("the payload skeleton_fragments should be empty")
|
|
def step_payload_skeleton_empty(context: Context) -> None:
|
|
assert context.payload.skeleton_fragments == (), (
|
|
f"Expected payload.skeleton_fragments to be empty (), "
|
|
f"got {context.payload.skeleton_fragments!r}"
|
|
)
|