Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 859fcc9ec9 | |||
| 8d0f892eed | |||
| ad5313aa1a | |||
| ba7a44c048 | |||
| 296fb751ac | |||
| 0a836c93f0 | |||
| 0b824f841f | |||
| f5cbdeb328 | |||
| cc7efd0a46 | |||
| c433d7b658 | |||
| edf4c0b411 | |||
| 05d4f5ffdd | |||
| 0fc697c61e | |||
| 93997632ed | |||
| c55bcbf7ab | |||
| 74d9eb5ac6 | |||
| 781ea3db08 | |||
| 7e6165c615 | |||
| 40f5069bf6 | |||
| 8808a7eb15 | |||
| dd89144486 | |||
| 518e4169a3 | |||
| a256b65c92 | |||
| 9d39b69d5f | |||
| 355923248b |
@@ -800,6 +800,23 @@ uko-oo:Class` triple emission in `PythonAnalyzer._extract_class()` so that
|
||||
limit. 19 BDD scenarios cover all v3 paths including tool actors,
|
||||
update mode, LSP dict bindings, and field propagation.
|
||||
|
||||
- **Plan Status JSON Envelope Compliance** (#9450): `plan_status` in
|
||||
`src/cleveragents/cli/commands/plan.py` now returns a spec-compliant JSON
|
||||
envelope for `agents plan status --format json` instead of a raw plan
|
||||
dictionary. The envelope includes all required fields: `command: "plan status"`,
|
||||
`status: "ok"`, `exit_code: 0`, `data` (with `action`, `project`, `automation`,
|
||||
`attempt`, `progress`, `timing`, `execution`, `cost`), `timing` (with `started`
|
||||
and `duration_ms`), and `messages: ["Status refreshed"]`. The `elapsed` and
|
||||
`eta` fields in `data.timing` are now computed from plan timestamps and
|
||||
estimation results respectively. The `files_modified` and `child_plans` fields
|
||||
in `data.execution` are derived from plan changeset and subplan references.
|
||||
Imports `Plan as LifecyclePlan` and `ProcessingState` moved to module level;
|
||||
`_get_progress_status` promoted to module-level private function. BDD test
|
||||
scenarios added in `features/plan_status_json_envelope.feature`.
|
||||
|
||||
- **Plan Status JSON Envelope spec compliance follow-up** (#9450): Fixed outer envelope `timing.started` to emit the plan-created-at ISO timestamp via `_build_envelope()` accepting an optional `started_iso` parameter; fixed `child_plans` output format to include the required `" complete"` suffix (e.g. `"0/2 complete"`); fixed progress step status logic so that plans in non-traditional phases (ACTION/SUBMIT) correctly report `"queued"` instead of `"done"` for Strategize and Execute steps; added Behave scenarios verifying timing.started, child_plans suffix, and ACTION phase progress.
|
||||
|
||||
|
||||
- **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in
|
||||
`features/environment.py` now emits its non-assertion exception guard warning to
|
||||
both the structured logger and `stderr` via a new `_warning_with_stderr` helper.
|
||||
|
||||
@@ -41,6 +41,7 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the plan tree JSON/YAML command envelope fix (#9163): wrapped `agents plan tree --format json/yaml` output in the spec-required command envelope structure, added summary statistics, decision_ids mapping, child_plans list, and accurate timing measurement.
|
||||
* HAL 9000 contributed Structural Component Output Validation (PR #11161 / issue #8164): implemented `validate_plan_tree`, `validate_decision_dict`, `validate_structured_output`, and `validate_structured_component_output` validators that replace exact-character matching with structural schema checking for plan tree nodes, decision CLI dictionaries, and structured session output envelopes.
|
||||
* HAMZA KHYARI has contributed the ACMS execute-phase context assembler project-level hot_max_tokens fix (PR #11036 / issue #11035): added `_resolve_effective_budget()` method that reads each linked project's `settings.hot_max_tokens` and uses the maximum override value as the pipeline budget instead of the hardcoded global 16K default.
|
||||
* HAL 9000 has contributed the agents plan rollback command (PR #8674 / issue #8557): implemented checkpoint-based plan state restoration with the `agents plan rollback <plan-id> [<checkpoint-id>]` CLI command as part of Epic #8493, enabling plans to be restored to previous checkpoints, discarding post-checkpoint decisions, and resuming execution from the rolled-back state. Supported by `--yes/-y`, `--to-checkpoint`, and `--format/-f` flags. Includes comprehensive BDD test coverage (>= 97%) for rollback, decision discarding, and plan resume functionality.
|
||||
* HAL 9000 has contributed the automated CLI docstring example validation (#9106): added `DocstringExampleValidator` to enforce positional-before-option ordering in CLI `Examples:` sections, with Behave test coverage and CONTRIBUTING.md documentation.
|
||||
* HAL 9000 has contributed the AutoDebugAgent prompt injection mitigation fix (#9110): sanitized user-provided `error_message` and `code_context` fields in all three agent methods using `PromptSanitizer` boundary markers, added graceful `PromptInjectionDetected` exception handling, and added BDD and Robot Framework integration tests for the security fix.
|
||||
* HAL 9000 has contributed the PureGraph BDD coverage suite (PR #9601 / issue #9531): wired the previously orphaned `features/steps/pure_graph_coverage_steps.py` definitions through the existing `features/consolidated_langgraph.feature` (topological ordering, function execution, missing function fallback, and non-functional node handling); created Robot Framework integration tests in `robot/langgraph/pure_graph.robot` backed by the `robot/langgraph/pure_graph_lib.py` Python library; and implemented ASV benchmarks in `benchmarks/pure_graph_bench.py` measuring execution throughput across varying node counts.
|
||||
@@ -67,6 +68,7 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
|
||||
* HAL 9000 has contributed the concurrent ValidationPipeline stdout/stderr restoration fix (PR #7811 / issue #7623): introduced a reference-counted shared stream wrapper manager so concurrent ValidationPipeline.run() calls correctly restore the true sys.stdout/sys.stderr after all pipelines finish, preventing permanent stream wrapping under concurrent execution.
|
||||
* HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch.
|
||||
* HAL 9000 has contributed the Plan Status JSON Envelope spec compliance follow-up for PR #9827 / issue #9450: corrected outer envelope `timing.started` ISO timestamp forwarding through `_build_envelope()`, fixed `child_plans` string format to include the required `" complete"` suffix per spec, and fixed progress step status logic for non-traditional plan phases (ACTION/SUBMIT).
|
||||
* HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files.
|
||||
|
||||
* HAL 9000 has contributed the TuiMaterializer A2A integration layer (PR #10589 / issue #5326): implemented the ``TuiMaterializer`` class bridging the Output Rendering Framework to Textual UI widgets, implementing the ``MaterializationStrategy`` protocol, mapping all element types (Panel, Table, Status, Progress, Tree, Code, Diff, Separator, ActionHint, Text) to plain-text renderings, adding A2A routing for PermissionRequest and ThoughtBlock events, with comprehensive Behave BDD test coverage including thread-safety verification.
|
||||
|
||||
@@ -34,14 +34,20 @@ Feature: CLI output formats parity
|
||||
Given there is a single plan for format testing
|
||||
When I run plan status with plan id and --format json
|
||||
Then the output should be valid JSON
|
||||
And the JSON should contain key "processing_state"
|
||||
And the JSON should contain key "state"
|
||||
|
||||
Scenario: Plan status JSON reports child progress from subplan statuses
|
||||
Given there is a parent plan with two tracked subplans for format testing
|
||||
When I run plan status with plan id and --format json
|
||||
Then the output should be valid JSON
|
||||
And the JSON plan status child progress should be "1/2 complete"
|
||||
|
||||
# Plan status --format plain (single plan)
|
||||
Scenario: Plan status single plan outputs plain text
|
||||
Given there is a single plan for format testing
|
||||
When I run plan status with plan id and --format plain
|
||||
Then the format output should contain "plan_id:"
|
||||
And the format output should contain "processing_state:"
|
||||
And the format output should contain "state:"
|
||||
|
||||
# Action list --format table
|
||||
Scenario: Action list outputs table format
|
||||
|
||||
@@ -59,6 +59,7 @@ Feature: Plan rollback CLI command
|
||||
And the plan is not applied
|
||||
When I run "agents plan rollback 01ARZ3NDEKTSV4RRFFQ69G5FAV 01BRZ4NFEKTSV4RRFFQ69G5FAV" with confirmation
|
||||
Then the plan rollback should succeed
|
||||
And restore mode should route rollback through the lifecycle service
|
||||
And the output should show rollback summary
|
||||
|
||||
Scenario: Restore plan to checkpoint with --yes flag
|
||||
@@ -87,6 +88,7 @@ Feature: Plan rollback CLI command
|
||||
And the plan has a checkpoint "01BRZ4NFEKTSV4RRFFQ69G5FAV"
|
||||
When I run "agents plan rollback 01ARZ3NDEKTSV4RRFFQ69G5FAV 01BRZ4NFEKTSV4RRFFQ69G5FAV --yes"
|
||||
Then the command should fail with "plan is already applied"
|
||||
And restore mode should route rollback through the lifecycle service
|
||||
|
||||
Scenario: Restore plan without checkpoint ID shows error
|
||||
Given a plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" exists
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
Feature: Plan status JSON envelope compliance
|
||||
As a programmatic consumer of the CleverAgents CLI
|
||||
I want agents plan status --format json to return a spec-compliant JSON envelope
|
||||
So that I can reliably parse plan status data from the structured output
|
||||
|
||||
Background:
|
||||
Given a plan status JSON envelope test runner
|
||||
And a plan status JSON envelope mocked lifecycle service
|
||||
|
||||
# ── Envelope field presence ──────────────────────────────────────────────
|
||||
|
||||
Scenario: Plan status JSON output includes all required top-level envelope fields
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON envelope should contain field "command"
|
||||
And the plan status JSON envelope should contain field "status"
|
||||
And the plan status JSON envelope should contain field "exit_code"
|
||||
And the plan status JSON envelope should contain field "data"
|
||||
And the plan status JSON envelope should contain field "timing"
|
||||
And the plan status JSON envelope should contain field "messages"
|
||||
|
||||
Scenario: Plan status JSON envelope command field is "plan status"
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON envelope command should be "plan status"
|
||||
|
||||
Scenario: Plan status JSON envelope status field is "ok"
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON envelope status should be "ok"
|
||||
|
||||
Scenario: Plan status JSON envelope exit_code is 0
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON envelope exit_code should be 0
|
||||
|
||||
Scenario: Plan status JSON envelope messages contains "Status refreshed"
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON envelope messages should contain "Status refreshed"
|
||||
|
||||
# ── Data field presence ──────────────────────────────────────────────────
|
||||
|
||||
Scenario: Plan status JSON data field contains action name
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "action"
|
||||
|
||||
Scenario: Plan status JSON data field contains plan_id
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "plan_id"
|
||||
|
||||
Scenario: Plan status JSON data field contains phase
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "phase"
|
||||
|
||||
Scenario: Plan status JSON data field contains state
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "state"
|
||||
|
||||
Scenario: Plan status JSON data field contains attempt
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "attempt"
|
||||
|
||||
Scenario: Plan status JSON data field contains progress array
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "progress"
|
||||
And the plan status JSON data progress should be a list
|
||||
|
||||
Scenario: Plan status JSON data progress contains Strategize step
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data progress should contain step "Strategize"
|
||||
|
||||
Scenario: Plan status JSON data progress contains Execute step
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data progress should contain step "Execute"
|
||||
|
||||
Scenario: Plan status JSON data progress contains Apply step
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data progress should contain step "Apply"
|
||||
|
||||
Scenario: Plan status JSON data field contains execution details
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "execution"
|
||||
And the plan status JSON data execution should contain "sandbox"
|
||||
And the plan status JSON data execution should contain "tool_calls"
|
||||
And the plan status JSON data execution should contain "files_modified"
|
||||
And the plan status JSON data execution should contain "child_plans"
|
||||
And the plan status JSON data execution should contain "checkpoints"
|
||||
|
||||
Scenario: Plan status JSON data field contains cost details
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "cost"
|
||||
And the plan status JSON data cost should contain "tokens_used"
|
||||
And the plan status JSON data cost should contain "cost_so_far"
|
||||
And the plan status JSON data cost should contain "estimated"
|
||||
|
||||
Scenario: Plan status JSON cost details prefer estimation values over recorded cost
|
||||
Given a plan status JSON envelope plan exists with recorded cost and estimation
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data cost should contain "tokens_used"
|
||||
And the plan status JSON data cost should contain "cost_so_far"
|
||||
And the plan status JSON data cost should contain "estimated"
|
||||
And the plan status JSON data cost should contain "total_tokens"
|
||||
And the plan status JSON data cost should contain "total_cost_usd"
|
||||
|
||||
Scenario: Plan status JSON handles legacy fallback objects
|
||||
Given a plan status JSON envelope legacy plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "plan"
|
||||
And the plan status JSON data plan should be "legacy-plan"
|
||||
|
||||
# ── Timing envelope field ────────────────────────────────────────────────
|
||||
|
||||
Scenario: Plan status JSON timing envelope contains duration_ms
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON timing should contain "duration_ms"
|
||||
|
||||
# ── Project and automation fields ────────────────────────────────────────
|
||||
|
||||
Scenario: Plan status JSON data contains project when plan has project links
|
||||
Given a plan status JSON envelope plan exists with project "local/api-service"
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "project"
|
||||
And the plan status JSON data project should be "local/api-service"
|
||||
|
||||
Scenario: Plan status JSON data contains automation when plan has automation profile
|
||||
Given a plan status JSON envelope plan exists with automation profile "review"
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "automation"
|
||||
And the plan status JSON data automation should be "review"
|
||||
|
||||
# ── BLOCKER D: timing.started, child_plans format, ACTION phase progress ────
|
||||
|
||||
Scenario: Outer envelope timing field contains started ISO timestamp
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON timing should contain "started"
|
||||
And the plan status JSON timing started should be an ISO timestamp string
|
||||
|
||||
Scenario: child_plans field has spec-required complete suffix
|
||||
Given a plan status JSON envelope plan exists with no child plans
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data execution child_plans should end with " complete"
|
||||
|
||||
Scenario: Child plans zero-zero format uses spec suffix
|
||||
Given a plan status JSON envelope plan exists with no child plans
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data execution child_plans should be "0/0 complete"
|
||||
|
||||
Scenario: ACTION phase progress shows queued for Strategize and Execute steps
|
||||
Given a plan status JSON envelope plan exists in action phase
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data progress step "Strategize" should be "queued"
|
||||
And the plan status JSON data progress step "Execute" should be "queued"
|
||||
|
||||
Scenario: ACTION phase progress shows queued for Apply step
|
||||
Given a plan status JSON envelope plan exists in action phase
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data progress step "Apply" should be "queued"
|
||||
@@ -26,6 +26,7 @@ from cleveragents.domain.models.core.plan import (
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
SubplanStatus,
|
||||
)
|
||||
|
||||
_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
@@ -159,6 +160,25 @@ def step_single_plan_for_format(context: Context) -> None:
|
||||
context.mock_plan_service.get_plan.return_value = context.format_plan
|
||||
|
||||
|
||||
@given("there is a parent plan with two tracked subplans for format testing")
|
||||
def step_parent_plan_with_two_tracked_subplans(context: Context) -> None:
|
||||
context.format_plan = _make_plan()
|
||||
context.format_plan.subplan_statuses = [
|
||||
SubplanStatus(
|
||||
subplan_id="01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
||||
action_name="local/child-a",
|
||||
status=ProcessingState.COMPLETE,
|
||||
completed_at=datetime(2025, 1, 15, 10, 30, 0),
|
||||
),
|
||||
SubplanStatus(
|
||||
subplan_id="01ARZ3NDEKTSV4RRFFQ69G5FAX",
|
||||
action_name="local/child-b",
|
||||
status=ProcessingState.QUEUED,
|
||||
),
|
||||
]
|
||||
context.mock_plan_service.get_plan.return_value = context.format_plan
|
||||
|
||||
|
||||
# ------- When steps -------
|
||||
|
||||
|
||||
@@ -289,6 +309,20 @@ def step_json_contains_key(context: Context, key: str) -> None:
|
||||
assert key in data, f"Key '{key}' not in {list(data.keys())}"
|
||||
|
||||
|
||||
@then('the JSON plan status child progress should be "{expected}"')
|
||||
def step_json_plan_status_child_progress(context: Context, expected: str) -> None:
|
||||
parsed = json.loads(context.result.output.strip())
|
||||
data = _unwrap_envelope(parsed)
|
||||
assert isinstance(data, dict), f"Expected data dict, got {type(data)}"
|
||||
execution = data.get("execution")
|
||||
assert isinstance(execution, dict), (
|
||||
f"Expected execution dict, got {type(execution)}"
|
||||
)
|
||||
assert execution.get("child_plans") == expected, (
|
||||
f"Expected child_plans {expected!r}, got {execution.get('child_plans')!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the YAML should contain key "{key}"')
|
||||
def step_yaml_contains_key(context: Context, key: str) -> None:
|
||||
output = context.result.output.strip()
|
||||
|
||||
@@ -15,6 +15,7 @@ from cleveragents.cli.commands.plan import app as plan_app
|
||||
from cleveragents.core.exceptions import (
|
||||
BusinessRuleViolation,
|
||||
CleverAgentsError,
|
||||
PlanError,
|
||||
ResourceNotFoundError,
|
||||
)
|
||||
from cleveragents.domain.models.core.checkpoint import Checkpoint, CheckpointMetadata
|
||||
@@ -48,6 +49,10 @@ def _build_mock_container(context: Context) -> MagicMock:
|
||||
mock_container = MagicMock()
|
||||
mock_cp_svc = MagicMock()
|
||||
mock_lifecycle_svc = MagicMock()
|
||||
unwired_lifecycle_svc = MagicMock()
|
||||
unwired_lifecycle_svc.rollback_plan.side_effect = PlanError(
|
||||
"rollback_plan requires a CheckpointService to be configured"
|
||||
)
|
||||
|
||||
plan_id = getattr(context, "plan_id", None)
|
||||
checkpoint_id = getattr(context, "checkpoint_id", None)
|
||||
@@ -73,6 +78,9 @@ def _build_mock_container(context: Context) -> MagicMock:
|
||||
mock_lifecycle_svc.rollback_plan.side_effect = BusinessRuleViolation(
|
||||
"plan is already applied"
|
||||
)
|
||||
mock_cp_svc.selective_rollback.side_effect = BusinessRuleViolation(
|
||||
"plan is already applied"
|
||||
)
|
||||
else:
|
||||
mock_result = MagicMock()
|
||||
mock_result.from_checkpoint_id = checkpoint_id or ""
|
||||
@@ -82,6 +90,7 @@ def _build_mock_container(context: Context) -> MagicMock:
|
||||
]
|
||||
mock_result.changes_reverted = None
|
||||
mock_lifecycle_svc.rollback_plan.return_value = mock_result
|
||||
mock_cp_svc.selective_rollback.return_value = mock_result
|
||||
|
||||
if checkpoint_id:
|
||||
matching = [
|
||||
@@ -93,8 +102,14 @@ def _build_mock_container(context: Context) -> MagicMock:
|
||||
resource_type="checkpoint", resource_id=checkpoint_id
|
||||
)
|
||||
)
|
||||
mock_cp_svc.selective_rollback.side_effect = ResourceNotFoundError(
|
||||
resource_type="checkpoint", resource_id=checkpoint_id
|
||||
)
|
||||
elif plan_id == "INVALID_PLAN_ID":
|
||||
mock_cp_svc.list_checkpoints.side_effect = CleverAgentsError("invalid plan ID")
|
||||
mock_cp_svc.selective_rollback.side_effect = CleverAgentsError(
|
||||
"invalid plan ID"
|
||||
)
|
||||
mock_lifecycle_svc.rollback_plan.side_effect = CleverAgentsError(
|
||||
"invalid plan ID"
|
||||
)
|
||||
@@ -102,12 +117,21 @@ def _build_mock_container(context: Context) -> MagicMock:
|
||||
mock_cp_svc.list_checkpoints.side_effect = ResourceNotFoundError(
|
||||
resource_type="plan", resource_id=plan_id or "unknown"
|
||||
)
|
||||
mock_cp_svc.selective_rollback.side_effect = ResourceNotFoundError(
|
||||
resource_type="plan", resource_id=plan_id or "unknown"
|
||||
)
|
||||
mock_lifecycle_svc.rollback_plan.side_effect = ResourceNotFoundError(
|
||||
resource_type="plan", resource_id=plan_id or "unknown"
|
||||
)
|
||||
|
||||
def _make_lifecycle_service(*, checkpoint_service=None):
|
||||
if checkpoint_service is mock_cp_svc:
|
||||
return mock_lifecycle_svc
|
||||
return unwired_lifecycle_svc
|
||||
|
||||
context.mock_lifecycle_svc = mock_lifecycle_svc
|
||||
mock_container.checkpoint_service.return_value = mock_cp_svc
|
||||
mock_container.plan_lifecycle_service.return_value = mock_lifecycle_svc
|
||||
mock_container.plan_lifecycle_service.side_effect = _make_lifecycle_service
|
||||
return mock_container
|
||||
|
||||
|
||||
@@ -120,6 +144,7 @@ def _invoke_rollback(
|
||||
mock_container = _build_mock_container(context)
|
||||
with patch(_PATCH_CONTAINER, return_value=mock_container):
|
||||
result = _RUNNER.invoke(plan_app, args, input=input_text)
|
||||
context.mock_container = mock_container
|
||||
context.last_exit_code = result.exit_code
|
||||
context.last_stdout = result.output
|
||||
context.last_stderr = str(result.exception) if result.exception else ""
|
||||
@@ -400,6 +425,21 @@ def step_rollback_succeeds(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@then("restore mode should route rollback through the lifecycle service")
|
||||
def step_restore_uses_lifecycle_service(context: Context) -> None:
|
||||
"""Verify restore mode does not bypass lifecycle rollback invariants."""
|
||||
lifecycle_svc = context.mock_lifecycle_svc
|
||||
checkpoint_svc = context.mock_container.checkpoint_service.return_value
|
||||
|
||||
context.mock_container.plan_lifecycle_service.assert_called_once_with(
|
||||
checkpoint_service=checkpoint_svc
|
||||
)
|
||||
lifecycle_svc.rollback_plan.assert_called_once_with(
|
||||
context.plan_id, context.checkpoint_id
|
||||
)
|
||||
checkpoint_svc.selective_rollback.assert_not_called()
|
||||
|
||||
|
||||
@then("the output should show rollback summary")
|
||||
def step_output_shows_summary(context: Context) -> None:
|
||||
"""Verify the output shows a rollback summary."""
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"""Step definitions for plan status JSON envelope compliance feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands.plan import app as plan_app
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
from cleveragents.domain.models.core.cost_metadata import CostMetadata
|
||||
from cleveragents.domain.models.core.estimation import EstimationResult
|
||||
|
||||
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
|
||||
|
||||
|
||||
def _make_status_plan(
|
||||
*,
|
||||
action_name: str = "local/test-action",
|
||||
phase: PlanPhase = PlanPhase.EXECUTE,
|
||||
state: ProcessingState = ProcessingState.PROCESSING,
|
||||
project_links: list[ProjectLink] | None = None,
|
||||
automation_profile: AutomationProfileRef | None = None,
|
||||
) -> Plan:
|
||||
"""Create a Plan instance for status JSON envelope tests."""
|
||||
now = datetime.now()
|
||||
return Plan(
|
||||
identity=PlanIdentity(plan_id=_PLAN_ULID),
|
||||
namespaced_name=NamespacedName.parse("local/test-plan"),
|
||||
description="Test plan description",
|
||||
definition_of_done="All tests pass",
|
||||
action_name=action_name,
|
||||
phase=phase,
|
||||
processing_state=state,
|
||||
project_links=project_links or [],
|
||||
arguments={},
|
||||
arguments_order=[],
|
||||
automation_profile=automation_profile,
|
||||
invariants=[],
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
estimation_actor=None,
|
||||
invariant_actor=None,
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
created_by=None,
|
||||
timestamps=PlanTimestamps(created_at=now, updated_at=now),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a plan status JSON envelope test runner")
|
||||
def step_status_envelope_runner(context: Context) -> None:
|
||||
"""Set up the CLI runner."""
|
||||
context.runner = CliRunner()
|
||||
|
||||
|
||||
@given("a plan status JSON envelope mocked lifecycle service")
|
||||
def step_status_envelope_service(context: Context) -> None:
|
||||
"""Set up a mock PlanLifecycleService."""
|
||||
context.mock_service = MagicMock()
|
||||
context.service_patcher = patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=context.mock_service,
|
||||
)
|
||||
context.service_patcher.start()
|
||||
if not hasattr(context, "_cleanup_handlers"):
|
||||
context._cleanup_handlers = []
|
||||
context._cleanup_handlers.append(context.service_patcher.stop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a plan status JSON envelope plan exists")
|
||||
def step_status_envelope_plan(context: Context) -> None:
|
||||
"""Set up a plan for status JSON envelope tests."""
|
||||
context.mock_plan = _make_status_plan(
|
||||
project_links=[ProjectLink(project_name="local/api-service")],
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name="review",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
),
|
||||
)
|
||||
context.mock_service.get_plan.return_value = context.mock_plan
|
||||
|
||||
|
||||
@given('a plan status JSON envelope plan exists with project "{project_name}"')
|
||||
def step_status_envelope_plan_with_project(context: Context, project_name: str) -> None:
|
||||
"""Set up a plan with a specific project link."""
|
||||
context.mock_plan = _make_status_plan(
|
||||
project_links=[ProjectLink(project_name=project_name)],
|
||||
)
|
||||
context.mock_service.get_plan.return_value = context.mock_plan
|
||||
|
||||
|
||||
@given(
|
||||
'a plan status JSON envelope plan exists with automation profile "{profile_name}"'
|
||||
)
|
||||
def step_status_envelope_plan_with_profile(context: Context, profile_name: str) -> None:
|
||||
"""Set up a plan with a specific automation profile."""
|
||||
context.mock_plan = _make_status_plan(
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name=profile_name,
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
),
|
||||
)
|
||||
context.mock_service.get_plan.return_value = context.mock_plan
|
||||
|
||||
|
||||
@given("a plan status JSON envelope plan exists with recorded cost and estimation")
|
||||
def step_status_envelope_plan_with_cost_estimation(context: Context) -> None:
|
||||
"""Set up a plan with both recorded and estimated cost metadata."""
|
||||
context.mock_plan = _make_status_plan(
|
||||
project_links=[ProjectLink(project_name="local/api-service")],
|
||||
)
|
||||
context.mock_plan.cost_metadata = CostMetadata(
|
||||
total_tokens=123,
|
||||
input_tokens=100,
|
||||
output_tokens=23,
|
||||
total_cost=0.42,
|
||||
)
|
||||
context.mock_plan.estimation_result = EstimationResult(
|
||||
estimated_cost_usd=1.23,
|
||||
estimated_tokens=456,
|
||||
estimated_time_seconds=90,
|
||||
)
|
||||
context.mock_service.get_plan.return_value = context.mock_plan
|
||||
|
||||
|
||||
@given("a plan status JSON envelope legacy plan exists")
|
||||
def step_status_envelope_legacy_plan(context: Context) -> None:
|
||||
"""Set up a non-lifecycle object to exercise status fallback output."""
|
||||
context.mock_service.get_plan.return_value = "legacy-plan"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I run plan status with format json")
|
||||
def step_run_plan_status_json(context: Context) -> None:
|
||||
"""Run plan status with --format json and parse the JSON output."""
|
||||
result = context.runner.invoke(plan_app, ["status", _PLAN_ULID, "--format", "json"])
|
||||
assert result.exit_code == 0, (
|
||||
f"plan status --format json failed ({result.exit_code}): {result.output}"
|
||||
)
|
||||
context.result = result
|
||||
# Parse the JSON output
|
||||
try:
|
||||
context.envelope = json.loads(result.output)
|
||||
except json.JSONDecodeError as e:
|
||||
raise AssertionError(
|
||||
f"plan status --format json did not produce valid JSON: {e}\n"
|
||||
f"Output was: {result.output}"
|
||||
) from e
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — envelope fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the plan status JSON envelope should contain field "{field_name}"')
|
||||
def step_envelope_has_field(context: Context, field_name: str) -> None:
|
||||
"""Verify the envelope contains the specified top-level field."""
|
||||
assert field_name in context.envelope, (
|
||||
f"Expected envelope to contain field '{field_name}', "
|
||||
f"but got keys: {list(context.envelope.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON envelope command should be "{expected_command}"')
|
||||
def step_envelope_command(context: Context, expected_command: str) -> None:
|
||||
"""Verify the envelope command field value."""
|
||||
actual = context.envelope.get("command")
|
||||
assert actual == expected_command, (
|
||||
f"Expected envelope command to be '{expected_command}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON envelope status should be "{expected_status}"')
|
||||
def step_envelope_status(context: Context, expected_status: str) -> None:
|
||||
"""Verify the envelope status field value."""
|
||||
actual = context.envelope.get("status")
|
||||
assert actual == expected_status, (
|
||||
f"Expected envelope status to be '{expected_status}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the plan status JSON envelope exit_code should be {expected_code:d}")
|
||||
def step_envelope_exit_code(context: Context, expected_code: int) -> None:
|
||||
"""Verify the envelope exit_code field value."""
|
||||
actual = context.envelope.get("exit_code")
|
||||
assert actual == expected_code, (
|
||||
f"Expected envelope exit_code to be {expected_code}, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON envelope messages should contain "{expected_message}"')
|
||||
def step_envelope_messages(context: Context, expected_message: str) -> None:
|
||||
"""Verify the envelope messages list contains the expected message."""
|
||||
messages = context.envelope.get("messages", [])
|
||||
assert isinstance(messages, list), (
|
||||
f"Expected messages to be a list, got {type(messages)}"
|
||||
)
|
||||
# Messages may be strings or dicts with "text" key
|
||||
message_texts = [m["text"] if isinstance(m, dict) else m for m in messages]
|
||||
assert expected_message in message_texts, (
|
||||
f"Expected messages to contain '{expected_message}', got {messages}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — data fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the plan status JSON data should contain field "{field_name}"')
|
||||
def step_data_has_field(context: Context, field_name: str) -> None:
|
||||
"""Verify the data dict contains the specified field."""
|
||||
data = context.envelope.get("data", {})
|
||||
assert field_name in data, (
|
||||
f"Expected data to contain field '{field_name}', "
|
||||
f"but got keys: {list(data.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then("the plan status JSON data progress should be a list")
|
||||
def step_data_progress_is_list(context: Context) -> None:
|
||||
"""Verify the data.progress field is a list."""
|
||||
data = context.envelope.get("data", {})
|
||||
progress = data.get("progress")
|
||||
assert isinstance(progress, list), (
|
||||
f"Expected data.progress to be a list, got {type(progress)}: {progress}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data progress should contain step "{step_name}"')
|
||||
def step_data_progress_has_step(context: Context, step_name: str) -> None:
|
||||
"""Verify the data.progress list contains a step with the given name."""
|
||||
data = context.envelope.get("data", {})
|
||||
progress = data.get("progress", [])
|
||||
step_names = [s.get("step") for s in progress if isinstance(s, dict)]
|
||||
assert step_name in step_names, (
|
||||
f"Expected progress to contain step '{step_name}', "
|
||||
f"but found steps: {step_names}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data execution should contain "{field_name}"')
|
||||
def step_data_execution_has_field(context: Context, field_name: str) -> None:
|
||||
"""Verify the data.execution dict contains the specified field."""
|
||||
data = context.envelope.get("data", {})
|
||||
execution = data.get("execution", {})
|
||||
assert field_name in execution, (
|
||||
f"Expected data.execution to contain '{field_name}', "
|
||||
f"but got keys: {list(execution.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data cost should contain "{field_name}"')
|
||||
def step_data_cost_has_field(context: Context, field_name: str) -> None:
|
||||
"""Verify the data.cost dict contains the specified field."""
|
||||
data = context.envelope.get("data", {})
|
||||
cost = data.get("cost", {})
|
||||
assert field_name in cost, (
|
||||
f"Expected data.cost to contain '{field_name}', "
|
||||
f"but got keys: {list(cost.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data cost "{field_name}" should be {expected_value:g}')
|
||||
def step_data_cost_numeric_value(
|
||||
context: Context,
|
||||
field_name: str,
|
||||
expected_value: float,
|
||||
) -> None:
|
||||
"""Verify a numeric value inside data.cost."""
|
||||
data = context.envelope.get("data", {})
|
||||
cost = data.get("cost", {})
|
||||
actual = cost.get(field_name)
|
||||
assert actual == expected_value, (
|
||||
f"Expected data.cost.{field_name} to be {expected_value}, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON timing should contain "{field_name}"')
|
||||
def step_timing_has_field(context: Context, field_name: str) -> None:
|
||||
"""Verify the top-level timing dict contains the specified field."""
|
||||
timing = context.envelope.get("timing", {})
|
||||
assert field_name in timing, (
|
||||
f"Expected timing to contain '{field_name}', "
|
||||
f"but got keys: {list(timing.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data project should be "{expected_project}"')
|
||||
def step_data_project_value(context: Context, expected_project: str) -> None:
|
||||
"""Verify the data.project field value."""
|
||||
data = context.envelope.get("data", {})
|
||||
actual = data.get("project")
|
||||
assert actual == expected_project, (
|
||||
f"Expected data.project to be '{expected_project}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data automation should be "{expected_automation}"')
|
||||
def step_data_automation_value(context: Context, expected_automation: str) -> None:
|
||||
"""Verify the data.automation field value."""
|
||||
data = context.envelope.get("data", {})
|
||||
actual = data.get("automation")
|
||||
assert actual == expected_automation, (
|
||||
f"Expected data.automation to be '{expected_automation}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data plan should be "{expected_plan}"')
|
||||
def step_data_plan_value(context: Context, expected_plan: str) -> None:
|
||||
"""Verify the fallback data.plan field value."""
|
||||
data = context.envelope.get("data", {})
|
||||
actual = data.get("plan")
|
||||
assert actual == expected_plan, (
|
||||
f"Expected data.plan to be '{expected_plan}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
# ── BLOCKER D: additional Given and Then steps ──────────────────────────────
|
||||
|
||||
|
||||
@given("a plan status JSON envelope plan exists with no child plans")
|
||||
def step_status_envelope_no_child_plans(context: Context) -> None:
|
||||
"""Set up a plan with empty child_plan_ids."""
|
||||
context.mock_plan = _make_status_plan(
|
||||
project_links=[ProjectLink(project_name="local/api-service")],
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name="review",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
),
|
||||
)
|
||||
context.mock_plan.child_plan_ids = []
|
||||
context.mock_plan.completed_child_plan_ids = []
|
||||
context.mock_service.get_plan.return_value = context.mock_plan
|
||||
|
||||
|
||||
@given("a plan status JSON envelope plan exists in action phase")
|
||||
def step_status_envelope_action_phase(context: Context) -> None:
|
||||
"""Set up a plan in the ACTION (non-strategize/execute/apply) phase."""
|
||||
context.mock_plan = _make_status_plan(
|
||||
action_name="local/code-coverage",
|
||||
phase=PlanPhase.ACTION,
|
||||
state=ProcessingState.QUEUED,
|
||||
project_links=[ProjectLink(project_name="local/api-service")],
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name="submitters",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
),
|
||||
)
|
||||
context.mock_service.get_plan.return_value = context.mock_plan
|
||||
|
||||
|
||||
@then("the plan status JSON timing started should be an ISO timestamp string")
|
||||
def step_timing_started_is_iso(context: Context) -> None:
|
||||
"""Verify the outer timing.started exists and is a non-empty ISO string."""
|
||||
timing = context.envelope.get("timing", {})
|
||||
started = timing.get("started")
|
||||
assert started is not None, (
|
||||
f"Expected timing.started to be present in outer envelope timing, "
|
||||
f"got keys: {list(timing.keys())}"
|
||||
)
|
||||
assert isinstance(started, str) and len(started) > 0, (
|
||||
f"Expected timing.started to be a non-empty ISO timestamp string, "
|
||||
f"got: {started!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data execution child_plans should end with "{suffix}"')
|
||||
def step_child_plans_has_suffix(context: Context, suffix: str) -> None:
|
||||
"""Verify the data.execution.child_plans ends with the expected suffix."""
|
||||
data = context.envelope.get("data", {})
|
||||
execution = data.get("execution", {})
|
||||
child_plans = execution.get("child_plans")
|
||||
assert isinstance(child_plans, str), (
|
||||
f"Expected child_plans to be a string, got: {type(child_plans)}"
|
||||
)
|
||||
assert child_plans.endswith(suffix), (
|
||||
f"Expected child_plans '{child_plans}' to end with '{suffix}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data execution child_plans should be "{expected_value}"')
|
||||
def step_child_plans_exact_value(context: Context, expected_value: str) -> None:
|
||||
"""Verify the exact child_plans string value."""
|
||||
data = context.envelope.get("data", {})
|
||||
execution = data.get("execution", {})
|
||||
actual = execution.get("child_plans")
|
||||
assert actual == expected_value, (
|
||||
f"Expected child_plans to be '{expected_value}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
'the plan status JSON data progress step "{step_name}" should be "{expected_status}"'
|
||||
)
|
||||
def step_data_progress_step_status(
|
||||
context: Context, step_name: str, expected_status: str
|
||||
) -> None:
|
||||
"""Verify the exact status of a given progress step."""
|
||||
data = context.envelope.get("data", {})
|
||||
progress = data.get("progress", [])
|
||||
for entry in progress:
|
||||
if isinstance(entry, dict) and entry.get("step") == step_name:
|
||||
actual = entry.get("status")
|
||||
assert actual == expected_status, (
|
||||
f"Expected step '{step_name}' status to be '{expected_status}', "
|
||||
f"got '{actual}'"
|
||||
)
|
||||
return
|
||||
raise AssertionError(f"Step '{step_name}' not found in progress: {progress}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -267,14 +267,24 @@ def _build_envelope(
|
||||
duration_ms: int,
|
||||
messages: Sequence[str | dict[str, str]],
|
||||
started_at: datetime | None = None,
|
||||
*,
|
||||
started_iso: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Build the spec-required JSON/YAML output envelope."""
|
||||
"""Build the spec-required JSON/YAML output envelope.
|
||||
|
||||
``started_iso`` takes precedence over ``started_at`` when non-empty, so
|
||||
callers that already have a pre-formatted ISO timestamp can pass it
|
||||
through verbatim (e.g., the plan status command forwards
|
||||
``plan.timestamps.created_at.isoformat()``).
|
||||
"""
|
||||
if status not in _VALID_STATUSES:
|
||||
raise ValueError(f"status must be one of {_VALID_STATUSES!r}, got {status!r}")
|
||||
if exit_code < 0:
|
||||
raise ValueError(f"exit_code must be non-negative, got {exit_code!r}")
|
||||
timing: dict[str, Any] = {"duration_ms": duration_ms}
|
||||
if started_at is not None:
|
||||
if started_iso:
|
||||
timing["started"] = started_iso
|
||||
elif started_at is not None:
|
||||
timing["started"] = started_at.isoformat()
|
||||
return {
|
||||
"command": command,
|
||||
@@ -295,6 +305,7 @@ def format_output(
|
||||
exit_code: int = 0,
|
||||
messages: Sequence[str | Mapping[str, Any]] | None = None,
|
||||
started_at: datetime | None = None,
|
||||
started_iso: str = "",
|
||||
) -> str:
|
||||
"""Format *data* according to *format_type*.
|
||||
|
||||
@@ -331,6 +342,13 @@ def format_output(
|
||||
Envelope messages array. Entries may be plain strings or dicts
|
||||
with ``level``/``text`` keys. If *None*, a default single-entry
|
||||
list is generated from *command* and *status*.
|
||||
started_at:
|
||||
Optional :class:`datetime` forwarded to the outer ``timing.started``
|
||||
field of the envelope.
|
||||
started_iso:
|
||||
Optional pre-formatted ISO 8601 start timestamp forwarded to the
|
||||
outer ``timing.started`` field of the envelope. Takes precedence
|
||||
over *started_at* when non-empty.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -359,6 +377,7 @@ def format_output(
|
||||
duration_ms,
|
||||
envelope_messages,
|
||||
started_at=started_at,
|
||||
started_iso=started_iso,
|
||||
)
|
||||
rendered = _format_json(envelope)
|
||||
elif fmt == OutputFormat.YAML.value:
|
||||
@@ -371,6 +390,7 @@ def format_output(
|
||||
duration_ms,
|
||||
envelope_messages,
|
||||
started_at=started_at,
|
||||
started_iso=started_iso,
|
||||
)
|
||||
rendered = _format_yaml(envelope)
|
||||
else:
|
||||
|
||||
@@ -815,6 +815,17 @@ class Plan(BaseModel):
|
||||
description="Status tracking for spawned subplans",
|
||||
)
|
||||
|
||||
# Child plan IDs — flattened lists derived from subplan statuses for
|
||||
# convenience access in status rendering and cross-plan-correction logic.
|
||||
child_plan_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Plan IDs of all spawned child plans",
|
||||
)
|
||||
completed_child_plan_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Plan IDs of child plans that have completed execution",
|
||||
)
|
||||
|
||||
# Resume metadata (step-level progress for plan resume)
|
||||
last_completed_step: int = Field(
|
||||
default=-1,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
|
||||
class _FakeConfigService:
|
||||
def resolve(self, _key: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(value="llm")
|
||||
|
||||
|
||||
class _FakeContainer:
|
||||
def __init__(self) -> None:
|
||||
self.registry = object()
|
||||
self.acms = object()
|
||||
self.tier = object()
|
||||
self.project_repo = object()
|
||||
self.resource_registry = object()
|
||||
self.subplans = object()
|
||||
self.checkpoints = object()
|
||||
self.settings_obj = SimpleNamespace(context_max_tokens_hot=1234)
|
||||
|
||||
def provider_registry(self) -> object:
|
||||
return self.registry
|
||||
|
||||
def config_service(self) -> _FakeConfigService:
|
||||
return _FakeConfigService()
|
||||
|
||||
def acms_pipeline(self) -> object:
|
||||
return self.acms
|
||||
|
||||
def context_tier_service(self) -> object:
|
||||
return self.tier
|
||||
|
||||
def namespaced_project_repo(self) -> object:
|
||||
return self.project_repo
|
||||
|
||||
def resource_registry_service(self) -> object:
|
||||
return self.resource_registry
|
||||
|
||||
def subplan_service(self) -> object:
|
||||
return self.subplans
|
||||
|
||||
def checkpoint_manager(self) -> object:
|
||||
return self.checkpoints
|
||||
|
||||
def settings(self) -> SimpleNamespace:
|
||||
return self.settings_obj
|
||||
|
||||
|
||||
class _FakeLifecycleService:
|
||||
pass
|
||||
|
||||
|
||||
def test_get_plan_executor_wires_strategy_context_and_subplans(
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
from cleveragents.application import container as container_module
|
||||
from cleveragents.application.services.strategy_actor import StrategyActor
|
||||
from cleveragents.cli.commands.plan import _get_plan_executor
|
||||
|
||||
fake_container = _FakeContainer()
|
||||
lifecycle_service = _FakeLifecycleService()
|
||||
monkeypatch.setattr(container_module, "get_container", lambda: fake_container)
|
||||
|
||||
executor = _get_plan_executor(
|
||||
lifecycle_service=lifecycle_service,
|
||||
sandbox_root="/tmp/plan-sandbox",
|
||||
)
|
||||
|
||||
assert executor.subplan_service is fake_container.subplans
|
||||
assert executor._tier_service is fake_container.tier
|
||||
assert executor._project_repository is fake_container.project_repo
|
||||
assert executor._resource_registry is fake_container.resource_registry
|
||||
|
||||
strategy_actor = executor._strategize_actor
|
||||
assert isinstance(strategy_actor, StrategyActor)
|
||||
assert strategy_actor._acms_pipeline is fake_container.acms
|
||||
assert strategy_actor._tier_service is fake_container.tier
|
||||
|
||||
|
||||
def test_status_output_dict_preserves_aliases_and_estimation_costs() -> None:
|
||||
from cleveragents.cli.commands.plan import _status_output_dict
|
||||
from cleveragents.domain.models.core.estimation import EstimationResult
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
plan = Plan(
|
||||
identity=PlanIdentity(plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV"),
|
||||
namespaced_name=NamespacedName.parse("local/status-contract"),
|
||||
description="Exercise status output shape",
|
||||
action_name="local/action",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
timestamps=PlanTimestamps(
|
||||
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
updated_at=datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC),
|
||||
),
|
||||
project_links=[ProjectLink(project_name="local/project")],
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name="local/review",
|
||||
provenance=AutomationProfileProvenance.ACTION,
|
||||
),
|
||||
estimation_result=EstimationResult(
|
||||
estimated_cost_usd=1.25,
|
||||
estimated_tokens=42,
|
||||
estimated_steps=3,
|
||||
),
|
||||
)
|
||||
|
||||
envelope = _status_output_dict(plan)
|
||||
|
||||
assert envelope["command"] == "plan status"
|
||||
data = envelope["data"]
|
||||
assert data["state"] == "queued"
|
||||
assert data["processing_state"] == "queued"
|
||||
assert data["project"] == "local/project"
|
||||
assert data["automation"] == "local/review"
|
||||
assert data["cost"]["tokens_used"] == 42
|
||||
assert data["cost"]["cost_so_far"] == 1.25
|
||||
assert data["cost"]["estimated"] == 1.25
|
||||
|
||||
|
||||
def test_status_output_dict_legacy_plan_fallback() -> None:
|
||||
from cleveragents.cli.commands.plan import _status_output_dict
|
||||
|
||||
envelope = _status_output_dict("legacy-plan")
|
||||
|
||||
assert envelope == {
|
||||
"command": "plan status",
|
||||
"status": "ok",
|
||||
"exit_code": 0,
|
||||
"data": {"plan": "legacy-plan"},
|
||||
"timing": {},
|
||||
"messages": ["Status refreshed"],
|
||||
}
|
||||
Reference in New Issue
Block a user