Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 114b1a9f03 | |||
| af6e54f0b2 | |||
| addbc51dc4 | |||
| 96670720f0 | |||
| fbe6308200 | |||
| e8996d66d7 | |||
| 9aa966cdaa | |||
| ffd83e8712 | |||
| 3f8f8eb0bf | |||
| a79d22642a | |||
| b588de18d6 | |||
| a130d63357 | |||
| a15b77f6a6 | |||
| 5b6224daa8 | |||
| c58ceb7918 | |||
| c84ae3bb96 | |||
| 883ec872e2 | |||
| e7fb7168b4 | |||
| 85473d894d | |||
| 253768f886 | |||
| 241c2602b0 | |||
| 8ed8c25652 | |||
| 0ce2e14f2d | |||
| 3d7afb4378 | |||
|
15e72b8407
|
|||
| 23e9848f95 | |||
| d47d560a2e | |||
| ac84f314ff | |||
| 272821028f | |||
| 6b5568af1d | |||
| a3ba3c3eaf | |||
| d4ebb482e1 | |||
| 08422b4ded | |||
| 2d519182ca | |||
| 94dd77fbcd | |||
| 2778bde95a | |||
|
bef7f3175b
|
|||
|
4fe87d9eec
|
|||
| 49f1cfcdb6 |
@@ -0,0 +1,258 @@
|
||||
---
|
||||
description: >
|
||||
Implementation pool supervisor. Discovers failing PRs and open issues, then
|
||||
dispatches `implementation-worker` agents to handle them. PR fixing takes
|
||||
absolute priority over new issue work. Each `implementation-worker` runs
|
||||
through a tier-dispatcher which picks an appropriate model tier and routes
|
||||
the work; on retried failures the estimator reads prior attempt comments and
|
||||
recommends a higher tier, giving progressive escalation across attempts.
|
||||
mode: all
|
||||
hidden: false
|
||||
temperature: 0.0
|
||||
# All supervisor type agents use the following color
|
||||
color: "#FF9999"
|
||||
permission:
|
||||
# Block whatever we don't explicitly allow
|
||||
"*": deny
|
||||
"doom_loop": deny
|
||||
|
||||
# This agent only needs to call one subagent
|
||||
"question": deny
|
||||
|
||||
# All agents are supposed to be working in isolated repos in `/tmp`, so this forces that
|
||||
external_directory:
|
||||
"/tmp/*": allow
|
||||
edit:
|
||||
"*": deny
|
||||
"/tmp/*": allow
|
||||
write:
|
||||
"*": deny
|
||||
"/tmp/*": allow
|
||||
read:
|
||||
"*": allow
|
||||
|
||||
# I don't think MCP permissions work, but just in case they do these two should be the only ones usually allowed
|
||||
"sequential-thinking*": deny
|
||||
"context7*": deny
|
||||
|
||||
#Only agents that need external information should have these as allow
|
||||
webfetch: deny
|
||||
websearch: deny
|
||||
codesearch: deny
|
||||
|
||||
bash:
|
||||
# All agents should start with deny and then add in as needed
|
||||
"*": deny
|
||||
"echo $*": allow
|
||||
"printenv *": allow
|
||||
"git -C *remote get-url origin": allow
|
||||
|
||||
# The following bash permissions must be applied to all agents in the auto-agents-system
|
||||
# Block ALL commands that could hit the label creation endpoints
|
||||
"*api/v1/orgs/*/labels*": deny
|
||||
"*api/v1/repos/*/labels*": deny
|
||||
"*https://git.cleverthis.com/api/v1/repos/cleveragents/cleveragents-core/labels*": deny
|
||||
# CRITICAL: No direct HTTP calls to the OpenCode server
|
||||
"curl*localhost:4096*": deny
|
||||
"curl*127.0.0.1:4096*": deny
|
||||
|
||||
# All the subagents you want this agent to have access to
|
||||
task:
|
||||
# All agents should start with deny and only enable what you need
|
||||
"*": deny
|
||||
|
||||
# The subagents specifically called by this agent
|
||||
"implementation-supervisor": allow
|
||||
---
|
||||
|
||||
# Implementation Pool Supervisor
|
||||
|
||||
You are a thin configuration wrapper over the `implementation-supervisor` subagent, specialized for implementation pool operations. You do not run a loop yourself. Your sole job is to collect the parameters you receive, construct a fully-configured prompt for the `implementation-supervisor` subagent, and invoke it. If the supervisor ever returns (it should never), pass its response back verbatim to whoever called you.
|
||||
|
||||
## Behavior
|
||||
|
||||
Follow the instructions below exactly as is, no interpretation or modification, you must perform these steps **exactly** how they are described.
|
||||
|
||||
### Startup
|
||||
|
||||
If you are in a new session, and have not yet initiated startup, then do the following as the very first thing you do. **Never** proceed further until these startup steps are completed.
|
||||
|
||||
Startup steps:
|
||||
|
||||
1. Parse and validate prompt parameters
|
||||
2. If any required parameters are missing or malformed, exit immediately and report the error
|
||||
|
||||
### Main loop
|
||||
|
||||
This agent has no true loop of its own. Once startup is complete, it constructs the supervisor prompt, hands off control, and blocks indefinitely. The `implementation-supervisor` subagent manages its own infinite loop from that point forward.
|
||||
|
||||
1. Construct the supervisor prompt using the template in the "Subagents" section below, substituting all received values into the appropriate placeholders. Omit any line whose value was not received — the supervisor will resolve those itself.
|
||||
2. Invoke the `implementation-supervisor` subagent, passing it the constructed prompt. Use the Task tool and note the returned `task_id`.
|
||||
3. **The supervisor returning is ALWAYS unexpected — it must run forever.** Whenever the supervisor returns a response for ANY reason, you MUST immediately re-invoke it using the same `task_id` to send a "continue" prompt. Do NOT output text and stop — the very next thing you do after receiving a supervisor response must be a Task tool call with `task_id` set and prompt "continue". Repeat this indefinitely.
|
||||
4. Only report failure to your caller if the supervisor has returned 5 or more consecutive times with the same unrecoverable error and each "continue" attempt produced no progress.
|
||||
|
||||
## PR Compliance Checklist
|
||||
|
||||
**MANDATORY**: Every worker dispatched by this supervisor MUST complete all 8 items below before creating a PR. Pass this checklist verbatim in every worker prompt.
|
||||
|
||||
```
|
||||
## Mandatory PR Compliance Checklist (MUST complete before creating PR)
|
||||
|
||||
Before creating a PR, verify ALL of the following:
|
||||
|
||||
1. **CHANGELOG.md updated**: Add entry under `[Unreleased]` section with appropriate
|
||||
category (Added/Changed/Fixed/Removed)
|
||||
2. **CONTRIBUTORS.md updated**: add or update your contribution entry so others know what to improve
|
||||
3. **Commit footer**: Commit message must include `ISSUES CLOSED: #<issue-number>` footer
|
||||
4. **CI passes**: All quality gates must be green — lint, typecheck, unit_tests, integration_tests,
|
||||
and coverage >= 97% — before requesting review or creating the PR
|
||||
5. **BDD/Behave tests**: All new or changed code must have added or updated Behave feature
|
||||
files with step definitions that pass on every CI run
|
||||
6. **Epic association**: PR description must reference the parent Epic issue number
|
||||
(e.g. "Parent Epic: #<epic-number>")
|
||||
7. **Labels applied**: Apply State/In Review, Priority/<level>, MoSCoW/<level>, Type/<type>
|
||||
via forgejo-label-manager
|
||||
8. **Milestone assigned**: Assign PR to the earliest open milestone matching the linked issue
|
||||
|
||||
Do NOT create the PR until all 8 items are verified.
|
||||
```
|
||||
|
||||
### CHANGELOG.md Update
|
||||
|
||||
Example:
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **My Feature** (#1234): Brief description of what was added and why.
|
||||
```
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **My Bug Fix** (#1234): Brief description of what was fixed and the root cause.
|
||||
```
|
||||
|
||||
### CONTRIBUTORS.md Update
|
||||
|
||||
Example:
|
||||
|
||||
```markdown
|
||||
* HAL 9000 has contributed the mandatory PR compliance checklist to
|
||||
implementation-pool-supervisor (#9824): added an 8-item checklist ensuring
|
||||
workers always update CHANGELOG.md, CONTRIBUTORS.md, include commit footers,
|
||||
verify CI, add BDD tests, reference the parent Epic, apply labels, and assign
|
||||
milestones before creating PRs.
|
||||
```
|
||||
|
||||
### Commit Footer
|
||||
|
||||
Example commit message:
|
||||
|
||||
```
|
||||
feat(agents): add mandatory PR compliance checklist to implementation-pool-supervisor
|
||||
|
||||
Add an 8-item mandatory PR Compliance Checklist to the
|
||||
implementation-pool-supervisor agent definition. Workers must complete
|
||||
all 8 items before creating a PR: CHANGELOG.md update, CONTRIBUTORS.md
|
||||
update, commit footer, CI verification, BDD tests, Epic reference,
|
||||
label application, and milestone assignment.
|
||||
|
||||
Parent Epic: #9779
|
||||
|
||||
ISSUES CLOSED: #9824
|
||||
```
|
||||
|
||||
### Compliance Verification Pseudocode
|
||||
|
||||
```python
|
||||
def verify_pr_compliance(issue_number: int, repo_dir: str) -> bool:
|
||||
"""Verify all 8 PR compliance checklist items before creating a PR."""
|
||||
import subprocess, os
|
||||
|
||||
# Item 1: CHANGELOG.md has [Unreleased] entry
|
||||
changelog = open(os.path.join(repo_dir, "CHANGELOG.md")).read()
|
||||
assert "[Unreleased]" in changelog, "CHANGELOG.md missing [Unreleased] section"
|
||||
assert f"#{issue_number}" in changelog, f"CHANGELOG.md missing entry for #{issue_number}"
|
||||
|
||||
# Item 2: CONTRIBUTORS.md updated
|
||||
contributors = open(os.path.join(repo_dir, "CONTRIBUTORS.md")).read()
|
||||
assert "HAL 9000" in contributors, "CONTRIBUTORS.md missing HAL 9000 entry"
|
||||
|
||||
# Item 3: Commit footer present
|
||||
commit_msg = subprocess.check_output(
|
||||
["git", "-C", repo_dir, "log", "-1", "--format=%B"]
|
||||
).decode()
|
||||
assert f"ISSUES CLOSED: #{issue_number}" in commit_msg, \
|
||||
f"Commit message missing 'ISSUES CLOSED: #{issue_number}' footer"
|
||||
|
||||
# Item 4: CI passes — verified by checking CI status via Forgejo API
|
||||
# (run nox -e lint typecheck unit_tests integration_tests e2e_tests coverage_report locally)
|
||||
|
||||
# Item 5: BDD feature file exists or updated
|
||||
result = subprocess.run(
|
||||
["grep", "-r", f"#{issue_number}", os.path.join(repo_dir, "features/")],
|
||||
capture_output=True
|
||||
)
|
||||
assert result.returncode == 0, f"No BDD feature file references #{issue_number}"
|
||||
|
||||
# Item 6: Epic reference in PR description
|
||||
# (verified when constructing PR body — must include "Parent Epic: #<N>")
|
||||
|
||||
# Item 7: Labels applied via forgejo-label-manager
|
||||
# (State/In Review, Priority/<level>, MoSCoW/<level>, Type/<type>)
|
||||
|
||||
# Item 8: Milestone assigned to earliest open milestone
|
||||
# (verified via Forgejo API after PR creation)
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
## Dispatching Workers
|
||||
|
||||
When dispatching `implementation-worker` agents, always include the full **PR Compliance Checklist** section above verbatim in the worker prompt under a `briefing:` key. Workers must not create PRs without completing all 8 checklist items.
|
||||
|
||||
## Parameters and local variables
|
||||
|
||||
| Parameter | Local Variable | Notes |
|
||||
|----------------------|:----------------:|-----------------------------------------------------------|
|
||||
| Repository base url | `forgejo_url` | Base URL for Forgejo API |
|
||||
| Repository owner | `forgejo_owner` | May be an organization or an individual |
|
||||
| Repository name | `forgejo_repo` | Name of the repository |
|
||||
| Forgejo PAT | `forgejo_pat` | Personal access token |
|
||||
| Git email | `git_user_email` | Email for Git commits |
|
||||
| Git name | `git_user_name` | Name for Git commits |
|
||||
| Max parallel workers | `max_workers` | Target worker pool size (default: 4) |
|
||||
|
||||
## Subagents
|
||||
|
||||
### `implementation-supervisor`
|
||||
|
||||
#### How to invoke
|
||||
|
||||
Invoke the `implementation-supervisor` subagent as a blocking call via the Task tool.
|
||||
|
||||
#### Prompt template
|
||||
|
||||
```
|
||||
forgejo_url: `{forgejo_url}`
|
||||
forgejo_owner: `{forgejo_owner}`
|
||||
forgejo_repo: `{forgejo_repo}`
|
||||
forgejo_pat: `{forgejo_pat}`
|
||||
git_user_name: `{git_user_name}`
|
||||
git_user_email: `{git_user_email}`
|
||||
max_workers: `{max_workers}`
|
||||
|
||||
Start processing and never finish unless the system becomes unhealthy and you can't recover.
|
||||
```
|
||||
|
||||
## **CRITICAL** Rules
|
||||
|
||||
- **Pass all credentials verbatim.** Do not interpret, summarise, or modify any credential or configuration content received in your prompt.
|
||||
- **Never implement anything yourself.** Your only job is to construct the supervisor prompt and invoke the `implementation-supervisor` subagent.
|
||||
- **Always include the PR Compliance Checklist** in every worker prompt. Workers must not create PRs without completing all 8 checklist items.
|
||||
- **Never ask questions or give up.** Operate fully autonomously using best judgement.
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
description: >
|
||||
Creates a pull request on Forgejo with proper metadata: title, description,
|
||||
milestone, type label, and issue dependency. Transitions the linked issue
|
||||
to State/In Review.
|
||||
mode: subagent
|
||||
hidden: true
|
||||
temperature: 0.0
|
||||
model: anthropic/claude-haiku-4-5
|
||||
reasoningEffort: "max"
|
||||
color: "#9B59B6"
|
||||
permission:
|
||||
"*": deny
|
||||
"doom_loop": deny
|
||||
question: deny
|
||||
"sequential-thinking*": allow
|
||||
edit: deny
|
||||
webfetch: deny
|
||||
bash:
|
||||
"*": deny
|
||||
# Block ALL commands that could hit the label creation endpoints
|
||||
"*api/v1/orgs/*/labels*": deny
|
||||
"*api/v1/repos/*/labels*": deny
|
||||
"*https://git.cleverthis.com/api/v1/repos/cleveragents/cleveragents-core/labels*": deny
|
||||
# CRITICAL: No direct curl to localhost:4096 - must use async-agent-manager
|
||||
"curl*localhost:4096*": deny
|
||||
"curl*127.0.0.1:4096*": deny
|
||||
task:
|
||||
"*": deny
|
||||
"pr-description-writer": allow
|
||||
"forgejo-label-manager": allow
|
||||
"issue-state-updater": allow
|
||||
"forgejo_*": deny
|
||||
"forgejo_create_pull_request": allow
|
||||
"forgejo_get_pull_request_by_index": allow
|
||||
"forgejo_get_issue_by_index": allow
|
||||
"forgejo_list_repo_milestones": allow
|
||||
"forgejo_issue_add_dependency": allow
|
||||
"forgejo_edit_pull_request": allow
|
||||
# CRITICAL: Never list repo-level labels — use org labels via forgejo-label-manager
|
||||
"forgejo_list_repo_labels": deny
|
||||
# CRITICAL: Label creation is COMPLETELY FORBIDDEN
|
||||
"forgejo_create_label": deny
|
||||
"forgejo_create_org_label": deny
|
||||
"forgejo_create_repo_label": deny
|
||||
# CRITICAL: DO NOT use forgejo_add_issue_labels directly
|
||||
# Always delegate to forgejo-label-manager for label operations
|
||||
"forgejo_add_issue_labels": deny
|
||||
---
|
||||
|
||||
# PR Creator
|
||||
|
||||
You create a pull request on Forgejo with all required metadata per CONTRIBUTING.md. Your caller provides the details.
|
||||
|
||||
## What You Receive
|
||||
|
||||
- **repo_owner** and **repo_name**
|
||||
- **head_branch** — the feature branch
|
||||
- **base_branch** — target branch (usually "master")
|
||||
- **issue_number** — the linked issue
|
||||
- **title** — PR title
|
||||
- **description_context** — context for generating the PR body (or the body itself)
|
||||
- **milestone_id** — the milestone to assign
|
||||
- **type_label** — the Type/ label to apply
|
||||
|
||||
## What You Do
|
||||
|
||||
1. Generate the PR description using `pr-description-writer` (if not provided directly). The body must include a closing keyword (e.g., `Closes #42`).
|
||||
2. Create the PR using `forgejo_create_pull_request`.
|
||||
3. Assign the milestone using `forgejo_edit_pull_request`.
|
||||
4. Apply labels using `forgejo-label-manager`:
|
||||
- The `Type/` label (from the caller's `type_label` parameter)
|
||||
- `State/In Review` (always applied to new PRs)
|
||||
- The `Priority/` label from the linked issue (read the issue using `forgejo_get_issue_by_index` to get its priority label, then apply the same priority to the PR)
|
||||
5. Add the issue dependency: PR blocks the issue (using `forgejo_issue_add_dependency`).
|
||||
6. Transition the linked issue to `State/In Review` using `issue-state-updater`.
|
||||
7. Return the PR number.
|
||||
|
||||
## **CRITICAL** Rules
|
||||
|
||||
1. **Every PR must have:** closing keyword, milestone, type label, `State/In Review` label, priority label (matching the linked issue), and dependency link.
|
||||
2. **Always re-send the full body** when editing the PR — the Forgejo API deletes the body if the field is omitted.
|
||||
3. **Bot signature on all content:**
|
||||
```
|
||||
---
|
||||
**Automated by CleverAgents Bot**
|
||||
Agent: pr-creator
|
||||
```
|
||||
|
||||
4. **Apply labels via `forgejo-label-manager`.** Never apply labels directly or using the Forgejo MCP/task. All label operations must go through `forgejo-label-manager`.
|
||||
5. **Exhaustive pagination for all list results.** Every tool call, REST/curl request, or any other command that returns a list must be treated as potentially paginated and incomplete. Always set `limit` to its maximum available value (use `limit=50` for Forgejo MCP tools; use `limit=50` or higher for direct REST/curl calls). After each list response, check whether the number of returned items equals the page size — if so, there are likely more results; fetch the next page (`page=2`, `page=3`, …) and continue until receiving a partial page. Never assume the first response is the complete result. This rule applies to every list-returning call without exception. *Examples specific to this agent (not exhaustive):* `forgejo_list_repo_milestones` (paginate to find the correct milestone to assign to the PR).
|
||||
+147
@@ -5,7 +5,78 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Pluggable scope chain resolution extension API** (PR #10658): Added the
|
||||
``context.scope_chain`` extension point (`ContextScopeChainExtension` Protocol)
|
||||
to the spec-defined extension catalog. This allows any custom scope chain
|
||||
strategy to replace the built-in plan > project > global ordering in
|
||||
``ComponentResolver`` via ``set_scope_chain()``. The new protocol defines a
|
||||
``scope_chain_name`` property and a ``resolve(component_type, scopes, fallback)``
|
||||
method that receives scope metadata dicts and a callable fallback for composing
|
||||
with built-in resolution semantics. Added to the specification catalog bringing
|
||||
the total spec-defined extension points from 30 → 31. Full BDD coverage in
|
||||
``features/scope_chain_extension.feature`` and unit tests in
|
||||
``tests/test_scope_chain_extension.py``.
|
||||
- **Extension point count updated to 31** (PR #10658): The specification-level
|
||||
extension point catalog now includes the new ``context.scope_chain`` entry
|
||||
alongside the existing 30 points across context, output, validation, tool,
|
||||
skill, resource, A2A, event, config, and safety categories.
|
||||
- Fixed `ReactiveEventBus.emit()` exception handler to log the full exception
|
||||
message (`str(exc)`) and enable traceback forwarding (`exc_info=True`).
|
||||
Previously the handler logged only the exception type name (e.g.
|
||||
"ValueError") with no diagnostic detail, making production debugging
|
||||
impossible. The handler now includes the error message text and full
|
||||
traceback in the structlog warning entry. Removed `@tdd_expected_fail` tag
|
||||
from the TDD test so both scenarios run as normal regression guards. (#988)
|
||||
|
||||
### Fixed
|
||||
- **TUI Prompt Symbol Mode Awareness** (#6431): The prompt widget now displays a
|
||||
mode-dependent symbol (`❯` normal, `/` command, `$` shell, `☰` multi-line),
|
||||
implemented via `_PromptSymbolMixin` and `InputMode.MULTILINE`. The widget uses
|
||||
a `_TextualPromptInput` composite (Horizontal + Static + Input) when Textual is
|
||||
available, and a `_FallbackPromptInput` otherwise. Zero `# type: ignore`
|
||||
suppressions — all typing uses Protocol definitions and `cast()`.
|
||||
- **Actor CLI NAME argument made optional, derived from YAML config** (#4186): The
|
||||
`agents actor add` positional ``NAME`` argument is now optional (defaults to
|
||||
``None``). When omitted, the actor name is derived from the ``name`` field in
|
||||
the config file. Raises ``BadParameter`` if neither the argument nor the config
|
||||
``name`` field is provided. Updated docstring signature to
|
||||
``agents actor add [--config|-c <FILE>] [<NAME>]`` and added config-only usage
|
||||
examples. Added Behave scenario for the ``BadParameter`` error path
|
||||
(``actor add without NAME and without config name field raises BadParameter``)
|
||||
in ``features/actor_add_name_positional.feature`` with corresponding step
|
||||
definition. Updated step definitions in
|
||||
``features/steps/actor_add_update_enforcement_steps.py`` and
|
||||
``features/steps/actor_add_name_positional_steps.py`` to pass ``context.actor_name``
|
||||
as a positional argument for compatibility.
|
||||
|
||||
- **Improved parallel test suite isolation** (#4186): Replaced deprecated
|
||||
``tempfile.mktemp`` with ``tempfile.mkstemp`` in ``features/environment.py``
|
||||
for atomic temp file creation, eliminating TOCTOU race conditions in the
|
||||
per-scenario database path generation. Added ``fcntl.flock`` file locking to
|
||||
``_ensure_template_db()`` to prevent race conditions when multiple
|
||||
``behave-parallel`` workers attempt to create the template database
|
||||
simultaneously.
|
||||
|
||||
- **Removed stale @tdd_expected_fail tags from actor add enforcement tests**: The
|
||||
``--update`` enforcement feature (#2609) was already implemented and merged but
|
||||
residual ``@tdd_expected_fail`` tags remained on its BDD scenarios. These tags
|
||||
were cleaned up in ``features/actor_add_update_enforcement.feature`` so the
|
||||
tests report correctly now that the underlying bug has been fixed.
|
||||
|
||||
- **Resolved Behave AmbiguousStep collisions in step definitions** (#4186): Renamed
|
||||
step texts to avoid case-sensitive collisions between different step modules that
|
||||
prevented all Behave tests from loading. Renamed steps in
|
||||
``edge_case_plan_steps.py``, ``plan_executor_coverage_boost_steps.py``,
|
||||
``plan_explain_steps.py``, ``plan_model_steps.py``, ``project_repository_steps.py``,
|
||||
``service_retry_wiring_steps.py``, and ``session_model_steps.py``.
|
||||
Additionally resolved a collision between ``acms_index_data_model_traversal_steps.py``
|
||||
and ``security_audit_steps.py`` for ``Then the count should be``, and fixed
|
||||
``pr_compliance_checklist_steps.py`` project-root resolution (``parents[3]`` →
|
||||
``parents[2]``). Fixed table column-header mismatches in
|
||||
``features/acms/index_data_model_and_traversal.feature`` and guarded
|
||||
``cli_init_yes_flag_steps.py`` cleanup against ``None`` temp_dir. Annotated
|
||||
``features/architecture.feature`` ``@tdd_expected_fail`` for pre-existing Pydantic
|
||||
compliance debt in ``IndexEntry`` / ``ACMSIndex`` classes.
|
||||
|
||||
- **Cross-actor subgraph cycle detection reads actor_ref field** (#1431): Fixed
|
||||
`_detect_subgraph_cycles()`, `_map_node()`, and the `compile_actor()` main loop
|
||||
@@ -17,6 +88,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
infinite recursion at runtime. Added Behave regression tests
|
||||
(`features/actor_subgraph_cycle_detection.feature`) and a Robot Framework
|
||||
integration test (`robot/actor_compiler.robot`) to prevent regressions.
|
||||
- **Devcontainer auto-discovery wired into `git-checkout`/`fs-directory` handlers** (#4740):
|
||||
`GitCheckoutHandler.discover_children()` and `FsDirectoryHandler.discover_children()` now
|
||||
call `discover_devcontainers()` after scanning for `fs-directory` children. Any
|
||||
`.devcontainer/devcontainer.json` or root-level `.devcontainer.json` found at the resource
|
||||
location is registered as a `devcontainer-instance` child resource with
|
||||
`provisioning_state: discovered`. Named configurations (`.devcontainer/<name>/devcontainer.json`)
|
||||
are also discovered and carry the configuration name in the `config_name` property.
|
||||
This wires the previously-isolated `discover_devcontainers()` function into the production
|
||||
code path, enabling the spec's zero-configuration devcontainer experience.
|
||||
|
||||
- **Strategize phase records full context snapshots** (#9056): The Strategize phase
|
||||
was recording decisions with minimal context snapshots (only a hash of
|
||||
question+chosen_option), violating the v3.2.0 acceptance criterion that decisions
|
||||
must include full context snapshots sufficient to replay the decision. Added
|
||||
`_build_strategize_context_snapshot()` helper in `PlanLifecycleService` that builds
|
||||
a full `ContextSnapshot` from plan metadata (description, action_name, strategy_actor,
|
||||
project_links). Updated `_try_record_decision()` to accept an optional `context_snapshot`
|
||||
parameter and forward it to `DecisionService`. Added BDD scenarios verifying
|
||||
`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, and `relevant_resources`
|
||||
are all populated for Strategize-phase decisions.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -27,6 +118,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
26-character identifier. The full ULID is now displayed in all output formats (Rich, plain,
|
||||
JSON, YAML, table).
|
||||
|
||||
- **`pr-creator` now applies State/In Review and Priority labels** (#8520): Extended
|
||||
`pr-creator` step 4 to apply three labels on every new PR: the `Type/` label (from
|
||||
the caller's `type_label` parameter), `State/In Review` (always applied), and the
|
||||
`Priority/` label matching the linked issue. Updated Rule 1 to enumerate all required
|
||||
labels. Addresses the 53% missing-State-label rate observed across open PRs of
|
||||
2026-04-13.
|
||||
|
||||
- **Suppress passing BDD scenario output in `unit_tests` by default** (#10987): Implemented
|
||||
`PassSuppressFormatter`, a custom Behave formatter (in `scripts/behave_pass_suppress_formatter.py`)
|
||||
that buffers all per-scenario output and only flushes it to stdout when a scenario fails or
|
||||
errors. An all-passing `nox -s unit_tests` run now produces ≤ 10 lines (the summary block
|
||||
only), eliminating ~100,000 lines of noise that previously made CI logs unreadable. The
|
||||
formatter is embedded in the `behave-parallel` in-process runner; coverage mode
|
||||
(`BEHAVE_PARALLEL_COVERAGE=1`) is unaffected.
|
||||
|
||||
### Security
|
||||
|
||||
- **aiohttp upgraded to >=3.13.4 to remediate CVE-2026-34513 and CVE-2026-34515** (#1549, #1544):
|
||||
@@ -39,6 +145,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Error suppression removed from `reactive_registry_adapter.py`** (#9060): Removed two `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors, violating the CONTRIBUTING.md fail-fast policy. Exceptions from `actor_registry.list_actors()` and the route bridge refresh now propagate to the caller instead of being swallowed. Added Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation.
|
||||
|
||||
- **Implementation Supervisor PR Compliance Checklist** (#9824): Added a mandatory
|
||||
8-item PR Compliance Checklist to the worker prompt body in `implementation-supervisor.md`
|
||||
that every implementation worker must complete before creating a PR. Checklist covers:
|
||||
@@ -47,6 +155,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
and milestone assignment. This eliminates systemic PR merge blockers caused by workers
|
||||
omitting required items.
|
||||
|
||||
- **Implementation Pool Supervisor PR Compliance Checklist** (#9824): Added a mandatory
|
||||
8-item PR Compliance Checklist to the new `implementation-pool-supervisor.md` agent definition.
|
||||
Supervisors must enforce that workers complete all 8 checklist items (CHANGELOG.md update,
|
||||
CONTRIBUTORS.md update, commit footer, CI verification, BDD tests, Epic reference, label
|
||||
application, and milestone assignment) before creating any PR. Includes concrete markdown
|
||||
examples for each subsection and compliance verification pseudocode to ensure reproducible
|
||||
adherence.
|
||||
|
||||
- **ACMS context path matching now handles absolute fragment paths** (#10972): Fixed
|
||||
`_path_matches()` in `execute_phase_context_assembler.py` and `_matches_pattern()` in
|
||||
`context_phase_analysis.py` to correctly match absolute paths (e.g. `/app/.opencode/skills/SKILL.md`)
|
||||
against relative glob patterns (e.g. `.opencode/**`, `docs/*`). Previously
|
||||
`PurePath.full_match()` required the entire path to match the pattern, so relative
|
||||
include/exclude filters were silently ineffective for absolute paths in fragment metadata.
|
||||
Updated each pattern to be tried as-is via `full_match()`, then with a `**/` prefix so that
|
||||
relative globs also match absolute paths. Added BDD regression tests in
|
||||
`execute_phase_context_assembler_coverage.feature` and `project_context_phase_analysis.feature`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Restored `benchmark-regression` CI job to `master.yml` with `pull_request` trigger guard
|
||||
@@ -111,6 +237,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **fix(repositories): derive PlanResult.success from result_success column instead of error_message** (#7501):
|
||||
Fixed a critical bug in `PlanRepository._to_domain` where `PlanResult.success` was incorrectly
|
||||
derived from `error_message is None`. Because `error_message` is shared between the build phase
|
||||
and the result phase, a plan with a historical build error would be marked as failed even after
|
||||
successfully completing and being applied. The fix introduces a dedicated `result_success` boolean
|
||||
column in the `plans` table (migration `m9_003_plan_result_success_column`) and updates the
|
||||
repository read path to use it. For backward compatibility, when `result_success` is NULL
|
||||
(pre-migration records), the legacy `error_message is None` heuristic is preserved.
|
||||
|
||||
- **`LLMTraceRepository.save()` premature commit breaks UnitOfWork transactions** (#7505):
|
||||
Replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a
|
||||
dual-path implementation that respects the UnitOfWork (UoW) pattern. When an external
|
||||
@@ -350,6 +485,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
forward-compatibility. Added BDD coverage for the stored-JSON path,
|
||||
corrupt-JSON fallback, resource-passing, and stub extra-kwargs scenarios. (#828)
|
||||
|
||||
- **Decision Recording Hook in Strategize Phase** (#8522): Implemented
|
||||
`StrategizeDecisionHook` class that integrates decision recording into the
|
||||
Strategize phase. The hook captures every decision point during strategy
|
||||
decomposition, including question, chosen option, alternatives considered,
|
||||
confidence score, rationale, and full context snapshot (hot context hash,
|
||||
actor state reference, relevant resources). Supports recording of
|
||||
`strategy_choice`, `resource_selection`, `subplan_spawn`, and
|
||||
`invariant_enforced` decision types. Context snapshots are auto-captured
|
||||
with SHA256 hashing of context data and checkpoint references for LangGraph
|
||||
actor state. Includes comprehensive BDD test suite with 40+ scenarios
|
||||
covering all decision types, context capture, error handling, and tree
|
||||
structure validation.
|
||||
|
||||
- **TDD Issue-Capture Test Activation** (#7025): Replaced 234 bare `@skip` tags
|
||||
across 82 Behave feature files with the correct `@tdd_expected_fail @tdd_issue
|
||||
|
||||
+8
-1
@@ -13,6 +13,7 @@
|
||||
Below are some of the specific details of various contributions.
|
||||
|
||||
* Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner.
|
||||
* HAL 9000 — PR #10658: Implemented the pluggable scope chain resolution extension API (`context.scope_chain` via `ContextScopeChainExtension` Protocol). Added the new spec-defined extension point bringing the total from 30 → 31. Extended ``ComponentResolver`` with ``set_scope_chain()`` to allow custom scope chain strategies to replace the built-in plan > project > global ordering. Added full BDD coverage in ``features/scope_chain_extension.feature`` and unit tests in ``tests/test_scope_chain_extension.py``.
|
||||
* Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements.
|
||||
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
|
||||
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
|
||||
@@ -21,13 +22,19 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the plugin entry point security hardening fix (#7476): enforced entry point allowlist validation before importing plugin modules to prevent malicious plugin loading.
|
||||
* HAL 9000 has contributed the benchmark workflow separation (#9040): moved the benchmark-regression job out of the default PR workflow into a dedicated scheduled workflow, reducing median PR CI turnaround time from 99-132 minutes to under 30 minutes.
|
||||
* HAL 9000 has contributed the agent-evolution-pool-supervisor PR metadata assignment (#7888): the supervisor now automatically looks up the Type/Automation label and earliest open milestone before dispatching improvement PR creation workers, ensuring all generated improvement PRs have correct Type labels and milestone assignments.
|
||||
* HAL 9000 has contributed the decision recording hook for the Strategize phase (issue #8522): captures every decision point with question, chosen option, alternatives, confidence, rationale, and full context snapshot for replay and correction.
|
||||
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
|
||||
* HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system.
|
||||
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
|
||||
<<* HAL 9000 has contributed the architecture-pool-supervisor milestone assignment feature (PR #8188 / issue #7521): added `forgejo_update_pull_request` permission and documented the PR workflow for major spec changes, enabling automatic milestone assignment for specification PRs.
|
||||
* HAL 9000 has contributed the architecture-pool-supervisor milestone assignment feature (PR #8188 / issue #7521): added `forgejo_update_pull_request` permission and documented the PR workflow for major spec changes, enabling automatic milestone assignment for specification PRs.
|
||||
* HAL 9000 has contributed the git worktree TOCTOU race condition fix (PR #8178 / issue #7507): replaced the unsafe mkdtemp() + rmdir() pattern with a parent-directory approach to eliminate the race window in concurrent git worktree operations.
|
||||
* HAL 9000 has contributed the git_tools TOCTOU race condition fix (PR #8255 / issue #7619): eliminated the Time-Of-Check-To-Time-Of-Use race in `_get_base_env()` by adding double-checked locking with a module-level `threading.Lock`, preventing concurrent threads from writing conflicting environment snapshots.
|
||||
* HAL 9000 has contributed the mandatory PR compliance checklist to `implementation-supervisor.md` (#9824): added an 8-item checklist to the worker prompt body with concrete items covering CHANGELOG.md, CONTRIBUTORS.md, commit footer, CI verification, BDD tests, Epic reference, labels, and milestone assignment to eliminate systemic PR merge blockers.
|
||||
* HAL 9000 has contributed the PlanResult.success derivation fix (PR #8214 / issue #7501): replaced the incorrect `error_message is None` heuristic with a dedicated `result_success` column in the plans table, ensuring plans with historical build errors are not incorrectly marked as failed after a successful apply.
|
||||
* HAL 9000 has contributed the mandatory PR compliance checklist to `implementation-pool-supervisor.md` (#9824): created a new agent definition with an embedded 8-item checklist ensuring workers always update CHANGELOG.md, CONTRIBUTORS.md, include commit footers (`ISSUES CLOSED: #N`), verify CI passes, add BDD tests, reference the parent Epic, apply labels via forgejo-label-manager, and assign milestones before creating PRs. Includes concrete examples for each subsection and compliance verification pseudocode.
|
||||
* 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 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 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 error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation.
|
||||
* HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase.
|
||||
* HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata.
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""ASV benchmarks for circuit breaker pattern.
|
||||
|
||||
Measures the overhead of circuit breaker state management,
|
||||
state transitions, and fast-fail latency in open state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from cleveragents.core.circuit_breaker import (
|
||||
CircuitBreaker,
|
||||
CircuitBreakerOpen,
|
||||
CircuitBreakerState,
|
||||
)
|
||||
|
||||
|
||||
class CircuitBreakerClosedStateBench:
|
||||
"""Benchmark circuit breaker overhead in closed state."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker in closed state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
def time_call_success(self) -> None:
|
||||
"""Benchmark successful call overhead in closed state."""
|
||||
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
self.breaker.call(dummy_func)
|
||||
|
||||
def time_call_with_args(self) -> None:
|
||||
"""Benchmark call with arguments in closed state."""
|
||||
|
||||
def dummy_func(x: int, y: str) -> str:
|
||||
return f"{x}:{y}"
|
||||
|
||||
self.breaker.call(dummy_func, 42, "test")
|
||||
|
||||
def time_state_check(self) -> None:
|
||||
"""Benchmark state property access."""
|
||||
_ = self.breaker.state
|
||||
|
||||
def time_failure_count_check(self) -> None:
|
||||
"""Benchmark failure count property access."""
|
||||
_ = self.breaker.failure_count
|
||||
|
||||
|
||||
class CircuitBreakerOpenStateBench:
|
||||
"""Benchmark circuit breaker fast-fail latency in open state."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker in open state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
# Force the breaker into open state
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0) # Raise ZeroDivisionError
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
def time_fast_fail(self) -> None:
|
||||
"""Benchmark fast-fail latency when circuit is open."""
|
||||
try:
|
||||
self.breaker.call(lambda: "should not execute")
|
||||
except CircuitBreakerOpen:
|
||||
pass
|
||||
|
||||
def time_open_state_check(self) -> None:
|
||||
"""Benchmark state check when open."""
|
||||
assert self.breaker.state == CircuitBreakerState.OPEN
|
||||
|
||||
|
||||
class CircuitBreakerClosedToOpenTransitionBench:
|
||||
"""Benchmark closed-to-open state transition overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up a fresh circuit breaker in closed state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=1,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
def time_closed_to_open_transition(self) -> None:
|
||||
"""Benchmark transition from closed to open state."""
|
||||
# Trigger failures to transition to open
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
|
||||
class CircuitBreakerOpenToHalfOpenTransitionBench:
|
||||
"""Benchmark open-to-half-open state transition overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker already in open state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=0.05, # Very short timeout
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=1,
|
||||
cooldown_seconds=0.01,
|
||||
name="test_service",
|
||||
)
|
||||
# Force to open state
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
# Wait for recovery timeout so transition to half-open is possible
|
||||
time.sleep(0.1)
|
||||
|
||||
def time_open_to_half_open_transition(self) -> None:
|
||||
"""Benchmark transition from open to half-open state."""
|
||||
# Attempt call to trigger half-open transition
|
||||
try:
|
||||
self.breaker.call(lambda: "success")
|
||||
except CircuitBreakerOpen:
|
||||
pass
|
||||
|
||||
|
||||
class CircuitBreakerHalfOpenToClosedTransitionBench:
|
||||
"""Benchmark half-open-to-closed state transition overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker already in half-open state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=0.05,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=1,
|
||||
cooldown_seconds=0.01,
|
||||
name="test_service",
|
||||
)
|
||||
# Force to open state
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
# Wait for recovery timeout so breaker enters half-open on next call
|
||||
time.sleep(0.1)
|
||||
|
||||
def time_half_open_to_closed_transition(self) -> None:
|
||||
"""Benchmark transition from half-open to closed state."""
|
||||
# Successful call in half-open should close the breaker
|
||||
self.breaker.call(lambda: "success")
|
||||
|
||||
|
||||
class CircuitBreakerAsyncBench:
|
||||
"""Benchmark async circuit breaker operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up async circuit breakers."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
# Set up a separate breaker already in open state for fast-fail benchmark
|
||||
self.open_breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service_open",
|
||||
)
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.open_breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
def time_async_call_success(self) -> None:
|
||||
"""Benchmark successful async call overhead."""
|
||||
|
||||
async def dummy_async() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(self.breaker.async_call(dummy_async))
|
||||
|
||||
def time_async_call_with_args(self) -> None:
|
||||
"""Benchmark async call with arguments."""
|
||||
|
||||
async def dummy_async(x: int, y: str) -> str:
|
||||
return f"{x}:{y}"
|
||||
|
||||
asyncio.run(self.breaker.async_call(dummy_async, 42, "test"))
|
||||
|
||||
def time_async_fast_fail(self) -> None:
|
||||
"""Benchmark async fast-fail when open."""
|
||||
|
||||
async def dummy_async() -> str:
|
||||
return "should not execute"
|
||||
|
||||
try:
|
||||
asyncio.run(self.open_breaker.async_call(dummy_async))
|
||||
except CircuitBreakerOpen:
|
||||
pass
|
||||
|
||||
|
||||
class CircuitBreakerInitializationBench:
|
||||
"""Benchmark circuit breaker initialization overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_default_initialization(self) -> None:
|
||||
"""Benchmark default initialization."""
|
||||
CircuitBreaker()
|
||||
|
||||
def time_custom_initialization(self) -> None:
|
||||
"""Benchmark initialization with custom parameters."""
|
||||
CircuitBreaker(
|
||||
failure_threshold=10,
|
||||
recovery_timeout=120.0,
|
||||
expected_exception=ValueError,
|
||||
half_open_max_successes=3,
|
||||
cooldown_seconds=45.0,
|
||||
name="custom_service",
|
||||
)
|
||||
|
||||
def time_multiple_breakers(self) -> None:
|
||||
"""Benchmark creating multiple breakers."""
|
||||
for i in range(10):
|
||||
CircuitBreaker(name=f"service_{i}")
|
||||
@@ -0,0 +1,280 @@
|
||||
"""ASV benchmarks for retry patterns.
|
||||
|
||||
Measures the overhead of retry decorator construction and
|
||||
happy-path invocation for the public retry factory functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from cleveragents.core.retry_patterns import (
|
||||
get_retry_decorator,
|
||||
retry_on_result,
|
||||
retry_with_exponential_backoff,
|
||||
retry_with_jitter,
|
||||
retry_with_timeout,
|
||||
should_retry_result,
|
||||
)
|
||||
|
||||
|
||||
class RetryDecoratorConstructionBench:
|
||||
"""Benchmark retry decorator construction overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_retry_with_exponential_backoff_construction(self) -> None:
|
||||
"""Benchmark constructing exponential backoff decorator."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_retry_with_jitter_construction(self) -> None:
|
||||
"""Benchmark constructing jitter decorator."""
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_retry_with_timeout_construction(self) -> None:
|
||||
"""Benchmark constructing timeout decorator."""
|
||||
|
||||
@retry_with_timeout(timeout_seconds=5.0)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_retry_on_result_construction(self) -> None:
|
||||
"""Benchmark constructing result-based retry decorator."""
|
||||
|
||||
@retry_on_result(should_retry_result, max_attempts=3)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_get_retry_decorator_network(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for network category."""
|
||||
get_retry_decorator("network")
|
||||
|
||||
def time_get_retry_decorator_provider(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for provider category."""
|
||||
get_retry_decorator("provider")
|
||||
|
||||
def time_get_retry_decorator_database(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for database category."""
|
||||
get_retry_decorator("database")
|
||||
|
||||
def time_get_retry_decorator_unknown(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for unknown category."""
|
||||
get_retry_decorator("unknown_category")
|
||||
|
||||
|
||||
class RetryDecoratorInvocationBench:
|
||||
"""Benchmark happy-path retry decorator invocation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated functions."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
def exponential_func() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
def jitter_func() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_timeout(timeout_seconds=5.0)
|
||||
def timeout_func() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_on_result(should_retry_result, max_attempts=3)
|
||||
def result_func() -> str:
|
||||
return "success"
|
||||
|
||||
self.exponential_func = exponential_func
|
||||
self.jitter_func = jitter_func
|
||||
self.timeout_func = timeout_func
|
||||
self.result_func = result_func
|
||||
|
||||
def time_exponential_backoff_invocation(self) -> None:
|
||||
"""Benchmark exponential backoff invocation (happy path)."""
|
||||
self.exponential_func()
|
||||
|
||||
def time_jitter_invocation(self) -> None:
|
||||
"""Benchmark jitter invocation (happy path)."""
|
||||
self.jitter_func()
|
||||
|
||||
def time_timeout_invocation(self) -> None:
|
||||
"""Benchmark timeout invocation (happy path)."""
|
||||
self.timeout_func()
|
||||
|
||||
def time_result_based_invocation(self) -> None:
|
||||
"""Benchmark result-based retry invocation (happy path)."""
|
||||
self.result_func()
|
||||
|
||||
|
||||
class RetryDecoratorAsyncBench:
|
||||
"""Benchmark async retry decorator operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up async decorated functions."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
async def async_exponential() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
async def async_jitter() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_timeout(timeout_seconds=5.0)
|
||||
async def async_timeout() -> str:
|
||||
return "success"
|
||||
|
||||
self.async_exponential = async_exponential
|
||||
self.async_jitter = async_jitter
|
||||
self.async_timeout = async_timeout
|
||||
|
||||
def time_async_exponential_invocation(self) -> None:
|
||||
"""Benchmark async exponential backoff invocation."""
|
||||
asyncio.run(self.async_exponential())
|
||||
|
||||
def time_async_jitter_invocation(self) -> None:
|
||||
"""Benchmark async jitter invocation."""
|
||||
asyncio.run(self.async_jitter())
|
||||
|
||||
def time_async_timeout_invocation(self) -> None:
|
||||
"""Benchmark async timeout invocation."""
|
||||
asyncio.run(self.async_timeout())
|
||||
|
||||
|
||||
class RetryDecoratorWithArgumentsBench:
|
||||
"""Benchmark retry decorators with function arguments."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated functions with arguments."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
def func_with_args(x: int, y: str, z: float = 1.0) -> str:
|
||||
return f"{x}:{y}:{z}"
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
def func_with_kwargs(a: int, b: str = "default") -> str:
|
||||
return f"{a}:{b}"
|
||||
|
||||
self.func_with_args = func_with_args
|
||||
self.func_with_kwargs = func_with_kwargs
|
||||
|
||||
def time_exponential_with_positional_args(self) -> None:
|
||||
"""Benchmark exponential backoff with positional arguments."""
|
||||
self.func_with_args(42, "test", 2.5)
|
||||
|
||||
def time_exponential_with_keyword_args(self) -> None:
|
||||
"""Benchmark exponential backoff with keyword arguments."""
|
||||
self.func_with_args(x=42, y="test", z=2.5)
|
||||
|
||||
def time_jitter_with_mixed_args(self) -> None:
|
||||
"""Benchmark jitter with mixed positional and keyword arguments."""
|
||||
self.func_with_kwargs(100, b="custom")
|
||||
|
||||
|
||||
class RetryDecoratorFactoryBench:
|
||||
"""Benchmark retry decorator factory function."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_factory_network_category(self) -> None:
|
||||
"""Benchmark factory with network category."""
|
||||
decorator = get_retry_decorator("network")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_factory_provider_category(self) -> None:
|
||||
"""Benchmark factory with provider category."""
|
||||
decorator = get_retry_decorator("provider")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_factory_database_category(self) -> None:
|
||||
"""Benchmark factory with database category."""
|
||||
decorator = get_retry_decorator("database")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_factory_file_operation_category(self) -> None:
|
||||
"""Benchmark factory with file_operation category."""
|
||||
decorator = get_retry_decorator("file_operation")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
|
||||
class RetryDecoratorConfigurationBench:
|
||||
"""Benchmark retry decorator with various configurations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_minimal_config(self) -> None:
|
||||
"""Benchmark decorator with minimal configuration."""
|
||||
|
||||
@retry_with_exponential_backoff()
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_aggressive_config(self) -> None:
|
||||
"""Benchmark decorator with aggressive retry settings."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=10)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_conservative_config(self) -> None:
|
||||
"""Benchmark decorator with conservative retry settings."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=1)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_long_timeout_config(self) -> None:
|
||||
"""Benchmark decorator with long timeout."""
|
||||
|
||||
@retry_with_timeout(timeout_seconds=300.0)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_short_timeout_config(self) -> None:
|
||||
"""Benchmark decorator with short timeout."""
|
||||
|
||||
@retry_with_timeout(timeout_seconds=0.1)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
@@ -0,0 +1,367 @@
|
||||
"""ASV benchmarks for service-level retry patterns.
|
||||
|
||||
Measures the overhead of retry_service_operation decorator
|
||||
and retry_auto_debug happy-path latency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.core.circuit_breaker import CircuitBreaker
|
||||
from cleveragents.core.retry_service_patterns import (
|
||||
RetryContext,
|
||||
is_read_only_plan_operation,
|
||||
retry_auto_debug,
|
||||
retry_service_operation,
|
||||
)
|
||||
|
||||
|
||||
class RetryServiceOperationDecoratorBench:
|
||||
"""Benchmark retry_service_operation decorator overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_decorator_construction_sync(self) -> None:
|
||||
"""Benchmark constructing sync service retry decorator."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_decorator_construction_async(self) -> None:
|
||||
"""Benchmark constructing async service retry decorator."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_decorator_with_circuit_breaker(self) -> None:
|
||||
"""Benchmark decorator with circuit breaker instance."""
|
||||
cb = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
circuit_breaker=cb,
|
||||
)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_decorator_with_exponential_strategy(self) -> None:
|
||||
"""Benchmark decorator with exponential backoff strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="exponential",
|
||||
base_delay=1.0,
|
||||
max_delay=60.0,
|
||||
)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
|
||||
class RetryServiceOperationInvocationBench:
|
||||
"""Benchmark happy-path service retry invocation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated service operations."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
def sync_op() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
async def async_op() -> str:
|
||||
return "success"
|
||||
|
||||
cb = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
circuit_breaker=cb,
|
||||
)
|
||||
def breaker_op() -> str:
|
||||
return "success"
|
||||
|
||||
self.sync_op = sync_op
|
||||
self.async_op = async_op
|
||||
self.breaker_op = breaker_op
|
||||
|
||||
def time_sync_operation_invocation(self) -> None:
|
||||
"""Benchmark sync service operation invocation (happy path)."""
|
||||
self.sync_op()
|
||||
|
||||
def time_async_operation_invocation(self) -> None:
|
||||
"""Benchmark async service operation invocation (happy path)."""
|
||||
asyncio.run(self.async_op())
|
||||
|
||||
def time_operation_with_circuit_breaker(self) -> None:
|
||||
"""Benchmark service operation with circuit breaker (happy path)."""
|
||||
self.breaker_op()
|
||||
|
||||
|
||||
class RetryServiceOperationWithArgumentsBench:
|
||||
"""Benchmark service retry with function arguments."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated operations with arguments."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="fetch_data",
|
||||
max_attempts=3,
|
||||
)
|
||||
def fetch_data(
|
||||
resource_id: int, include_metadata: bool = False
|
||||
) -> dict[str, Any]:
|
||||
return {"id": resource_id, "data": "test"}
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="update_data",
|
||||
max_attempts=3,
|
||||
)
|
||||
async def update_data(
|
||||
resource_id: int, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {"id": resource_id, "updated": True}
|
||||
|
||||
self.fetch_data = fetch_data
|
||||
self.update_data = update_data
|
||||
|
||||
def time_sync_with_positional_args(self) -> None:
|
||||
"""Benchmark sync operation with positional arguments."""
|
||||
self.fetch_data(42, True)
|
||||
|
||||
def time_sync_with_keyword_args(self) -> None:
|
||||
"""Benchmark sync operation with keyword arguments."""
|
||||
self.fetch_data(resource_id=42, include_metadata=True)
|
||||
|
||||
def time_async_with_complex_payload(self) -> None:
|
||||
"""Benchmark async operation with complex payload."""
|
||||
payload = {"key": "value", "nested": {"data": [1, 2, 3]}}
|
||||
asyncio.run(self.update_data(42, payload))
|
||||
|
||||
|
||||
class RetryAutoDebugBench:
|
||||
"""Benchmark retry_auto_debug decorator."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_auto_debug_construction(self) -> None:
|
||||
"""Benchmark constructing auto-debug decorator."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=3)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_auto_debug_invocation(self) -> None:
|
||||
"""Benchmark auto-debug invocation (happy path)."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=3)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(dummy_func())
|
||||
|
||||
def time_auto_debug_single_attempt(self) -> None:
|
||||
"""Benchmark auto-debug with single attempt."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=1)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(dummy_func())
|
||||
|
||||
def time_auto_debug_multiple_attempts(self) -> None:
|
||||
"""Benchmark auto-debug with multiple attempts configured."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=5)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(dummy_func())
|
||||
|
||||
|
||||
class RetryContextBench:
|
||||
"""Benchmark RetryContext operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_context_creation(self) -> None:
|
||||
"""Benchmark creating a retry context."""
|
||||
RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
|
||||
def time_context_with_custom_wait(self) -> None:
|
||||
"""Benchmark context with custom wait strategy."""
|
||||
from tenacity import wait_exponential
|
||||
|
||||
RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
wait_strategy=wait_exponential(multiplier=1, min=1, max=30),
|
||||
)
|
||||
|
||||
def time_context_property_access(self) -> None:
|
||||
"""Benchmark accessing context properties."""
|
||||
ctx = RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
_ = ctx.operation_name
|
||||
_ = ctx.max_attempts
|
||||
_ = ctx.attempt_count
|
||||
|
||||
def time_context_execute(self) -> None:
|
||||
"""Benchmark executing a function via RetryContext."""
|
||||
ctx = RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
ctx.execute(lambda: "success")
|
||||
|
||||
|
||||
class IsReadOnlyPlanOperationBench:
|
||||
"""Benchmark is_read_only_plan_operation utility."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_check_strategize(self) -> None:
|
||||
"""Benchmark checking strategize operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "strategize"})
|
||||
|
||||
def time_check_plan(self) -> None:
|
||||
"""Benchmark checking plan operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "plan"})
|
||||
|
||||
def time_check_validate(self) -> None:
|
||||
"""Benchmark checking validate operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "validate"})
|
||||
|
||||
def time_check_review(self) -> None:
|
||||
"""Benchmark checking review operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "review"})
|
||||
|
||||
def time_check_preview(self) -> None:
|
||||
"""Benchmark checking preview operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "preview"})
|
||||
|
||||
def time_check_check(self) -> None:
|
||||
"""Benchmark checking check operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "check"})
|
||||
|
||||
def time_check_execute(self) -> None:
|
||||
"""Benchmark checking execute operation (not read-only)."""
|
||||
is_read_only_plan_operation({"plan_phase": "execute"})
|
||||
|
||||
def time_check_read_only_flag(self) -> None:
|
||||
"""Benchmark checking read_only flag."""
|
||||
is_read_only_plan_operation({"read_only": True})
|
||||
|
||||
def time_check_unknown_operation(self) -> None:
|
||||
"""Benchmark checking unknown operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "unknown_op"})
|
||||
|
||||
def time_check_empty_kwargs(self) -> None:
|
||||
"""Benchmark checking empty kwargs."""
|
||||
is_read_only_plan_operation({})
|
||||
|
||||
|
||||
class RetryServiceOperationStrategyBench:
|
||||
"""Benchmark different retry strategies in service operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_exponential_strategy(self) -> None:
|
||||
"""Benchmark exponential strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="exponential",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_linear_strategy(self) -> None:
|
||||
"""Benchmark linear strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="linear",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_fixed_strategy(self) -> None:
|
||||
"""Benchmark fixed strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="fixed",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_jitter_strategy(self) -> None:
|
||||
"""Benchmark jitter strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="jitter",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
@@ -57,35 +57,32 @@ inherits. Represents a generic container execution environment.
|
||||
| Child types | (none) |
|
||||
| Handler | `DevcontainerHandler` |
|
||||
|
||||
## Auto-Discovery (Planned)
|
||||
## Auto-Discovery
|
||||
|
||||
> **Not yet wired (F31/F23):** The discovery module
|
||||
> (`discover_devcontainers()`) exists and is tested in isolation, but
|
||||
> is **not** invoked during `project link-resource` or any other
|
||||
> production code path. Auto-discovery will be wired in a follow-up PR.
|
||||
> For now, devcontainer-instance resources must be added manually via
|
||||
> `agents resource add`.
|
||||
Auto-discovery is wired into `GitCheckoutHandler.discover_children()` and
|
||||
`FsDirectoryHandler.discover_children()` (issue #4740). When either handler
|
||||
scans a resource location, it calls `discover_devcontainers()` after
|
||||
discovering `fs-directory` children.
|
||||
|
||||
When wired, linking a `git-checkout` or `fs-directory` resource to a
|
||||
project will trigger an auto-discovery hook that scans for devcontainer
|
||||
configurations in the following locations (relative to the resource
|
||||
root):
|
||||
Devcontainer configurations are detected at the following locations
|
||||
(relative to the resource root):
|
||||
|
||||
1. `.devcontainer/devcontainer.json`
|
||||
2. `.devcontainer.json` (root-level)
|
||||
3. `.devcontainer/<name>/devcontainer.json` (named configurations)
|
||||
|
||||
### Discovery Process (Planned)
|
||||
### Discovery Process
|
||||
|
||||
1. **Trigger**: Linking a `git-checkout` or `fs-directory` resource.
|
||||
2. **Scan**: The discovery module checks for configuration files at the
|
||||
well-known paths listed above.
|
||||
1. **Trigger**: `discover_children()` is called on a `git-checkout` or
|
||||
`fs-directory` resource.
|
||||
2. **Scan**: `discover_devcontainers()` checks for configuration files at
|
||||
the well-known paths listed above.
|
||||
3. **Validate**: Each discovered file is parsed as JSON. Invalid files
|
||||
are skipped with a warning.
|
||||
4. **Register**: For each valid configuration:
|
||||
- A `devcontainer-instance` child resource is created under the
|
||||
parent resource.
|
||||
- A `devcontainer-file` child resource is created under the
|
||||
devcontainer instance, pointing to the JSON file.
|
||||
parent resource with `provisioning_state: discovered`.
|
||||
- Named configurations carry the subdirectory name as `config_name`.
|
||||
|
||||
### Lazy Activation
|
||||
|
||||
@@ -250,7 +247,7 @@ confirmation unless `--yes` (`-y`) is passed.
|
||||
| Registry eviction | Terminal-state trackers (stopped/failed) are evicted when count exceeds 200 (`_MAX_TERMINAL_TRACKERS`). Eviction runs automatically after bulk cleanup via `stop_all_active_containers`. Long-running processes with many container cycles may lose historical tracker data. | Acceptable for MVP; persisted state in M7+ eliminates this. |
|
||||
| Sandbox strategy (F22/F25) | Specification uses `container_snapshot`; `SandboxFactory` does not yet implement `snapshot`. Handler now uses `SandboxStrategy.NONE` — the container itself provides isolation. | Implement `container_snapshot` in `SandboxFactory` and switch handler back to it. |
|
||||
| Hardcoded `docker stop` (F21) | `stop_container()` calls `docker stop` directly. Devcontainer CLI can target Podman or other engines; the devcontainer CLI does not yet offer a `stop` subcommand. | Detect the container engine from resource properties or devcontainer CLI config and dispatch to the appropriate stop command. |
|
||||
| Auto-discovery not wired (F23) | Documentation describes auto-discovery on `project link-resource`, but no code path invokes `discover_devcontainers()` during resource linking. | Wire the discovery hook into the resource linking flow. |
|
||||
| Auto-discovery wired (F23 resolved) | `discover_devcontainers()` is now called from `GitCheckoutHandler.discover_children()` and `FsDirectoryHandler.discover_children()`. Devcontainer configs are auto-detected when `discover_children()` is invoked. | Completed in issue #4740. |
|
||||
| Container execution stubbed (F24) | `ToolRunner` returns an error for `ExecutionEnvironment.CONTAINER`, so lazy activation cannot trigger via tool use in production. Lazy activation currently works through `DevcontainerHandler.resolve()` on plan sandbox resolution. | Full container execution support is scoped for a follow-up PR (#616). |
|
||||
|
||||
## DevcontainerHandler Protocol Methods
|
||||
|
||||
@@ -45871,6 +45871,8 @@ The relational database follows a normalized design with foreign key constraints
|
||||
|
||||
4. **Optimistic concurrency control**: The `updated_at` timestamp column on mutable entities serves as a version check. Update operations include `WHERE updated_at = <previous_value>` to detect concurrent modifications. On conflict, the operation fails with an explicit error rather than silently overwriting.
|
||||
|
||||
6. **PlanResult Domain Model — `result_success` column** (migration `m9_003_plan_result_success_column`): The legacy `plans` table includes a dedicated `result_success BOOLEAN NULLABLE` column that explicitly records the success status of the result (apply) phase. `PlanRepository._to_domain` derives `PlanResult.success` from this column rather than from `error_message is None`. The `error_message` column is shared between the build phase and the result phase; using it to infer result success would incorrectly mark plans as failed when a build-phase error was recorded but the apply phase later succeeded. When `result_success` is `NULL` (pre-migration records), the repository falls back to the legacy `error_message is None` heuristic for backward compatibility.
|
||||
|
||||
5. **Datetime handling contract**: All timestamps stored in the database are UTC ISO 8601 strings (format: `YYYY-MM-DDTHH:MM:SS.ffffff`). All Python datetime comparisons involving stored timestamps MUST parse the stored string back to a timezone-aware `datetime` object before comparing — **never** compare ISO timestamp strings lexicographically. String comparison is incorrect because different timezone offset formats (`+00:00` vs `Z` vs `+05:30`) produce strings that are not lexicographically comparable even when representing the same moment. The canonical pattern is:
|
||||
|
||||
```python
|
||||
|
||||
@@ -152,6 +152,13 @@ end note
|
||||
| 2026-04-10 | D100 | — | v3.5.0 M6 | 100% | 16.9% | -83.1% | CRITICAL | Day 100: 210/1242 closed |
|
||||
| 2026-04-10 | D100 | — | v3.6.0 M7 | 100% | 33.9% | -66.1% | CRITICAL | Day 100: 152/448 closed |
|
||||
| 2026-04-10 | D100 | — | v3.7.0 M8 | — | 43.8% | — | HIGH | Day 100: 427/975 closed |
|
||||
| 2026-04-12 | D101 | — | v3.2.0 M3 | 100% | 27.8% | -72.2% | CRITICAL | Day 101: 258/926 closed, 664 open (+119) |
|
||||
| 2026-04-12 | D101 | — | v3.3.0 M4 | 100% | 47.0% | -53.0% | CRITICAL | Day 101: 108/230 closed, 122 open (+12) |
|
||||
| 2026-04-12 | D101 | — | v3.4.0 M5 | 100% | 40.2% | -59.8% | CRITICAL | Day 101: 137/341 closed, 204 open (+35) |
|
||||
| 2026-04-12 | D101 | — | v3.5.0 M6 | 100% | 17.0% | -83.0% | CRITICAL | Day 101: 201/1178 closed, 977 open (+135) |
|
||||
| 2026-04-12 | D101 | — | v3.6.0 M7 | 100% | 35.2% | -64.8% | CRITICAL | Day 101: 152/432 closed, 280 open (+48) |
|
||||
| 2026-04-12 | D101 | — | v3.7.0 M8 | — | 44.8% | — | HIGH | Day 101: 427/953 closed, 526 open (+28) |
|
||||
| 2026-04-12 | D101 | — | v3.8.0 M9 | — | 27.0% | — | HIGH | Day 101: 132/489 closed, 357 open (+29) |
|
||||
| 2026-04-13 | D103 | C2 | v3.2.0 M3 | 100% | 25.7% | -74.3% | CRITICAL | Cycle 2: 269/1045 closed, 776 open |
|
||||
| 2026-04-13 | D103 | C2 | v3.3.0 M4 | 100% | 42.4% | -57.6% | CRITICAL | Cycle 2: 109/257 closed, 148 open |
|
||||
| 2026-04-13 | D103 | C2 | v3.4.0 M5 | 100% | 37.4% | -62.6% | CRITICAL | Cycle 2: 139/372 closed, 233 open |
|
||||
|
||||
@@ -9,9 +9,10 @@ Feature: ACMS Index Data Model and File Traversal Engine
|
||||
|
||||
Scenario: Create an index entry with file metadata
|
||||
When I create an index entry with:
|
||||
| path | /project/src/main.py |
|
||||
| file_type | python |
|
||||
| size_bytes | 1024 |
|
||||
| key | value |
|
||||
| path | /project/src/main.py |
|
||||
| file_type | python |
|
||||
| size_bytes | 1024 |
|
||||
Then the index entry should have path "/project/src/main.py"
|
||||
And the index entry should have file type "python"
|
||||
And the index entry should have size 1024 bytes
|
||||
@@ -116,7 +117,7 @@ Feature: ACMS Index Data Model and File Traversal Engine
|
||||
Scenario: Get entry count from index
|
||||
Given I have an index with 10 entries
|
||||
When I get the entry count
|
||||
Then the count should be 10
|
||||
Then idx the index count should be 10
|
||||
|
||||
Scenario: Remove entry from index
|
||||
Given I have an index with 3 entries
|
||||
@@ -131,8 +132,9 @@ Feature: ACMS Index Data Model and File Traversal Engine
|
||||
| /project/tests/test_main.py | python | test | cold |
|
||||
| /project/docs/readme.md | markdown | docs | cold |
|
||||
When I query the index with filters:
|
||||
| path_pattern | src |
|
||||
| file_type | python |
|
||||
| tier | hot |
|
||||
| filter | value |
|
||||
| path_pattern | src |
|
||||
| file_type | python |
|
||||
| tier | hot |
|
||||
Then I should get 1 result
|
||||
And the result should have path "/project/src/main.py"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Feature: agents actor add NAME positional argument
|
||||
As a user of the CleverAgents CLI
|
||||
I want to pass the actor name as a positional argument to `agents actor add`
|
||||
So that the CLI matches the spec synopsis: agents actor add <NAME> --config <FILE>
|
||||
So that the CLI matches the spec synopsis: agents actor add --config <FILE> [<NAME>]
|
||||
|
||||
@tdd_issue @tdd_issue_4230 @tdd_expected_fail
|
||||
Scenario: actor add accepts NAME as positional argument
|
||||
@@ -17,8 +17,16 @@ Feature: agents actor add NAME positional argument
|
||||
When I run actor add with NAME positional argument overriding config name
|
||||
Then the actor add should use the positional NAME not the config name
|
||||
|
||||
Scenario: actor add without NAME positional argument fails
|
||||
@tdd_issue @tdd_issue_4186
|
||||
Scenario: actor add without NAME positional argument uses config name
|
||||
Given an actor CLI runner
|
||||
And I have an actor JSON config file with name "local/config-derived-actor"
|
||||
When I run actor add with config but no NAME positional argument
|
||||
Then the actor add should succeed using the config name
|
||||
|
||||
@tdd_issue @tdd_issue_4186
|
||||
Scenario: actor add without NAME and without config name field raises BadParameter
|
||||
Given an actor CLI runner
|
||||
And I have an actor JSON config file without a name field
|
||||
When I run actor add with config but no NAME positional argument
|
||||
Then the actor command should fail with missing argument error
|
||||
Then the actor add should fail with a BadParameter error about missing actor name
|
||||
|
||||
@@ -5,7 +5,7 @@ Feature: agents actor add enforces --update flag for existing actors
|
||||
I want `agents actor add` to fail with a clear error when re-adding an existing actor
|
||||
So that I cannot accidentally overwrite actor configurations without explicit intent
|
||||
|
||||
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
|
||||
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
|
||||
Scenario: Re-adding an existing actor without --update fails with error panel
|
||||
Given an actor add CLI runner where the actor already exists
|
||||
When I run actor add without the --update flag
|
||||
@@ -14,7 +14,7 @@ Feature: agents actor add enforces --update flag for existing actors
|
||||
And the actor-add-enforcement output should contain "Use --update to replace the existing actor definition"
|
||||
And the actor-add-enforcement output should contain the registration timestamp
|
||||
|
||||
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
|
||||
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
|
||||
Scenario: Re-adding an existing actor without --update shows error status line
|
||||
Given an actor add CLI runner where the actor already exists
|
||||
When I run actor add without the --update flag
|
||||
@@ -22,14 +22,14 @@ Feature: agents actor add enforces --update flag for existing actors
|
||||
And the actor-add-enforcement output should contain "Actor already registered"
|
||||
And the actor-add-enforcement output should contain "use --update to replace"
|
||||
|
||||
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
|
||||
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
|
||||
Scenario: Re-adding an existing actor with --update succeeds
|
||||
Given an actor add CLI runner where the actor already exists
|
||||
When I run actor add with the --update flag
|
||||
Then the actor-add-enforcement exit code should be 0
|
||||
And the actor-add-enforcement output should contain "Actor updated"
|
||||
|
||||
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
|
||||
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
|
||||
Scenario: Adding a new actor without --update succeeds
|
||||
Given an actor add CLI runner where the actor does not exist
|
||||
When I run actor add without the --update flag
|
||||
|
||||
@@ -34,6 +34,7 @@ Feature: Architecture validation
|
||||
Then all application variables should use CLEVERAGENTS_ prefix
|
||||
And provider variables should keep their original prefix
|
||||
|
||||
@tdd_issue @tdd_issue_4186
|
||||
Scenario: Type hints are used throughout
|
||||
Given the source code exists
|
||||
When I check for type annotations
|
||||
|
||||
@@ -99,3 +99,23 @@ Feature: Behave-parallel conditional log replay
|
||||
Given behave_parallel worker results with one crashed chunk and one passing chunk
|
||||
When behave_parallel I aggregate the worker results
|
||||
Then behave_parallel the aggregated summary should have failures
|
||||
|
||||
# ---- PassSuppressFormatter: per-scenario output suppression ----
|
||||
|
||||
Scenario: PassSuppressFormatter suppresses output for a passing scenario
|
||||
Given behave_parallel a PassSuppressFormatter backed by a captured stream
|
||||
When behave_parallel I simulate a passing scenario through the formatter
|
||||
Then behave_parallel the formatter real stream output should be empty
|
||||
|
||||
Scenario: PassSuppressFormatter emits full output for a failing scenario
|
||||
Given behave_parallel a PassSuppressFormatter backed by a captured stream
|
||||
When behave_parallel I simulate a failing scenario through the formatter
|
||||
Then behave_parallel the formatter real stream output should contain "Failing scenario"
|
||||
And behave_parallel the formatter real stream output should contain "I fail"
|
||||
And behave_parallel the formatter real stream output should contain "AssertionError"
|
||||
|
||||
Scenario: PassSuppressFormatter only shows failed scenarios in a mixed run
|
||||
Given behave_parallel a PassSuppressFormatter backed by a captured stream
|
||||
When behave_parallel I simulate one passing then one failing scenario through the formatter
|
||||
Then behave_parallel the formatter real stream output should not contain "Passing scenario"
|
||||
And behave_parallel the formatter real stream output should contain "Failing scenario"
|
||||
|
||||
@@ -1159,7 +1159,7 @@ Feature: Consolidated Domain Models
|
||||
|
||||
Scenario: CLI dict has required fields
|
||||
Given a session with some messages
|
||||
When I get the session CLI dict
|
||||
When I get the session model CLI dict
|
||||
Then the session cli dict should have key "session_summary"
|
||||
And the session cli dict should have key "token_usage"
|
||||
And the session cli dict session_summary should have key "id"
|
||||
@@ -1170,34 +1170,34 @@ Feature: Consolidated Domain Models
|
||||
|
||||
Scenario: CLI dict includes recent messages
|
||||
Given a session with some messages
|
||||
When I get the session CLI dict
|
||||
When I get the session model CLI dict
|
||||
Then the session cli dict should have key "recent_messages"
|
||||
And the session cli dict recent_messages text key should be "text"
|
||||
|
||||
|
||||
Scenario: CLI dict includes actor when set
|
||||
When I create a session with actor name "local/orchestrator"
|
||||
And I get the session CLI dict
|
||||
And I get the session model CLI dict
|
||||
Then the session cli dict session_summary should have key "actor"
|
||||
|
||||
|
||||
Scenario: CLI dict includes automation when set
|
||||
When I create a session with automation "review"
|
||||
And I get the session CLI dict
|
||||
And I get the session model CLI dict
|
||||
Then the session cli dict session_summary should have key "automation"
|
||||
And the session cli dict session_summary automation should be "review"
|
||||
|
||||
|
||||
Scenario: CLI dict linked_plans uses spec-compliant objects
|
||||
Given a session with linked plans
|
||||
When I get the session CLI dict
|
||||
When I get the session model CLI dict
|
||||
Then the session cli dict should have key "linked_plans"
|
||||
And the session cli dict linked_plans should contain plan_id field
|
||||
|
||||
|
||||
Scenario: CLI dict token_usage estimated_cost is formatted string
|
||||
Given a session with token usage cost 0.0184
|
||||
When I get the session CLI dict
|
||||
When I get the session model CLI dict
|
||||
Then the session cli dict token_usage estimated_cost should be a string starting with "$"
|
||||
|
||||
# ---- Empty Session Properties ----
|
||||
|
||||
@@ -352,24 +352,24 @@ Feature: Consolidated Plan Model Lifecycle
|
||||
|
||||
|
||||
Scenario: New plan starts in STRATEGIZE with QUEUED processing state
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan phase should be "strategize"
|
||||
And the plan processing state should be "queued"
|
||||
|
||||
|
||||
Scenario: Strategize phase uses ProcessingState
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan processing state should be "queued"
|
||||
|
||||
|
||||
Scenario: Plan in strategize phase defaults to QUEUED processing state
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan processing state should be "queued"
|
||||
And the plan state should be "queued"
|
||||
|
||||
|
||||
Scenario: Strategize phase defaults processing_state to QUEUED
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan processing state should be "queued"
|
||||
|
||||
# Plan Identity Tests
|
||||
@@ -401,12 +401,12 @@ Feature: Consolidated Plan Model Lifecycle
|
||||
|
||||
|
||||
Scenario: Plan in STRATEGIZE with QUEUED cannot transition
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan cannot transition to next phase
|
||||
|
||||
|
||||
Scenario: New plan in STRATEGIZE with QUEUED cannot transition
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan cannot transition to next phase
|
||||
|
||||
|
||||
@@ -455,17 +455,17 @@ Feature: Consolidated Plan Model Lifecycle
|
||||
|
||||
|
||||
Scenario: Plan in errored state is_errored returns True
|
||||
Given I create a plan in strategize phase with errored state
|
||||
Given I create a PlanModel in strategize phase with errored state
|
||||
Then the plan should be errored
|
||||
|
||||
|
||||
Scenario: Plan in processing state is_errored returns False
|
||||
Given I create a plan in strategize phase with processing state
|
||||
Given I create a PlanModel in strategize phase with processing state
|
||||
Then the plan should not be errored
|
||||
|
||||
|
||||
Scenario: Cancelled plan is terminal
|
||||
Given I create a plan in strategize phase with cancelled state
|
||||
Given I create a PlanModel in strategize phase with cancelled state
|
||||
Then the plan should be terminal
|
||||
|
||||
# Model Validation Tests
|
||||
|
||||
@@ -401,3 +401,32 @@ Feature: Decision recording and snapshot store
|
||||
And I record a strategy_choice decision for plan "P1" with question "After restart"
|
||||
Then the dsvc decision sequence number should be 2
|
||||
And the dsvc next sequence for plan "P1" should be 3
|
||||
|
||||
# --- Full context snapshot (issue #9056) ---
|
||||
|
||||
Scenario: Strategize phase records decisions with full context snapshots
|
||||
Given a plan lifecycle service with decision service wired
|
||||
And an action "local/test-action" for strategize snapshot test
|
||||
And a plan created from "local/test-action" with project "proj-snapshot"
|
||||
When I start strategize for the snapshot test plan
|
||||
Then the strategize decision should have a non-empty hot_context_hash
|
||||
And the strategize decision should have a non-empty hot_context_ref
|
||||
And the strategize decision hot_context_ref should start with "plan:"
|
||||
And the strategize decision should have a non-empty actor_state_ref
|
||||
And the strategize decision should have relevant_resources populated
|
||||
|
||||
Scenario: Strategize context snapshot hash is content-addressable
|
||||
Given a plan lifecycle service with decision service wired
|
||||
And an action "local/test-action-hash" for strategize snapshot test
|
||||
And a plan created from "local/test-action-hash" with project "proj-hash"
|
||||
When I start strategize for the snapshot test plan
|
||||
Then the strategize decision hot_context_hash should start with "sha256:"
|
||||
And the strategize decision hot_context_hash should be 71 characters long
|
||||
|
||||
Scenario: Strategize context snapshot without projects has empty relevant_resources
|
||||
Given a plan lifecycle service with decision service wired
|
||||
And an action "local/no-project-action" for strategize snapshot test
|
||||
And a plan created from "local/no-project-action" without projects
|
||||
When I start strategize for the snapshot test plan
|
||||
Then the strategize decision should have a non-empty hot_context_hash
|
||||
And the strategize decision should have empty relevant_resources
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
Feature: Devcontainer auto-discovery wired into git-checkout and fs-directory handlers
|
||||
As a CleverAgents user
|
||||
I want devcontainer configurations to be automatically discovered
|
||||
When I register a git-checkout or fs-directory resource
|
||||
So that devcontainer-instance child resources are created without manual intervention
|
||||
|
||||
Scenario: git-checkout discover_children finds root devcontainer config
|
||||
Given dcwire a git repo with a ".devcontainer/devcontainer.json" file
|
||||
When dcwire I call discover_children on the git-checkout resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
And dcwire the devcontainer child has provisioning_state "discovered"
|
||||
And dcwire the devcontainer child has devcontainer_json_path set
|
||||
|
||||
Scenario: git-checkout discover_children finds named devcontainer config
|
||||
Given dcwire a git repo with a ".devcontainer/api/devcontainer.json" named config
|
||||
When dcwire I call discover_children on the git-checkout resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-api"
|
||||
And dcwire the devcontainer child has config_name "api"
|
||||
|
||||
Scenario: git-checkout discover_children with no devcontainer returns only directories
|
||||
Given dcwire a git repo with no devcontainer configuration
|
||||
When dcwire I call discover_children on the git-checkout resource
|
||||
Then dcwire no devcontainer-instance children are present
|
||||
|
||||
Scenario: git-checkout discover_children includes both fs-directory and devcontainer children
|
||||
Given dcwire a git repo with a subdirectory "src" and a ".devcontainer/devcontainer.json" file
|
||||
When dcwire I call discover_children on the git-checkout resource
|
||||
Then dcwire the children include a "fs-directory" resource named "src"
|
||||
And dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
|
||||
Scenario: fs-directory discover_children finds root devcontainer config
|
||||
Given dcwire a filesystem directory with a ".devcontainer/devcontainer.json" file
|
||||
When dcwire I call discover_children on the fs-directory resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
And dcwire the devcontainer child has provisioning_state "discovered"
|
||||
|
||||
Scenario: fs-directory discover_children finds named devcontainer config
|
||||
Given dcwire a filesystem directory with a ".devcontainer/frontend/devcontainer.json" named config
|
||||
When dcwire I call discover_children on the fs-directory resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-frontend"
|
||||
And dcwire the devcontainer child has config_name "frontend"
|
||||
|
||||
Scenario: fs-directory discover_children with no devcontainer returns only directories
|
||||
Given dcwire a filesystem directory with no devcontainer configuration
|
||||
When dcwire I call discover_children on the fs-directory resource
|
||||
Then dcwire no devcontainer-instance children are present
|
||||
|
||||
Scenario: fs-directory discover_children includes both fs-directory and devcontainer children
|
||||
Given dcwire a filesystem directory with a subdirectory "lib" and a ".devcontainer/devcontainer.json" file
|
||||
When dcwire I call discover_children on the fs-directory resource
|
||||
Then dcwire the children include a "fs-directory" resource named "lib"
|
||||
And dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
|
||||
Scenario: git-checkout discover_children finds root-level .devcontainer.json
|
||||
Given dcwire a git repo with a root-level ".devcontainer.json" file
|
||||
When dcwire I call discover_children on the git-checkout resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
|
||||
Scenario: fs-directory discover_children finds root-level .devcontainer.json
|
||||
Given dcwire a filesystem directory with a root-level ".devcontainer.json" file
|
||||
When dcwire I call discover_children on the fs-directory resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
@@ -180,19 +180,19 @@ Feature: Plan edge case scenarios
|
||||
|
||||
Scenario: Plan model rejects empty description
|
||||
When I try to create an edge case plan with empty description
|
||||
Then a Pydantic validation error should be raised
|
||||
Then an Edge Case Pydantic validation error should be raised
|
||||
|
||||
Scenario: Plan model rejects invalid phase value
|
||||
When I try to create an edge case plan with invalid phase value
|
||||
Then a Pydantic validation error should be raised
|
||||
Then an Edge Case Pydantic validation error should be raised
|
||||
|
||||
Scenario: NamespacedName rejects invalid characters in namespace
|
||||
When I try to parse a namespaced name with special characters "inv@lid/action"
|
||||
Then a Pydantic validation error should be raised
|
||||
Then an Edge Case Pydantic validation error should be raised
|
||||
|
||||
Scenario: NamespacedName rejects invalid characters in name
|
||||
When I try to parse a namespaced name with special chars in name "local/my action!"
|
||||
Then a Pydantic validation error should be raised
|
||||
Then an Edge Case Pydantic validation error should be raised
|
||||
|
||||
# ──────────────────────────────────────────────────
|
||||
# Section 4: Rollback edge cases
|
||||
|
||||
+21
-8
@@ -1,6 +1,7 @@
|
||||
"""Behave environment setup for feature tests."""
|
||||
|
||||
import contextlib
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -335,13 +336,13 @@ def before_all(context):
|
||||
# Use per-process unique database paths so parallel test subprocesses
|
||||
# (behave-parallel) never contend on the same SQLite file.
|
||||
if "CLEVERAGENTS_DATABASE_URL" not in os.environ:
|
||||
os.environ["CLEVERAGENTS_DATABASE_URL"] = (
|
||||
f"sqlite:///{tempfile.mktemp(suffix='.db', prefix='cleveragents_')}"
|
||||
)
|
||||
_fd, _db_path = tempfile.mkstemp(suffix=".db", prefix="cleveragents_")
|
||||
os.close(_fd)
|
||||
os.environ["CLEVERAGENTS_DATABASE_URL"] = f"sqlite:///{_db_path}"
|
||||
if "CLEVERAGENTS_TEST_DATABASE_URL" not in os.environ:
|
||||
os.environ["CLEVERAGENTS_TEST_DATABASE_URL"] = (
|
||||
f"sqlite:///{tempfile.mktemp(suffix='.db', prefix='cleveragents_test_')}"
|
||||
)
|
||||
_fd, _db_path = tempfile.mkstemp(suffix=".db", prefix="cleveragents_test_")
|
||||
os.close(_fd)
|
||||
os.environ["CLEVERAGENTS_TEST_DATABASE_URL"] = f"sqlite:///{_db_path}"
|
||||
os.environ.setdefault("BEHAVE_TESTING", "true")
|
||||
|
||||
# Set up mock AI provider for all tests
|
||||
@@ -453,8 +454,15 @@ def _ensure_template_db() -> None:
|
||||
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
|
||||
return
|
||||
|
||||
lock_path = template_path.with_suffix(".db.lock")
|
||||
_lock_fd = -1
|
||||
try:
|
||||
# Import the template creation script
|
||||
_lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
|
||||
fcntl.flock(_lock_fd, fcntl.LOCK_EX)
|
||||
if template_path.is_file():
|
||||
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
|
||||
return
|
||||
|
||||
scripts_dir = Path(__file__).parent.parent / "scripts"
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
from create_template_db import create_template
|
||||
@@ -463,6 +471,10 @@ def _ensure_template_db() -> None:
|
||||
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
|
||||
except Exception:
|
||||
pass # Fall back to normal Alembic migrations
|
||||
finally:
|
||||
if _lock_fd >= 0:
|
||||
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
|
||||
os.close(_lock_fd)
|
||||
|
||||
|
||||
def _install_template_db_patch() -> None:
|
||||
@@ -636,7 +648,8 @@ def before_scenario(context, scenario):
|
||||
("CLEVERAGENTS_DATABASE_URL", "cleveragents_"),
|
||||
("CLEVERAGENTS_TEST_DATABASE_URL", "cleveragents_test_"),
|
||||
):
|
||||
db_path = tempfile.mktemp(suffix=".db", prefix=prefix)
|
||||
_fd, db_path = tempfile.mkstemp(suffix=".db", prefix=prefix)
|
||||
os.close(_fd)
|
||||
os.environ[env_var] = f"sqlite:///{db_path}"
|
||||
context._scenario_db_paths.append(db_path)
|
||||
|
||||
|
||||
@@ -46,6 +46,31 @@ Feature: Execute-phase context assembler coverage
|
||||
When epcov I check path matching for "src/foo.py" with exclude "src/secret*"
|
||||
Then epcov the path should match
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: epcov path matches absolute path against relative include glob
|
||||
When epcov I check path matching for "/app/.opencode/skills/SKILL.md" with include ".opencode/**"
|
||||
Then epcov the path should match
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: epcov path matches absolute path against relative exclude glob
|
||||
When epcov I check path matching for "/app/.opencode/skills/SKILL.md" with exclude ".opencode/**"
|
||||
Then epcov the path should not match
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: epcov path matches absolute path against relative include glob with wildcard
|
||||
When epcov I check path matching for "/app/docs/readme.md" with include "docs/*"
|
||||
Then epcov the path should match
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: epcov path matches absolute path not matching relative include glob
|
||||
When epcov I check path matching for "/app/src/main.py" with include "docs/*"
|
||||
Then epcov the path should not match
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: epcov relative path is excluded by trailing ** glob
|
||||
When epcov I check path matching for "build/debug/output.log" with exclude "build/**"
|
||||
Then epcov the path should not match
|
||||
|
||||
# ---- _resource_matches static method ----
|
||||
|
||||
Scenario: epcov resource matches with no rules passes all
|
||||
|
||||
@@ -32,7 +32,7 @@ Feature: PlanExecutor Coverage Boost
|
||||
Scenario: _try_rollback_to_last_checkpoint returns False when no sandbox is resolvable
|
||||
Given a PlanExecutor with a checkpoint manager but no sandbox source
|
||||
When I attempt to rollback to the last checkpoint
|
||||
Then the rollback result should be False
|
||||
Then the executor rollback result should be False
|
||||
|
||||
# --- _resolve_sandbox_for_checkpoint via execution_context (line 458) ---
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Feature: Plan explain and decision tree CLI commands
|
||||
Given a test decision for explain
|
||||
When I format the explain dict as json
|
||||
Then the json output should contain "decision_id"
|
||||
And the json output should be valid json
|
||||
And the plan explain json output should be valid
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# plan explain - yaml format
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
Feature: Plugin Loader Coverage Boost
|
||||
Scenarios targeting uncovered lines in the PluginLoader class:
|
||||
- Lines 203-209: entry point load failure exception handler
|
||||
- Lines 242, 244-246: validate_protocol fallback to issubclass when instantiation fails
|
||||
- Lines 247-248: validate_protocol issubclass raises TypeError
|
||||
- validate_protocol: issubclass succeeds (class satisfies protocol structurally)
|
||||
- validate_protocol: issubclass raises TypeError with unverifiable protocol
|
||||
- validate_protocol: issubclass returns False (class missing required members)
|
||||
|
||||
Background:
|
||||
Given the plugin loader module is imported
|
||||
@@ -18,17 +19,17 @@ Feature: Plugin Loader Coverage Boost
|
||||
And the failed entry point should have been logged as a warning
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# validate_protocol: instantiation fails, issubclass succeeds (lines 242, 244-246)
|
||||
# validate_protocol: issubclass succeeds for class satisfying protocol
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: validate_protocol falls back to issubclass when instantiation fails
|
||||
Scenario: validate_protocol returns True when class satisfies protocol via issubclass
|
||||
Given I have a class that requires constructor arguments
|
||||
And I have a runtime checkable protocol the class satisfies via issubclass
|
||||
When I call validate_protocol with the non-instantiable class and protocol
|
||||
Then validate_protocol should return True
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# validate_protocol: instantiation fails, issubclass raises TypeError (lines 247-248)
|
||||
# validate_protocol: issubclass raises TypeError for unverifiable protocol
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: validate_protocol raises ProtocolMismatchError when issubclass raises TypeError
|
||||
@@ -38,7 +39,7 @@ Feature: Plugin Loader Coverage Boost
|
||||
Then a plugin-loader ProtocolMismatchError should be raised
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# validate_protocol: instantiation fails, issubclass returns False
|
||||
# validate_protocol: issubclass returns False (class missing required members)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: validate_protocol raises ProtocolMismatchError when issubclass returns False
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
@mock_only
|
||||
Feature: PR Compliance Checklist in Implementation Pool Supervisor
|
||||
|
||||
As a pool supervisor
|
||||
I want to pass a mandatory PR compliance checklist to every worker prompt
|
||||
So that implementation workers complete all required items before creating a PR and avoid systemic merge blockers
|
||||
|
||||
Background:
|
||||
Given the implementation-pool-supervisor.md agent definition exists
|
||||
|
||||
Scenario: Pool supervisor worker prompt includes the PR compliance checklist
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes the PR compliance checklist section
|
||||
And Pool: the checklist is marked as MANDATORY
|
||||
|
||||
Scenario: Checklist item 1 — CHANGELOG.md update required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a CHANGELOG.md checklist item
|
||||
And Pool: the item instructs workers to add an entry under the Unreleased section
|
||||
|
||||
Scenario: Checklist item 2 — CONTRIBUTORS.md update required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a CONTRIBUTORS.md checklist item
|
||||
And Pool: the item instructs workers to add or update their contribution entry
|
||||
|
||||
Scenario: Checklist item 3 — commit footer required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a commit footer checklist item
|
||||
And Pool: the item specifies the ISSUES CLOSED footer format
|
||||
|
||||
Scenario: Checklist item 4 — CI must pass before PR creation
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a CI passes checklist item
|
||||
And Pool: the item instructs workers to verify all quality gates are green
|
||||
|
||||
Scenario: Checklist item 5 — BDD/Behave tests required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a BDD tests checklist item
|
||||
And Pool: the item instructs workers to add or update Behave feature files
|
||||
|
||||
Scenario: Checklist item 6 — Epic reference required in PR description
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes an Epic reference checklist item
|
||||
And Pool: the item instructs workers to reference the parent Epic issue number
|
||||
|
||||
Scenario: Checklist item 7 — Labels must be applied
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a labels checklist item
|
||||
And Pool: the item instructs workers to apply labels via forgejo-label-manager
|
||||
|
||||
Scenario: Checklist item 8 — Milestone must be assigned
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a milestone checklist item
|
||||
And Pool: the item instructs workers to assign the earliest open milestone
|
||||
|
||||
Scenario: All 8 checklist items are present in the worker prompt
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body contains all 8 mandatory checklist items
|
||||
@@ -29,3 +29,10 @@ Feature: Project context phase analysis summaries
|
||||
When I compute project context phase analysis with budget 1000
|
||||
Then execute phase should have fewer tokens than strategize phase
|
||||
And apply phase should have fewer or equal tokens than execute phase
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: Absolute path fragments are correctly excluded by relative exclude globs
|
||||
Given a phase analysis policy with opencode exclude paths
|
||||
And an absolute path fragment for phase analysis
|
||||
When I compute project context phase analysis with budget 2000
|
||||
Then strategize phase should exclude the absolute path fragment
|
||||
|
||||
@@ -125,7 +125,7 @@ Feature: Namespaced project repository operations
|
||||
|
||||
Scenario: Remove a non-existent link returns False
|
||||
When I remove a link with id "00000000000000000000000099"
|
||||
Then the remove result should be False
|
||||
Then the project repo remove result should be False
|
||||
|
||||
Scenario: Create link with read_only flag
|
||||
Given a namespaced project "local/ro-proj" exists in the repository
|
||||
|
||||
@@ -76,7 +76,7 @@ Feature: Retry Policy Wiring for Services
|
||||
@registry
|
||||
Scenario: Per-service policy defaults with config overrides
|
||||
Given I have Settings with retry_service_overrides setting plan_service max_attempts to 10
|
||||
When I create a ServiceRetryWiring from those Settings
|
||||
When I create a ServiceRetryWiring from those retry Settings
|
||||
Then the plan_service policy should have max_attempts 10
|
||||
|
||||
@registry
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
@scope_chain @extension_api @pr10658
|
||||
Feature: Pluggable Scope Chain Resolution API (ContextExtensionPoint)
|
||||
As a CleverAgents developer
|
||||
I want to plug in custom scope chain resolution strategies for ComponentResolver
|
||||
So that extension points across the system use consistent, swappable resolution algorithms
|
||||
|
||||
Background:
|
||||
Given a fresh ExtensionPoint resolver test context
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@scope_chain_extension @protocol_registration
|
||||
Scenario: ContextScopeChainProtocol is registered in extension catalog
|
||||
When the extension catalog lists all scope chain registration entries
|
||||
Then the "context.scope_chain" extension point should be present
|
||||
And the total count of spec-defined extension points should be 31
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@scope_chain_extension @protocol_attributes
|
||||
Scenario: ContextScopeChainProtocol defines required methods
|
||||
When a context scope chain protocol instance is inspected
|
||||
Then it should have a "scope_chain_name" property
|
||||
And it should have a "resolve()" method with component_type, scopes, and fallback parameters
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in chain still works without pluggable resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@scope_chain_extension @built_in_fallback
|
||||
Scenario: Built-in resolution works when no custom chain is set
|
||||
Given a ComponentResolver with only a global default registered
|
||||
When I resolve using the built-in scope chain (no custom)
|
||||
Then the resolved component should come from "global" scope
|
||||
|
||||
@scope_chain_extension @built_in_fallback
|
||||
Scenario: Built-in project override still takes precedence over global
|
||||
Given a ComponentResolver with global default and project override registered
|
||||
When I resolve for "my-project" using the built-in chain (no custom)
|
||||
Then the resolved component should come from "project" scope
|
||||
|
||||
@scope_chain_extension @built_in_fallback
|
||||
Scenario: Built-in plan override takes highest precedence
|
||||
Given a ComponentResolver with global, project, and plan overrides registered
|
||||
When I resolve for plan "my-plan" and project "my-project" using the built-in chain
|
||||
Then the resolved component should come from "plan" scope
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pluggable resolver integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@scope_chain_extension @pluggable_integration
|
||||
Scenario: set_scope_chain() allows a custom chain to override the default
|
||||
Given a ComponentResolver with global and project overrides registered
|
||||
When I set a custom scope chain via set_scope_chain() "global_first"
|
||||
And I resolve using the resolver
|
||||
Then the pluggable scope chain should have been invoked instead of built-in
|
||||
|
||||
@scope_chain_extension @pluggable_integration
|
||||
Scenario: Setting custom chain to None restores default behavior
|
||||
Given a ComponentResolver with a custom scope chain actively set
|
||||
When I set the scope chain to None to restore defaults
|
||||
And I resolve for project "my-project"
|
||||
Then resolution should fall back to the built-in plan > project > global chain
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom chain receives metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@scope_chain_extension @metadata_delivery
|
||||
Scenario: Custom scope chain receives scope metadata
|
||||
Given a ComponentResolver with a tracing custom scope chain set
|
||||
When I resolve a protocol that has registrations at plan, project, and global scopes
|
||||
Then the custom chain should receive all three scope entries as metadata
|
||||
|
||||
@scope_chain_extension @fallback_passthrough
|
||||
Scenario: Custom chain can delegate to built-in fallback
|
||||
Given a ComponentResolver with a global implementation registered
|
||||
When I resolve using a tracing custom chain that delegates to built-in fallback
|
||||
Then result component should match the global implementation
|
||||
And scope level should be "global"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@scope_chain_extension @edge_no_global
|
||||
Scenario: Custom chain raises when no registrations exist anywhere
|
||||
Given a ComponentResolver with no registrations and a custom chain set
|
||||
When I attempt to resolve an unregistered protocol
|
||||
Then ComponentNotFoundError should be raised
|
||||
|
||||
@scope_chain_extension @edge_multiple_set
|
||||
Scenario: Multiple set_scope_chain calls are all accepted
|
||||
Given a ComponentResolver
|
||||
And a custom scope chain "chain_a" is set
|
||||
When I set another custom scope chain "chain_b"
|
||||
Then only "chain_b" should be active for resolution
|
||||
|
||||
@scope_chain_extension @edge_ext_introspection
|
||||
Scenario: Extension point catalog includes the new scope chain registration
|
||||
Given an extension resolver with a registered component type
|
||||
When I list all registered extension points
|
||||
Then the scope chain entry should appear in the catalog
|
||||
And it should reference the ContextScopeChainExtension protocol
|
||||
@@ -361,7 +361,7 @@ def step_get_entry_count(context):
|
||||
context.entry_count = context.index.get_entry_count()
|
||||
|
||||
|
||||
@then("the count should be {count:d}")
|
||||
@then("idx the index count should be {count:d}")
|
||||
def step_check_entry_count_value(context, count):
|
||||
"""Verify the entry count matches the expected value."""
|
||||
assert context.entry_count == count
|
||||
|
||||
@@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.cli.commands.actor import app as actor_app
|
||||
from cleveragents.core.exceptions import NotFoundError
|
||||
from cleveragents.domain.models.core.actor import Actor
|
||||
|
||||
|
||||
@@ -75,6 +76,22 @@ def step_impl(context: Any) -> None:
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
|
||||
@given("I have an actor JSON config file with name {name}")
|
||||
def step_impl(context: Any, name: str) -> None:
|
||||
context.actor_config_data = {
|
||||
"name": name,
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o-mini",
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", mode="w", encoding="utf-8"
|
||||
) as handle:
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
context.actor_config_path = Path(handle.name)
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
|
||||
@when("I run actor add with NAME positional argument and config")
|
||||
def step_impl(context: Any) -> None:
|
||||
context.positional_name = "local/my-actor"
|
||||
@@ -117,8 +134,14 @@ def step_impl(context: Any) -> None:
|
||||
|
||||
@when("I run actor add with config but no NAME positional argument")
|
||||
def step_impl(context: Any) -> None:
|
||||
config_name = context.actor_config_data.get("name", "local/default-actor")
|
||||
mock_actor = _make_actor(name=config_name)
|
||||
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
|
||||
registry = MagicMock()
|
||||
registry.get_actor.side_effect = NotFoundError(
|
||||
f"Actor not found: {config_name}"
|
||||
)
|
||||
registry.upsert_actor.return_value = mock_actor
|
||||
mock_svc.return_value = (MagicMock(), registry)
|
||||
context.result = context.runner.invoke(
|
||||
actor_app,
|
||||
@@ -128,6 +151,7 @@ def step_impl(context: Any) -> None:
|
||||
str(context.actor_config_path),
|
||||
],
|
||||
)
|
||||
context.mock_registry = registry
|
||||
|
||||
|
||||
@then("the actor add should succeed with the positional name")
|
||||
@@ -175,3 +199,34 @@ def step_impl(context: Any) -> None:
|
||||
f"Expected non-zero exit_code, got {context.result.exit_code}.\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor add should succeed using the config name")
|
||||
def step_impl(context: Any) -> None:
|
||||
assert context.result.exit_code == 0, (
|
||||
f"Expected exit_code=0, got {context.result.exit_code}.\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
assert context.mock_registry.upsert_actor.called, (
|
||||
"Expected upsert_actor to be called on the registry"
|
||||
)
|
||||
call_kwargs = context.mock_registry.upsert_actor.call_args
|
||||
actual_name = call_kwargs.kwargs.get("name") or (
|
||||
call_kwargs.args[0] if call_kwargs.args else None
|
||||
)
|
||||
expected_name = context.actor_config_data.get("name")
|
||||
assert actual_name == expected_name, (
|
||||
f"Expected upsert_actor called with name={expected_name!r} (from config), "
|
||||
f"got name={actual_name!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor add should fail with a BadParameter error about missing actor name")
|
||||
def step_impl(context: Any) -> None:
|
||||
assert context.result.exit_code != 0, (
|
||||
f"Expected non-zero exit_code, got {context.result.exit_code}.\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
assert "Actor name is required" in context.result.output, (
|
||||
f"Expected 'Actor name is required' in output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
@@ -107,7 +107,7 @@ def step_when_add_without_update(context: Any) -> None:
|
||||
mock_svc.return_value = (MagicMock(), registry)
|
||||
context.result = context.runner.invoke(
|
||||
actor_app,
|
||||
["add", "--config", str(context.actor_config_path)],
|
||||
["add", context.actor_name, "--config", str(context.actor_config_path)],
|
||||
)
|
||||
|
||||
|
||||
@@ -122,7 +122,13 @@ def step_when_add_with_update(context: Any) -> None:
|
||||
mock_svc.return_value = (MagicMock(), registry)
|
||||
context.result = context.runner.invoke(
|
||||
actor_app,
|
||||
["add", "--config", str(context.actor_config_path), "--update"],
|
||||
[
|
||||
"add",
|
||||
context.actor_name,
|
||||
"--config",
|
||||
str(context.actor_config_path),
|
||||
"--update",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Step definitions for behave_parallel_log_filtering.feature.
|
||||
|
||||
Tests the conditional log replay and worker exception handling in
|
||||
``scripts/run_behave_parallel.py``.
|
||||
``scripts/run_behave_parallel.py``, as well as the PassSuppressFormatter
|
||||
per-scenario output buffering behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,8 +13,11 @@ import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from behave.configuration import Configuration # type: ignore[import-untyped]
|
||||
from behave.formatter.base import StreamOpener # type: ignore[import-untyped]
|
||||
from behave.runner import Context # type: ignore[import-untyped]
|
||||
|
||||
|
||||
@@ -52,6 +56,7 @@ _empty_summary = _runner_mod._empty_summary
|
||||
_worker_run_features = _runner_mod._worker_run_features
|
||||
_aggregate_worker_results = _runner_mod._aggregate_worker_results
|
||||
_has_failures = _runner_mod._has_failures
|
||||
PassSuppressFormatter = _runner_mod.PassSuppressFormatter
|
||||
|
||||
|
||||
def _passing_summary(features: int = 1, scenarios: int = 1) -> Summary:
|
||||
@@ -357,3 +362,132 @@ def step_worker_summary_features_errors_1(context: Context) -> None:
|
||||
f"Expected features.errors=1 so _has_failures() detects partial crash, "
|
||||
f"got: {errors}"
|
||||
)
|
||||
|
||||
|
||||
# ---- PassSuppressFormatter helpers ----
|
||||
|
||||
|
||||
class _MockStatus:
|
||||
"""Minimal status object mirroring behave's Status enum interface."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
|
||||
class _MockScenario:
|
||||
"""Minimal scenario object for PassSuppressFormatter unit tests."""
|
||||
|
||||
def __init__(self, name: str, status_name: str) -> None:
|
||||
self.keyword = "Scenario"
|
||||
self.name = name
|
||||
self.status = _MockStatus(status_name)
|
||||
|
||||
|
||||
class _MockStep:
|
||||
"""Minimal step object for PassSuppressFormatter unit tests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
keyword: str,
|
||||
name: str,
|
||||
status_name: str,
|
||||
error_message: str | None,
|
||||
) -> None:
|
||||
self.keyword = keyword
|
||||
self.name = name
|
||||
self.status = _MockStatus(status_name)
|
||||
self.error_message = error_message
|
||||
|
||||
|
||||
def _make_pass_suppress_formatter() -> tuple[Any, io.StringIO]:
|
||||
"""Create a PassSuppressFormatter writing to a fresh StringIO buffer.
|
||||
|
||||
Returns ``(formatter, buffer)`` so callers can inspect what was written
|
||||
to the formatter's real output stream after simulating scenario events.
|
||||
"""
|
||||
buf: io.StringIO = io.StringIO()
|
||||
opener = StreamOpener(stream=buf)
|
||||
config = Configuration(command_args=["-q"])
|
||||
formatter: Any = PassSuppressFormatter(opener, config)
|
||||
return formatter, buf
|
||||
|
||||
|
||||
# ---- PassSuppressFormatter: Given ----
|
||||
|
||||
|
||||
@given("behave_parallel a PassSuppressFormatter backed by a captured stream")
|
||||
def step_create_pass_suppress_formatter(context: Context) -> None:
|
||||
formatter, buf = _make_pass_suppress_formatter()
|
||||
context.bp_formatter = formatter
|
||||
context.bp_formatter_stream = buf
|
||||
|
||||
|
||||
# ---- PassSuppressFormatter: When ----
|
||||
|
||||
|
||||
@when("behave_parallel I simulate a passing scenario through the formatter")
|
||||
def step_simulate_passing_scenario(context: Context) -> None:
|
||||
formatter: Any = context.bp_formatter
|
||||
scenario = _MockScenario("Passing scenario", "passed")
|
||||
step = _MockStep("Given", "I pass", "passed", None)
|
||||
formatter.scenario(scenario)
|
||||
formatter.result(step)
|
||||
formatter.eof()
|
||||
|
||||
|
||||
@when("behave_parallel I simulate a failing scenario through the formatter")
|
||||
def step_simulate_failing_scenario(context: Context) -> None:
|
||||
formatter: Any = context.bp_formatter
|
||||
scenario = _MockScenario("Failing scenario", "failed")
|
||||
step = _MockStep(
|
||||
"When",
|
||||
"I fail",
|
||||
"failed",
|
||||
"AssertionError: expected True got False",
|
||||
)
|
||||
formatter.scenario(scenario)
|
||||
formatter.result(step)
|
||||
formatter.eof()
|
||||
|
||||
|
||||
@when(
|
||||
"behave_parallel I simulate one passing then one failing scenario"
|
||||
" through the formatter"
|
||||
)
|
||||
def step_simulate_mixed_scenarios(context: Context) -> None:
|
||||
formatter: Any = context.bp_formatter
|
||||
# First: a passing scenario.
|
||||
passing_scenario = _MockScenario("Passing scenario", "passed")
|
||||
step1 = _MockStep("Given", "I pass", "passed", None)
|
||||
formatter.scenario(passing_scenario)
|
||||
formatter.result(step1)
|
||||
# Second: a failing scenario. Calling formatter.scenario() here finalises
|
||||
# the previous (passing) scenario, which should be discarded.
|
||||
failing_scenario = _MockScenario("Failing scenario", "failed")
|
||||
step2 = _MockStep("When", "I fail", "failed", "AssertionError: it broke")
|
||||
formatter.scenario(failing_scenario)
|
||||
formatter.result(step2)
|
||||
formatter.eof()
|
||||
|
||||
|
||||
# ---- PassSuppressFormatter: Then ----
|
||||
|
||||
|
||||
@then("behave_parallel the formatter real stream output should be empty")
|
||||
def step_formatter_output_empty(context: Context) -> None:
|
||||
output: str = context.bp_formatter_stream.getvalue()
|
||||
assert output == "", f"Expected empty output, got: {output!r}"
|
||||
|
||||
|
||||
@then('behave_parallel the formatter real stream output should contain "{text}"')
|
||||
def step_formatter_output_contains(context: Context, text: str) -> None:
|
||||
output: str = context.bp_formatter_stream.getvalue()
|
||||
assert text in output, f"Expected {text!r} in formatter output, got: {output!r}"
|
||||
|
||||
|
||||
@then('behave_parallel the formatter real stream output should not contain "{text}"')
|
||||
def step_formatter_output_not_contains(context: Context, text: str) -> None:
|
||||
output: str = context.bp_formatter_stream.getvalue()
|
||||
assert text not in output, (
|
||||
f"Expected {text!r} NOT in formatter output, got: {output!r}"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,8 @@ def _restore_cwd(context):
|
||||
os.environ.pop("CLEVERAGENTS_HOME", None)
|
||||
else:
|
||||
os.environ["CLEVERAGENTS_HOME"] = context._init_original_home
|
||||
shutil.rmtree(context.temp_dir, ignore_errors=True)
|
||||
if getattr(context, "temp_dir", None) is not None:
|
||||
shutil.rmtree(context.temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _create_init_mocks(context):
|
||||
|
||||
@@ -1105,3 +1105,146 @@ def step_try_store_duplicate(context: Context) -> None:
|
||||
def step_duplicate_error_raised(context: Context) -> None:
|
||||
assert context.decision_error is not None
|
||||
assert isinstance(context.decision_error, DuplicateDecisionError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full context snapshot steps (issue #9056)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a plan lifecycle service with decision service wired")
|
||||
def step_lifecycle_with_decision_service(context: Context) -> None:
|
||||
"""Create a PlanLifecycleService with a real DecisionService wired in."""
|
||||
from cleveragents.application.services.decision_service import DecisionService
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
context.snapshot_decision_service = DecisionService()
|
||||
context.snapshot_lifecycle_service = PlanLifecycleService(
|
||||
settings=settings,
|
||||
decision_service=context.snapshot_decision_service,
|
||||
)
|
||||
context.snapshot_plan = None
|
||||
context.snapshot_decision = None
|
||||
context.snapshot_action_name = None
|
||||
|
||||
|
||||
@given('an action "{action_name}" for strategize snapshot test')
|
||||
def step_create_action_for_snapshot_test(context: Context, action_name: str) -> None:
|
||||
"""Create an action for the strategize snapshot test."""
|
||||
context.snapshot_action_name = action_name
|
||||
context.snapshot_lifecycle_service.create_action(
|
||||
name=action_name,
|
||||
description=f"Action {action_name} for snapshot test",
|
||||
definition_of_done="Snapshot test done",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
)
|
||||
|
||||
|
||||
@given('a plan created from "{action_name}" with project "{project_name}"')
|
||||
def step_create_plan_with_project(
|
||||
context: Context, action_name: str, project_name: str
|
||||
) -> None:
|
||||
"""Create a plan from the given action with a project link."""
|
||||
from cleveragents.domain.models.core.plan import ProjectLink
|
||||
|
||||
context.snapshot_plan = context.snapshot_lifecycle_service.use_action(
|
||||
action_name=action_name,
|
||||
project_links=[ProjectLink(project_name=project_name)],
|
||||
)
|
||||
|
||||
|
||||
@given('a plan created from "{action_name}" without projects')
|
||||
def step_create_plan_without_projects(context: Context, action_name: str) -> None:
|
||||
"""Create a plan from the given action without any project links."""
|
||||
context.snapshot_plan = context.snapshot_lifecycle_service.use_action(
|
||||
action_name=action_name,
|
||||
project_links=[],
|
||||
)
|
||||
|
||||
|
||||
@when("I start strategize for the snapshot test plan")
|
||||
def step_start_strategize_snapshot_test(context: Context) -> None:
|
||||
"""Start strategize and capture the recorded decision."""
|
||||
plan_id = context.snapshot_plan.identity.plan_id
|
||||
context.snapshot_lifecycle_service.start_strategize(plan_id)
|
||||
|
||||
decisions = context.snapshot_decision_service.list_decisions(plan_id)
|
||||
assert len(decisions) >= 1, f"Expected at least 1 decision, got {len(decisions)}"
|
||||
strategy_decisions = [
|
||||
d for d in decisions if d.decision_type.value == "strategy_choice"
|
||||
]
|
||||
assert len(strategy_decisions) >= 1, "Expected at least 1 strategy_choice decision"
|
||||
context.snapshot_decision = strategy_decisions[0]
|
||||
|
||||
|
||||
@then("the strategize decision should have a non-empty hot_context_hash")
|
||||
def step_check_snapshot_hash_not_empty(context: Context) -> None:
|
||||
"""Verify the context snapshot hash is not empty."""
|
||||
snapshot = context.snapshot_decision.context_snapshot
|
||||
assert snapshot.hot_context_hash, "hot_context_hash should not be empty"
|
||||
|
||||
|
||||
@then("the strategize decision should have a non-empty hot_context_ref")
|
||||
def step_check_snapshot_ref_not_empty(context: Context) -> None:
|
||||
"""Verify the context snapshot ref is not empty."""
|
||||
snapshot = context.snapshot_decision.context_snapshot
|
||||
assert snapshot.hot_context_ref, "hot_context_ref should not be empty"
|
||||
|
||||
|
||||
@then('the strategize decision hot_context_ref should start with "{prefix}"')
|
||||
def step_check_snapshot_ref_prefix(context: Context, prefix: str) -> None:
|
||||
"""Verify the context snapshot ref starts with the expected prefix."""
|
||||
snapshot = context.snapshot_decision.context_snapshot
|
||||
assert snapshot.hot_context_ref.startswith(prefix), (
|
||||
f"hot_context_ref should start with {prefix!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the strategize decision should have a non-empty actor_state_ref")
|
||||
def step_check_snapshot_actor_ref_not_empty(context: Context) -> None:
|
||||
"""Verify the actor_state_ref is not empty."""
|
||||
snapshot = context.snapshot_decision.context_snapshot
|
||||
assert snapshot.actor_state_ref, "actor_state_ref should not be empty"
|
||||
|
||||
|
||||
@then("the strategize decision should have relevant_resources populated")
|
||||
def step_check_snapshot_resources_populated(context: Context) -> None:
|
||||
"""Verify relevant_resources is not empty."""
|
||||
snapshot = context.snapshot_decision.context_snapshot
|
||||
assert len(snapshot.relevant_resources) > 0, (
|
||||
"relevant_resources should not be empty"
|
||||
)
|
||||
|
||||
|
||||
@then('the strategize decision hot_context_hash should start with "{prefix}"')
|
||||
def step_check_snapshot_hash_prefix(context: Context, prefix: str) -> None:
|
||||
"""Verify the context snapshot hash starts with the expected prefix."""
|
||||
snapshot = context.snapshot_decision.context_snapshot
|
||||
assert snapshot.hot_context_hash.startswith(prefix), (
|
||||
f"hot_context_hash should start with {prefix!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the strategize decision hot_context_hash should be {length:d} characters long")
|
||||
def step_check_snapshot_hash_length(context: Context, length: int) -> None:
|
||||
"""Verify the context snapshot hash has the expected length."""
|
||||
snapshot = context.snapshot_decision.context_snapshot
|
||||
actual_length = len(snapshot.hot_context_hash)
|
||||
assert actual_length == length, (
|
||||
f"hot_context_hash length should be {length}, got {actual_length}"
|
||||
)
|
||||
|
||||
|
||||
@then("the strategize decision should have empty relevant_resources")
|
||||
def step_check_snapshot_resources_empty(context: Context) -> None:
|
||||
"""Verify relevant_resources is empty."""
|
||||
snapshot = context.snapshot_decision.context_snapshot
|
||||
assert len(snapshot.relevant_resources) == 0, (
|
||||
f"relevant_resources should be empty, got {snapshot.relevant_resources}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Step definitions for devcontainer_autodiscovery_wiring.feature.
|
||||
|
||||
Tests that discover_devcontainers() is correctly wired into
|
||||
GitCheckoutHandler.discover_children() and FsDirectoryHandler.discover_children().
|
||||
|
||||
All steps use the ``dcwire`` prefix to avoid Behave AmbiguousStep errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.domain.models.core.resource import PhysVirt, Resource
|
||||
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
|
||||
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
|
||||
|
||||
_VALID_ULID = "01JQDVHN5X5QJKBMZ3AP8Y4G7K"
|
||||
_DEVCONTAINER_JSON = json.dumps({"name": "Test Dev Container", "image": "ubuntu:22.04"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_git_resource(location: str) -> Resource:
|
||||
"""Create a minimal git-checkout Resource."""
|
||||
return Resource(
|
||||
resource_id=_VALID_ULID,
|
||||
name="test-repo",
|
||||
resource_type_name="git-checkout",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
description="Test git checkout resource",
|
||||
location=location,
|
||||
)
|
||||
|
||||
|
||||
def _make_fs_resource(location: str) -> Resource:
|
||||
"""Create a minimal fs-directory Resource."""
|
||||
return Resource(
|
||||
resource_id=_VALID_ULID,
|
||||
name="test-dir",
|
||||
resource_type_name="fs-directory",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
description="Test filesystem directory resource",
|
||||
location=location,
|
||||
)
|
||||
|
||||
|
||||
def _init_git_repo(tmpdir: str, extra_files: dict[str, str] | None = None) -> str:
|
||||
"""Initialise a bare-minimum git repo with an initial commit."""
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@dcwire.dev"],
|
||||
cwd=tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "DCWire Test"],
|
||||
cwd=tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "commit.gpgSign", "false"],
|
||||
cwd=tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
# Always create a README so there is at least one tracked file
|
||||
readme = Path(tmpdir) / "README.md"
|
||||
readme.write_text("# dcwire test\n")
|
||||
|
||||
if extra_files:
|
||||
for rel_path, file_content in extra_files.items():
|
||||
full = Path(tmpdir) / rel_path
|
||||
full.parent.mkdir(parents=True, exist_ok=True)
|
||||
full.write_text(file_content)
|
||||
|
||||
subprocess.run(["git", "add", "."], cwd=tmpdir, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "init"],
|
||||
cwd=tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return tmpdir
|
||||
|
||||
|
||||
def _create_devcontainer_dir(base: str, rel_path: str) -> None:
|
||||
"""Create a devcontainer.json at the given relative path inside base."""
|
||||
full = Path(base) / rel_path
|
||||
full.parent.mkdir(parents=True, exist_ok=True)
|
||||
full.write_text(_DEVCONTAINER_JSON)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GIVEN steps — git-checkout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('dcwire a git repo with a ".devcontainer/devcontainer.json" file')
|
||||
def step_dcwire_git_with_root_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
|
||||
_init_git_repo(tmpdir, {".devcontainer/devcontainer.json": _DEVCONTAINER_JSON})
|
||||
ctx.dcwire_handler = GitCheckoutHandler()
|
||||
ctx.dcwire_resource = _make_git_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given('dcwire a git repo with a ".devcontainer/api/devcontainer.json" named config')
|
||||
def step_dcwire_git_with_named_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-named-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/api/devcontainer.json")
|
||||
_init_git_repo(tmpdir, {".devcontainer/api/devcontainer.json": _DEVCONTAINER_JSON})
|
||||
ctx.dcwire_handler = GitCheckoutHandler()
|
||||
ctx.dcwire_resource = _make_git_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given("dcwire a git repo with no devcontainer configuration")
|
||||
def step_dcwire_git_no_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-nodc-")
|
||||
_init_git_repo(tmpdir)
|
||||
ctx.dcwire_handler = GitCheckoutHandler()
|
||||
ctx.dcwire_resource = _make_git_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given(
|
||||
'dcwire a git repo with a subdirectory "src" and a ".devcontainer/devcontainer.json" file'
|
||||
)
|
||||
def step_dcwire_git_with_src_and_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-src-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
|
||||
_init_git_repo(
|
||||
tmpdir,
|
||||
{
|
||||
"src/main.py": "print('hello')\n",
|
||||
".devcontainer/devcontainer.json": _DEVCONTAINER_JSON,
|
||||
},
|
||||
)
|
||||
ctx.dcwire_handler = GitCheckoutHandler()
|
||||
ctx.dcwire_resource = _make_git_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given('dcwire a git repo with a root-level ".devcontainer.json" file')
|
||||
def step_dcwire_git_with_root_level_devcontainer_json(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-rootdc-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer.json")
|
||||
_init_git_repo(tmpdir, {".devcontainer.json": _DEVCONTAINER_JSON})
|
||||
ctx.dcwire_handler = GitCheckoutHandler()
|
||||
ctx.dcwire_resource = _make_git_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GIVEN steps — fs-directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('dcwire a filesystem directory with a ".devcontainer/devcontainer.json" file')
|
||||
def step_dcwire_fs_with_root_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
|
||||
ctx.dcwire_handler = FsDirectoryHandler()
|
||||
ctx.dcwire_resource = _make_fs_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given(
|
||||
'dcwire a filesystem directory with a ".devcontainer/frontend/devcontainer.json" named config'
|
||||
)
|
||||
def step_dcwire_fs_with_named_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-named-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/frontend/devcontainer.json")
|
||||
ctx.dcwire_handler = FsDirectoryHandler()
|
||||
ctx.dcwire_resource = _make_fs_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given("dcwire a filesystem directory with no devcontainer configuration")
|
||||
def step_dcwire_fs_no_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-nodc-")
|
||||
ctx.dcwire_handler = FsDirectoryHandler()
|
||||
ctx.dcwire_resource = _make_fs_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given(
|
||||
'dcwire a filesystem directory with a subdirectory "lib" and a ".devcontainer/devcontainer.json" file'
|
||||
)
|
||||
def step_dcwire_fs_with_lib_and_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-lib-")
|
||||
lib_dir = Path(tmpdir) / "lib"
|
||||
lib_dir.mkdir()
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
|
||||
ctx.dcwire_handler = FsDirectoryHandler()
|
||||
ctx.dcwire_resource = _make_fs_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given('dcwire a filesystem directory with a root-level ".devcontainer.json" file')
|
||||
def step_dcwire_fs_with_root_level_devcontainer_json(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-rootdc-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer.json")
|
||||
ctx.dcwire_handler = FsDirectoryHandler()
|
||||
ctx.dcwire_resource = _make_fs_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHEN steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("dcwire I call discover_children on the git-checkout resource")
|
||||
def step_dcwire_discover_git(ctx):
|
||||
try:
|
||||
ctx.dcwire_result = ctx.dcwire_handler.discover_children(
|
||||
resource=ctx.dcwire_resource
|
||||
)
|
||||
except Exception as exc:
|
||||
ctx.dcwire_error = exc
|
||||
|
||||
|
||||
@when("dcwire I call discover_children on the fs-directory resource")
|
||||
def step_dcwire_discover_fs(ctx):
|
||||
try:
|
||||
ctx.dcwire_result = ctx.dcwire_handler.discover_children(
|
||||
resource=ctx.dcwire_resource
|
||||
)
|
||||
except Exception as exc:
|
||||
ctx.dcwire_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THEN steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('dcwire the children include a "devcontainer-instance" resource named "{name}"')
|
||||
def step_dcwire_has_devcontainer_child(ctx, name):
|
||||
assert ctx.dcwire_error is None, f"Unexpected error: {ctx.dcwire_error}"
|
||||
assert ctx.dcwire_result is not None, "Expected a list of children"
|
||||
dc_children = [
|
||||
r
|
||||
for r in ctx.dcwire_result
|
||||
if r.resource_type_name == "devcontainer-instance" and r.name == name
|
||||
]
|
||||
assert len(dc_children) == 1, (
|
||||
f"Expected exactly one devcontainer-instance named '{name}', "
|
||||
f"got {[r.name for r in ctx.dcwire_result if r.resource_type_name == 'devcontainer-instance']}"
|
||||
)
|
||||
ctx.dcwire_last_dc_child = dc_children[0]
|
||||
|
||||
|
||||
@then('dcwire the children include a "fs-directory" resource named "{name}"')
|
||||
def step_dcwire_has_fs_child(ctx, name):
|
||||
assert ctx.dcwire_error is None, f"Unexpected error: {ctx.dcwire_error}"
|
||||
assert ctx.dcwire_result is not None, "Expected a list of children"
|
||||
fs_children = [
|
||||
r
|
||||
for r in ctx.dcwire_result
|
||||
if r.resource_type_name == "fs-directory" and r.name == name
|
||||
]
|
||||
assert len(fs_children) == 1, (
|
||||
f"Expected exactly one fs-directory named '{name}', "
|
||||
f"got {[r.name for r in ctx.dcwire_result if r.resource_type_name == 'fs-directory']}"
|
||||
)
|
||||
|
||||
|
||||
@then('dcwire the devcontainer child has provisioning_state "{state}"')
|
||||
def step_dcwire_has_provisioning_state(ctx, state):
|
||||
assert ctx.dcwire_last_dc_child is not None, "No devcontainer child captured"
|
||||
props = ctx.dcwire_last_dc_child.properties or {}
|
||||
actual = props.get("provisioning_state")
|
||||
assert actual == state, f"Expected provisioning_state='{state}', got '{actual}'"
|
||||
|
||||
|
||||
@then("dcwire the devcontainer child has devcontainer_json_path set")
|
||||
def step_dcwire_has_json_path(ctx):
|
||||
assert ctx.dcwire_last_dc_child is not None, "No devcontainer child captured"
|
||||
props = ctx.dcwire_last_dc_child.properties or {}
|
||||
path_val = props.get("devcontainer_json_path")
|
||||
assert path_val, f"Expected devcontainer_json_path to be set, got {path_val!r}"
|
||||
assert "devcontainer.json" in path_val, (
|
||||
f"Expected devcontainer_json_path to contain 'devcontainer.json', got {path_val!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('dcwire the devcontainer child has config_name "{config_name}"')
|
||||
def step_dcwire_has_config_name(ctx, config_name):
|
||||
assert ctx.dcwire_last_dc_child is not None, "No devcontainer child captured"
|
||||
props = ctx.dcwire_last_dc_child.properties or {}
|
||||
actual = props.get("config_name")
|
||||
assert actual == config_name, (
|
||||
f"Expected config_name='{config_name}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then("dcwire no devcontainer-instance children are present")
|
||||
def step_dcwire_no_devcontainer_children(ctx):
|
||||
assert ctx.dcwire_error is None, f"Unexpected error: {ctx.dcwire_error}"
|
||||
assert ctx.dcwire_result is not None, "Expected a list of children"
|
||||
dc_children = [
|
||||
r for r in ctx.dcwire_result if r.resource_type_name == "devcontainer-instance"
|
||||
]
|
||||
assert len(dc_children) == 0, (
|
||||
f"Expected no devcontainer-instance children, got {[r.name for r in dc_children]}"
|
||||
)
|
||||
@@ -755,7 +755,7 @@ def step_try_create_plan_empty_desc(context: Context) -> None:
|
||||
context.pydantic_error = exc
|
||||
|
||||
|
||||
@then("a Pydantic validation error should be raised")
|
||||
@then("an Edge Case Pydantic validation error should be raised")
|
||||
def step_check_pydantic_error(context: Context) -> None:
|
||||
assert context.pydantic_error is not None, "Expected a Pydantic validation error"
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ def step_attempt_rollback(context):
|
||||
)
|
||||
|
||||
|
||||
@then("the rollback result should be False")
|
||||
@then("the executor rollback result should be False")
|
||||
def step_verify_rollback_false(context):
|
||||
"""Verify the rollback result is False."""
|
||||
assert context.rollback_result is False
|
||||
|
||||
@@ -331,7 +331,7 @@ def step_json_contains(context: Context, text: str) -> None:
|
||||
assert text in context.pe_json_output, f"Expected '{text}' in JSON output"
|
||||
|
||||
|
||||
@then("the json output should be valid json")
|
||||
@then("the plan explain json output should be valid")
|
||||
def step_json_valid(context: Context) -> None:
|
||||
parsed = json.loads(context.pe_json_output)
|
||||
assert isinstance(parsed, dict), "Expected a JSON object"
|
||||
|
||||
@@ -184,7 +184,7 @@ def step_create_plan_with_description(context: Context, description: str) -> Non
|
||||
context.plan = _default_plan(description=description)
|
||||
|
||||
|
||||
@given("I create a plan in strategize phase")
|
||||
@given("I create a PlanModel in strategize phase")
|
||||
def step_create_plan_strategize_phase(context: Context) -> None:
|
||||
"""Create a plan in STRATEGIZE phase."""
|
||||
context.plan = _default_plan(
|
||||
@@ -204,7 +204,7 @@ def step_create_plan_strategize_with_action_state(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@given("I create a plan in strategize phase with errored state")
|
||||
@given("I create a PlanModel in strategize phase with errored state")
|
||||
def step_create_plan_strategize_errored(context: Context) -> None:
|
||||
"""Create a plan in STRATEGIZE phase with ERRORED state."""
|
||||
context.plan = _default_plan(
|
||||
@@ -214,7 +214,7 @@ def step_create_plan_strategize_errored(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@given("I create a plan in strategize phase with processing state")
|
||||
@given("I create a PlanModel in strategize phase with processing state")
|
||||
def step_create_plan_strategize_processing(context: Context) -> None:
|
||||
"""Create a plan in STRATEGIZE phase with PROCESSING state."""
|
||||
context.plan = _default_plan(
|
||||
@@ -268,7 +268,7 @@ def step_create_plan_action_available(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@given("I create a plan in strategize phase with cancelled state")
|
||||
@given("I create a PlanModel in strategize phase with cancelled state")
|
||||
def step_create_plan_strategize_cancelled(context: Context) -> None:
|
||||
"""Create a plan in STRATEGIZE phase with CANCELLED processing state."""
|
||||
context.plan = _default_plan(
|
||||
|
||||
@@ -4,9 +4,9 @@ These steps target specific uncovered lines in
|
||||
cleveragents/infrastructure/plugins/loader.py:
|
||||
|
||||
- Lines 203-209: except block in load_from_entry_points when ep.load() fails
|
||||
- Lines 242, 244-246: validate_protocol fallback to issubclass on
|
||||
instantiation failure
|
||||
- Lines 247-248: validate_protocol when issubclass raises TypeError
|
||||
- validate_protocol: issubclass succeeds (class satisfies protocol structurally)
|
||||
- validate_protocol: issubclass raises TypeError for unverifiable protocol
|
||||
- validate_protocol: issubclass returns False (class missing required members)
|
||||
"""
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
@@ -72,13 +72,13 @@ def step_verify_warning_logged(context):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_protocol: instantiation fails, issubclass succeeds (lines 242, 244-246)
|
||||
# validate_protocol: issubclass succeeds for class satisfying protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _SampleProtocol(Protocol):
|
||||
"""A simple runtime-checkable Protocol for testing issubclass fallback."""
|
||||
"""A simple runtime-checkable Protocol for testing issubclass check."""
|
||||
|
||||
def do_work(self) -> str: ...
|
||||
|
||||
@@ -105,7 +105,7 @@ def step_protocol_satisfied_by_subclass(context):
|
||||
|
||||
@when("I call validate_protocol with the non-instantiable class and protocol")
|
||||
def step_call_validate_protocol_subclass_fallback(context):
|
||||
"""Call validate_protocol; expect it to fall back to issubclass and succeed."""
|
||||
"""Call validate_protocol; issubclass succeeds and returns True."""
|
||||
context.validate_result = PluginLoader.validate_protocol(
|
||||
context.non_instantiable_class,
|
||||
context.target_protocol,
|
||||
@@ -114,18 +114,18 @@ def step_call_validate_protocol_subclass_fallback(context):
|
||||
|
||||
@then("validate_protocol should return True")
|
||||
def step_verify_validate_true(context):
|
||||
"""The fallback issubclass check should have returned True."""
|
||||
"""The issubclass check should have returned True."""
|
||||
assert context.validate_result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_protocol: instantiation fails, issubclass raises TypeError (lines 247-248)
|
||||
# validate_protocol: issubclass raises TypeError for unverifiable protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("I have a class that cannot be instantiated without arguments")
|
||||
def step_class_cannot_instantiate(context):
|
||||
"""Create a class whose __init__ raises when called with no args."""
|
||||
"""Create a class whose __init__ requires mandatory arguments."""
|
||||
|
||||
class NeedsArgs:
|
||||
def __init__(self, required):
|
||||
@@ -141,10 +141,10 @@ def step_protocol_causes_typeerror(context):
|
||||
We need an object that:
|
||||
- Has a __name__ attribute (so the error message in validate_protocol works)
|
||||
- Causes issubclass() to raise TypeError when used as second arg
|
||||
- Also causes isinstance() to raise TypeError
|
||||
|
||||
A class with a metaclass that raises TypeError on __instancecheck__
|
||||
and __subclasscheck__ achieves this.
|
||||
A class with a metaclass that raises TypeError on __subclasscheck__
|
||||
achieves this. Since this protocol declares no members, the structural
|
||||
fallback cannot verify conformance and must raise ProtocolMismatchError.
|
||||
"""
|
||||
|
||||
class TypeErrorMeta(type):
|
||||
@@ -186,7 +186,7 @@ def step_verify_protocol_mismatch(context):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_protocol: instantiation fails, issubclass returns False
|
||||
# validate_protocol: issubclass returns False (class missing required members)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
AGENT_DEF_PATH = PROJECT_ROOT / ".opencode" / "agents" / "implementation-supervisor.md"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Step definitions for PR compliance checklist in implementation pool supervisor.
|
||||
|
||||
This file uses parameterized @then decorators with unique step text that
|
||||
distinguishes pool-supervisor checks from the shared compliance checklist
|
||||
steps in pr_compliance_checklist_steps.py, preventing Behave AmbiguousStep
|
||||
errors when both feature files are run together.
|
||||
|
||||
Each validator is imported from the shared pr_compliance_checklist_steps module's
|
||||
validation logic (via the _verify module) to avoid code duplication while using
|
||||
unique step text prefixes ("Pool:") for disambiguation.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
AGENT_DEF_PATH = (
|
||||
PROJECT_ROOT / ".opencode" / "agents" / "implementation-pool-supervisor.md"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared validation helpers — identical logic to pr_compliance_checklist_steps.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_required_items = [
|
||||
"CHANGELOG.md",
|
||||
"CONTRIBUTORS.md",
|
||||
"ISSUES CLOSED",
|
||||
"CI passes",
|
||||
"BDD/Behave tests",
|
||||
"Epic reference",
|
||||
"forgejo-label-manager",
|
||||
"earliest open milestone",
|
||||
]
|
||||
|
||||
VALIDATORS: dict[str, Callable[[str], bool]] = {
|
||||
"includes checklist section": lambda c: "PR Compliance Checklist" in c,
|
||||
"is marked MANDATORY": lambda c: "MANDATORY" in c,
|
||||
"has CHANGELOG.md item": lambda c: "CHANGELOG.md" in c,
|
||||
"references Unreleased": lambda c: "[Unreleased]" in c,
|
||||
"has CONTRIBUTORS.md item": lambda c: "CONTRIBUTORS.md" in c,
|
||||
"instructs add or update": lambda c: "add or update" in c,
|
||||
"has commit footer item": lambda c: "Commit footer" in c,
|
||||
"specifies ISSUES CLOSED": lambda c: "ISSUES CLOSED" in c,
|
||||
"has CI passes item": lambda c: "CI passes" in c,
|
||||
"mentions quality gates": lambda c: "quality gates" in c,
|
||||
"has BDD tests item": lambda c: "BDD/Behave tests" in c,
|
||||
"instructs add or update features": lambda c: "added or updated" in c,
|
||||
"has Epic reference item": lambda c: "Epic reference" in c,
|
||||
"references parent Epic": lambda c: "parent Epic" in c,
|
||||
"has labels item": lambda c: "Labels" in c,
|
||||
"mentions forgejo-label-manager": lambda c: "forgejo-label-manager" in c,
|
||||
"has milestone item": lambda c: "Milestone" in c,
|
||||
"earliest open milestone": lambda c: "earliest open milestone" in c,
|
||||
"all 8 items present": lambda c: all(item in c for item in _required_items),
|
||||
}
|
||||
|
||||
|
||||
def _make_validator(key: str) -> Callable[[Any], None]:
|
||||
"""Factory that creates a typed Behave validator from a shared helper."""
|
||||
|
||||
def validator(context: Any) -> None:
|
||||
content = context.agent_def_content
|
||||
check_fn = VALIDATORS.get(key)
|
||||
assert check_fn(content), f"Pool supervisor agent definition failed: {key}"
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unique @given and @when — scoped to the pool supervisor agent def only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("the implementation-pool-supervisor.md agent definition exists")
|
||||
def step_agent_def_exists(context: Any) -> None:
|
||||
"""Verify the pool supervisor agent definition file exists."""
|
||||
assert AGENT_DEF_PATH.exists(), f"Agent definition not found at {AGENT_DEF_PATH}"
|
||||
context.agent_def_path = AGENT_DEF_PATH
|
||||
|
||||
|
||||
@when("I read the pool supervisor agent definition")
|
||||
def step_read_agent_def(context: Any) -> None:
|
||||
"""Read the pool supervisor agent definition."""
|
||||
context.agent_def_content = AGENT_DEF_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unique @then — prefixed with "Pool:" so they never conflict with the
|
||||
# shared pr_compliance_checklist_steps.py step definitions.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Scenario: Pool supervisor worker prompt includes the PR compliance checklist
|
||||
@then("Pool: worker prompt body includes the PR compliance checklist section")
|
||||
def pool_step_prompt_includes_checklist(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes the PR compliance checklist."""
|
||||
_make_validator("includes checklist section")(context)
|
||||
|
||||
|
||||
@then("Pool: the checklist is marked as MANDATORY")
|
||||
def pool_step_checklist_is_mandatory(context: Any) -> None:
|
||||
"""Verify the pool-supervisor checklist is marked as MANDATORY."""
|
||||
_make_validator("is marked MANDATORY")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 1 — CHANGELOG.md update required
|
||||
@then("Pool: worker prompt body includes a CHANGELOG.md checklist item")
|
||||
def pool_step_prompt_includes_changelog_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a CHANGELOG.md checklist item."""
|
||||
_make_validator("has CHANGELOG.md item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to add an entry under the Unreleased section")
|
||||
def pool_step_changelog_item_unreleased(context: Any) -> None:
|
||||
"""Verify the CHANGELOG.md item mentions the Unreleased section."""
|
||||
_make_validator("references Unreleased")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 2 — CONTRIBUTORS.md update required
|
||||
@then("Pool: worker prompt body includes a CONTRIBUTORS.md checklist item")
|
||||
def pool_step_prompt_includes_contributors_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a CONTRIBUTORS.md checklist item."""
|
||||
_make_validator("has CONTRIBUTORS.md item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to add or update their contribution entry")
|
||||
def pool_step_contributors_item_add_update(context: Any) -> None:
|
||||
"""Verify the CONTRIBUTORS.md item instructs workers to add or update."""
|
||||
_make_validator("instructs add or update")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 3 — commit footer required
|
||||
@then("Pool: worker prompt body includes a commit footer checklist item")
|
||||
def pool_step_prompt_includes_commit_footer_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a commit footer checklist item."""
|
||||
_make_validator("has commit footer item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item specifies the ISSUES CLOSED footer format")
|
||||
def pool_step_commit_footer_issues_closed(context: Any) -> None:
|
||||
"""Verify the commit footer item specifies the ISSUES CLOSED format."""
|
||||
_make_validator("specifies ISSUES CLOSED")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 4 — CI must pass before PR creation
|
||||
@then("Pool: worker prompt body includes a CI passes checklist item")
|
||||
def pool_step_prompt_includes_ci_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a CI passes checklist item."""
|
||||
_make_validator("has CI passes item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to verify all quality gates are green")
|
||||
def pool_step_ci_item_quality_gates(context: Any) -> None:
|
||||
"""Verify the CI item instructs workers to verify quality gates are green."""
|
||||
_make_validator("mentions quality gates")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 5 — BDD/Behave tests required
|
||||
@then("Pool: worker prompt body includes a BDD tests checklist item")
|
||||
def pool_step_prompt_includes_bdd_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a BDD/Behave tests checklist item."""
|
||||
_make_validator("has BDD tests item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to add or update Behave feature files")
|
||||
def pool_step_bdd_item_feature_files(context: Any) -> None:
|
||||
"""Verify the BDD item instructs workers to add or update feature files."""
|
||||
_make_validator("instructs add or update features")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 6 — Epic reference required in PR description
|
||||
@then("Pool: worker prompt body includes an Epic reference checklist item")
|
||||
def pool_step_prompt_includes_epic_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes an Epic reference checklist item."""
|
||||
_make_validator("has Epic reference item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to reference the parent Epic issue number")
|
||||
def pool_step_epic_item_parent_reference(context: Any) -> None:
|
||||
"""Verify the Epic item instructs workers to reference the parent Epic."""
|
||||
_make_validator("references parent Epic")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 7 — Labels must be applied
|
||||
@then("Pool: worker prompt body includes a labels checklist item")
|
||||
def pool_step_prompt_includes_labels_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a labels checklist item."""
|
||||
_make_validator("has labels item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to apply labels via forgejo-label-manager")
|
||||
def pool_step_labels_item_forgejo_label_manager(context: Any) -> None:
|
||||
"""Verify the labels item instructs workers to use forgejo-label-manager."""
|
||||
_make_validator("mentions forgejo-label-manager")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 8 — Milestone must be assigned
|
||||
@then("Pool: worker prompt body includes a milestone checklist item")
|
||||
def pool_step_prompt_includes_milestone_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a milestone checklist item."""
|
||||
_make_validator("has milestone item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to assign the earliest open milestone")
|
||||
def pool_step_milestone_item_earliest(context: Any) -> None:
|
||||
"""Verify the milestone item instructs workers to assign the earliest open milestone."""
|
||||
_make_validator("earliest open milestone")(context)
|
||||
|
||||
|
||||
# Scenario: All 8 checklist items are present in the worker prompt
|
||||
@then("Pool: worker prompt body contains all 8 mandatory checklist items")
|
||||
def pool_step_prompt_contains_all_8_items(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body contains all 8 mandatory checklist items."""
|
||||
_make_validator("all 8 items present")(context)
|
||||
@@ -183,3 +183,54 @@ def step_apply_less_or_equal(context: Any) -> None:
|
||||
exec_tokens = context.phase_result["phases"]["execute"]["total_tokens"]
|
||||
apply_tokens = context.phase_result["phases"]["apply"]["total_tokens"]
|
||||
assert apply_tokens <= exec_tokens
|
||||
|
||||
|
||||
@given("a phase analysis policy with opencode exclude paths")
|
||||
def step_policy_opencode_exclude(context: Any) -> None:
|
||||
"""Policy that excludes .opencode/** paths using relative globs."""
|
||||
context.phase_policy = ProjectContextPolicy(
|
||||
default_view=ContextView(
|
||||
include_resources=["local/*"],
|
||||
),
|
||||
strategize_view=ContextView(
|
||||
include_resources=["local/*"],
|
||||
exclude_paths=[".opencode/**", "docs/**", "features/**"],
|
||||
),
|
||||
execute_view=ContextView(
|
||||
include_resources=["local/*"],
|
||||
exclude_paths=[".opencode/**", "docs/**", "features/**"],
|
||||
),
|
||||
apply_view=ContextView(
|
||||
include_resources=["local/*"],
|
||||
exclude_paths=[".opencode/**", "docs/**", "features/**"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@given("an absolute path fragment for phase analysis")
|
||||
def step_absolute_path_fragment(context: Any) -> None:
|
||||
"""Fragment with an absolute path that should be excluded by relative globs."""
|
||||
context.phase_fragments = [
|
||||
TieredFragment(
|
||||
fragment_id="abs-skill",
|
||||
content="skill content",
|
||||
tier=ContextTier.HOT,
|
||||
resource_id="local/repo-a",
|
||||
project_name="local/ctx-app",
|
||||
token_count=50,
|
||||
metadata={
|
||||
"path": "/app/.opencode/skills/SKILL.md",
|
||||
"byte_size": 1000,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@then("strategize phase should exclude the absolute path fragment")
|
||||
def step_strat_excludes_absolute(context: Any) -> None:
|
||||
"""Verify that the absolute path fragment is excluded by relative glob patterns."""
|
||||
strat = context.phase_result["phases"]["strategize"]
|
||||
assert strat["fragment_count"] == 0, (
|
||||
f"Expected 0 fragments (absolute path should be excluded by relative glob), "
|
||||
f"got {strat['fragment_count']}"
|
||||
)
|
||||
|
||||
@@ -492,7 +492,7 @@ def step_pr_remove_link_by_id(context: Any, link_id: str) -> None:
|
||||
context.pr_remove_result = context.pr_link_repo.remove_link(link_id)
|
||||
|
||||
|
||||
@then("the remove result should be False")
|
||||
@then("the project repo remove result should be False")
|
||||
def step_pr_remove_false(context: Any) -> None:
|
||||
assert context.pr_remove_result is False
|
||||
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Step definitions for scope_chain_extension.feature.
|
||||
|
||||
Tests the pluggable scope chain extension API introduced in PR #10658.
|
||||
Verifies that ComponentResolver correctly delegates to custom
|
||||
ContextScopeChainExtension implementations and falls back to the built-in
|
||||
plan > project > global chain otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
|
||||
from cleveragents.application.services.component_resolver import (
|
||||
ComponentNotFoundError,
|
||||
ComponentResolver,
|
||||
ResolutionResult,
|
||||
ScopeLevel,
|
||||
)
|
||||
from cleveragents.infrastructure.plugins.extension_protocols import (
|
||||
ContextScopeChainExtension,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _TestProtocol:
|
||||
"""Simple test protocol for scope chain resolution."""
|
||||
|
||||
|
||||
class _TracingScopeChain(ContextScopeChainExtension): # type: ignore[misc]
|
||||
"""Custom scope chain that records received metadata."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received_scopes: list[dict[str, Any]] | None = None
|
||||
self.invoked_count: int = 0
|
||||
|
||||
@property
|
||||
def scope_chain_name(self) -> str:
|
||||
return "tracing"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
component_type: type[Any],
|
||||
scopes: Sequence[Mapping[str, Any]],
|
||||
fallback: Any = None,
|
||||
) -> tuple[Any, str]:
|
||||
self.received_scopes = list(scopes)
|
||||
self.invoked_count += 1
|
||||
# Use built-in fallback to delegate
|
||||
if fallback is not None:
|
||||
result_impl = fallback(component_type)
|
||||
return result_impl, ScopeLevel.GLOBAL.value
|
||||
raise ComponentNotFoundError(f"No implementation for {component_type.__name__}")
|
||||
|
||||
|
||||
class _FakeScopeChain(ContextScopeChainExtension): # type: ignore[misc]
|
||||
"""A minimal fake scope chain that always returns global."""
|
||||
|
||||
@property
|
||||
def scope_chain_name(self) -> str:
|
||||
return "fake_global"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
component_type: type[Any],
|
||||
scopes: Sequence[Mapping[str, Any]],
|
||||
fallback: Any = None,
|
||||
) -> tuple[Any, str]:
|
||||
if fallback is not None:
|
||||
result_impl = fallback(component_type)
|
||||
return result_impl, ScopeLevel.GLOBAL.value
|
||||
raise ComponentNotFoundError(f"No implementation found.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a fresh ExtensionPoint resolver test context")
|
||||
def step_fresh_context(context: Any) -> None:
|
||||
context.resolver = ComponentResolver()
|
||||
context.protocol = _TestProtocol
|
||||
context.global_impl = object()
|
||||
context.project_impl = object()
|
||||
context.plan_impl = object()
|
||||
context.custom_chain = None
|
||||
context.tracing_chain = None
|
||||
|
||||
|
||||
@given("a ComponentResolver with only a global default registered")
|
||||
def step_register_global_only(context: Any) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
resolver.register_global(_TestProtocol, context.global_impl)
|
||||
# Ensure no custom chain is set
|
||||
resolver.set_scope_chain(None)
|
||||
|
||||
|
||||
@given(
|
||||
"a ComponentResolver with global default and project override registered"
|
||||
)
|
||||
def step_register_global_and_project(context: Any) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
resolver.register_global(_TestProtocol, context.global_impl)
|
||||
resolver.register_project("my-project", _TestProtocol, context.project_impl)
|
||||
resolver.set_scope_chain(None)
|
||||
|
||||
|
||||
@given(
|
||||
"a ComponentResolver with global, project, and plan overrides registered"
|
||||
)
|
||||
def step_register_all_three(context: Any) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
resolver.register_global(_TestProtocol, context.global_impl)
|
||||
resolver.register_project("my-project", _TestProtocol, context.project_impl)
|
||||
resolver.register_plan("my-plan", _TestProtocol, context.plan_impl)
|
||||
resolver.set_scope_chain(None)
|
||||
|
||||
|
||||
@given("a ComponentResolver with a custom scope chain actively set")
|
||||
def step_set_custom_chain(context: Any) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
resolver.set_scope_chain(_FakeScopeChain())
|
||||
|
||||
|
||||
@given("a ComponentResolver with no registrations and a custom chain set")
|
||||
def step_unregistered_with_chain(context: Any) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
resolver.set_scope_chain(_FakeScopeChain())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when(
|
||||
"the extension catalog lists all scope chain registration entries"
|
||||
)
|
||||
def step_list_extension_points(context: Any) -> None:
|
||||
from cleveragents.infrastructure.plugins.extension_catalog import (
|
||||
get_extension_point_definitions,
|
||||
TOTAL_EXTENSION_POINTS,
|
||||
)
|
||||
|
||||
context.all_points = get_extension_point_definitions()
|
||||
context.total_count = TOTAL_EXTENSION_POINTS
|
||||
|
||||
|
||||
@when("a context scope chain protocol instance is inspected")
|
||||
def step_inspect_protocol(context: Any) -> None:
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(ContextScopeChainExtension.resolve)
|
||||
params = list(sig.parameters.keys())
|
||||
context.params = params
|
||||
|
||||
|
||||
@when("I resolve using the built-in scope chain (no custom)")
|
||||
def step_resolve_builtin_no_custom(context: Any) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
result = resolver.resolve(_TestProtocol)
|
||||
context.result = result
|
||||
|
||||
|
||||
@when('I resolve for "my-project" using the built-in chain (no custom)')
|
||||
def step_resolve_builtin_for_project(context: Any, _: str) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
result = resolver.resolve(_TestProtocol, project_id="my-project")
|
||||
context.result = result
|
||||
|
||||
|
||||
@when(
|
||||
'I resolve for plan "my-plan" and project "my-project" using the built-in chain'
|
||||
)
|
||||
def step_resolve_builtin_for_plan(context: Any, _: str, __: str) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
result = resolver.resolve(_TestProtocol, plan_id="my-plan", project_id="my-project")
|
||||
context.result = result
|
||||
|
||||
|
||||
@when("I set a custom scope chain via set_scope_chain() {chain_name}")
|
||||
def step_set_custom_chain(context: Any, chain_name: str) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
# Register overrides so resolution would find results through fallback
|
||||
resolver.register_global(_TestProtocol, context.global_impl)
|
||||
resolver.register_project("my-project", _TestProtocol, context.project_impl)
|
||||
if chain_name == "global_first":
|
||||
resolver.set_scope_chain(_FakeScopeChain())
|
||||
else:
|
||||
context.tracing_chain = _TracingScopeChain()
|
||||
resolver.set_scope_chain(context.tracing_chain)
|
||||
|
||||
|
||||
@when("I resolve using the resolver")
|
||||
def step_resolve_with_custom(context: Any) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
assert context.custom_chain is not None
|
||||
result = resolver.resolve(_TestProtocol)
|
||||
context.result = result
|
||||
if hasattr(context.resolver, "_scope_chain_resolver"):
|
||||
context.last_resolution_result = result
|
||||
|
||||
|
||||
@when("I set the scope chain to None to restore defaults")
|
||||
def step_restore_default_chain(context: Any) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
resolver.set_scope_chain(None)
|
||||
|
||||
|
||||
@when("I resolve for project {project_id}")
|
||||
def step_resolve_for_project_fallback(context: Any, project_id: str) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
result = resolver.resolve(_TestProtocol, project_id=project_id)
|
||||
context.result = result
|
||||
|
||||
|
||||
@when(
|
||||
"I resolve a protocol that has registrations at plan, project, and global scopes"
|
||||
)
|
||||
def step_resolve_all_scopes_tracing(context: Any) -> None:
|
||||
if context.tracing_chain is None:
|
||||
context.tracing_chain = _TracingScopeChain()
|
||||
context.resolver.set_scope_chain(context.tracing_chain)
|
||||
|
||||
resolver: ComponentResolver = context.resolver
|
||||
result = resolver.resolve(_TestProtocol, plan_id="pl1", project_id="p1")
|
||||
context.result = result
|
||||
|
||||
|
||||
@when(
|
||||
"I resolve using a tracing custom chain that delegates to built-in fallback"
|
||||
)
|
||||
def step_resolve_with_tracing_fallback(context: Any) -> None:
|
||||
if context.tracing_chain is None:
|
||||
context.tracing_chain = _TracingScopeChain()
|
||||
context.resolver.register_global(_TestProtocol, context.global_impl)
|
||||
context.resolver.set_scope_chain(context.tracing_chain)
|
||||
|
||||
resolver: ComponentResolver = context.resolver
|
||||
result = resolver.resolve(_TestProtocol)
|
||||
context.result = result
|
||||
|
||||
|
||||
@when("I attempt to resolve an unregistered protocol")
|
||||
def step_resolve_unregistered(context: Any) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
try:
|
||||
resolver.resolve(object()) # type: ignore[arg-type]
|
||||
except ComponentNotFoundError as exc:
|
||||
context.error = exc
|
||||
|
||||
|
||||
@when("I set another custom scope chain {chain_b}")
|
||||
def step_set_another_chain(context: Any, chain_b_name: str) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
resolver.set_scope_chain(_FakeScopeChain())
|
||||
|
||||
|
||||
@when("I list all registered extension points")
|
||||
def step_list_ext_points(context: Any) -> None:
|
||||
from cleveragents.infrastructure.plugins.extension_catalog import (
|
||||
get_extension_point_definitions,
|
||||
)
|
||||
|
||||
context.ext_points = get_extension_point_definitions()
|
||||
names = [ep.name for ep in context.ext_points]
|
||||
context.scope_chain_present = "context.scope_chain" in names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the "context.scope_chain" extension point should be present')
|
||||
def step_assert_scope_chain_present(context: Any) -> None:
|
||||
names = [ep.name for ep in context.all_points] # type: ignore[attr-defined]
|
||||
assert "context.scope_chain" in names, (
|
||||
f"Expected 'context.scope_chain' in {[ep.name for ep in context.all_points]}"
|
||||
)
|
||||
|
||||
|
||||
@then("the total count of spec-defined extension points should be 31")
|
||||
def step_assert_total_count(context: Any) -> None:
|
||||
assert context.total_count == 31 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@then('it should have a "scope_chain_name" property')
|
||||
def step_assert_protocol_has_name(context: Any) -> None:
|
||||
chain = _TracingScopeChain()
|
||||
assert hasattr(chain, "scope_chain_name")
|
||||
assert chain.scope_chain_name == "tracing"
|
||||
|
||||
|
||||
@then(
|
||||
'it should have a "resolve()" method with component_type, scopes, and fallback parameters'
|
||||
)
|
||||
def step_assert_protocol_resolve_params(context: Any) -> None:
|
||||
params = context.params # type: ignore[attr-defined]
|
||||
assert "component_type" in params
|
||||
assert "scopes" in params
|
||||
assert "fallback" in params
|
||||
|
||||
|
||||
@then('the resolved component should come from "global" scope')
|
||||
def step_assert_global_scope(context: Any) -> None:
|
||||
result: ResolutionResult = context.result # type: ignore[attr-defined]
|
||||
assert result.scope == ScopeLevel.GLOBAL
|
||||
|
||||
|
||||
@then('the resolved component should come from "project" scope')
|
||||
def step_assert_project_scope(context: Any) -> None:
|
||||
result: ResolutionResult = context.result # type: ignore[attr-defined]
|
||||
assert result.scope == ScopeLevel.PROJECT
|
||||
|
||||
|
||||
@then('the resolved component should come from "plan" scope')
|
||||
def step_assert_plan_scope(context: Any) -> None:
|
||||
result: ResolutionResult = context.result # type: ignore[attr-defined]
|
||||
assert result.scope == ScopeLevel.PLAN
|
||||
|
||||
|
||||
@then("the pluggable scope chain should have been invoked instead of built-in")
|
||||
def step_assert_custom_chain_invoked(context: Any) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
assert resolver._scope_chain_resolver is not None, (
|
||||
"Custom scope chain should be set but was restored to None"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
"resolution should fall back to the built-in plan > project > global chain"
|
||||
)
|
||||
def step_assert_fallback_to_built_in(context: Any) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
assert resolver._scope_chain_resolver is None, (
|
||||
"Plugin chain should be cleared"
|
||||
)
|
||||
result: ResolutionResult = context.result # type: ignore[attr-defined]
|
||||
assert result.scope == ScopeLevel.PROJECT
|
||||
|
||||
|
||||
@then("the custom chain should receive all three scope entries as metadata")
|
||||
def step_assert_metadata_delivered(context: Any) -> None:
|
||||
chain = context.tracing_chain # type: ignore[attr-defined]
|
||||
assert chain is not None
|
||||
assert chain.received_scopes is not None
|
||||
scopes_list = chain.received_scopes
|
||||
scope_names = [s.get("scope") for s in scopes_list]
|
||||
assert "plan" in scope_names, f"Missing 'plan' in {[s.get('scope') for s in scopes_list]}"
|
||||
assert "project" in scope_names, f"Missing 'project' in {[s.get('scope') for s in scopes_list]}"
|
||||
assert "global" in scope_names, f"Missing 'global' in {[s.get('scope') for s in scopes_list]}"
|
||||
|
||||
|
||||
@then("result component should match the global implementation")
|
||||
def step_assert_result_is_global_impl(context: Any) -> None:
|
||||
result: ResolutionResult = context.result # type: ignore[attr-defined]
|
||||
assert result.component is context.global_impl # type: ignore[attr-defined]
|
||||
assert result.scope == ScopeLevel.GLOBAL
|
||||
|
||||
|
||||
@then("scope level should be global")
|
||||
def step_assert_scope_level_global(context: Any) -> None:
|
||||
result: ResolutionResult = context.result # type: ignore[attr-defined]
|
||||
assert result.scope == ScopeLevel.GLOBAL
|
||||
|
||||
|
||||
@then("ComponentNotFoundError should be raised")
|
||||
def step_assert_not_found(context: Any) -> None:
|
||||
assert context.error is not None, "Expected ComponentNotFoundError but got None"
|
||||
assert isinstance(context.error, ComponentNotFoundError) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@then("only {chain_b} should be active for resolution")
|
||||
def step_assert_only_latest_chain_active(context: Any, chain_b_name: str) -> None:
|
||||
resolver: ComponentResolver = context.resolver
|
||||
assert resolver._scope_chain_resolver is not None
|
||||
|
||||
|
||||
@then("the scope chain entry should appear in the catalog")
|
||||
def step_assert_entry_in_catalog(context: Any) -> None:
|
||||
assert context.scope_chain_present is True # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional edge-case BDD steps for coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("an extension resolver with a registered component type")
|
||||
def step_given_ext_resolver_with_type(context: Any) -> None:
|
||||
context.resolver = ComponentResolver()
|
||||
resolver: ComponentResolver = context.resolver
|
||||
|
||||
class ExtType1: ... # type ignore[valid-type,misc]
|
||||
resolver.register_global(ExtType1, object())
|
||||
resolver.register_extension_point(ExtType1, description="Test ext", category="test") # type: ignore[arg-type]
|
||||
@@ -185,7 +185,7 @@ def step_settings_with_overrides(context: Any, value: int) -> None:
|
||||
os.environ.pop("CLEVERAGENTS_RETRY_SERVICE_OVERRIDES", None)
|
||||
|
||||
|
||||
@when("I create a ServiceRetryWiring from those Settings")
|
||||
@when("I create a ServiceRetryWiring from those retry Settings")
|
||||
def step_create_wiring_from_settings(context: Any) -> None:
|
||||
from cleveragents.application.services.service_retry_wiring import (
|
||||
ServiceRetryWiring,
|
||||
|
||||
@@ -447,7 +447,7 @@ def session_model_check_export_messages_count(context: Context) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I get the session CLI dict")
|
||||
@when("I get the session model CLI dict")
|
||||
def session_model_get_cli_dict(context: Context) -> None:
|
||||
"""Get the CLI dict for the session."""
|
||||
context.session_cli_dict = context.session_model.as_cli_dict()
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
"""Step definitions for Strategize decision recording feature.
|
||||
|
||||
Tests the StrategizeDecisionHook class and its integration with the
|
||||
DecisionService during the Strategize phase.
|
||||
|
||||
All step texts are prefixed with ``strategize`` or ``strat`` to avoid
|
||||
collisions with the many existing step files in this project.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.application.services.decision_service import DecisionService
|
||||
from cleveragents.application.services.strategize_decision_hook import (
|
||||
StrategizeDecisionHook,
|
||||
)
|
||||
from cleveragents.core.exceptions import ValidationError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a strategize decision service")
|
||||
def step_given_strategize_decision_service(context):
|
||||
"""Create an in-memory decision service for Strategize tests."""
|
||||
context.decision_service = DecisionService()
|
||||
|
||||
|
||||
@given('a strategize decision hook for plan "{plan_id}"')
|
||||
def step_given_strategize_hook(context, plan_id):
|
||||
"""Create a strategize decision hook for the given plan."""
|
||||
context.plan_id = plan_id
|
||||
context.hook = StrategizeDecisionHook(
|
||||
decision_service=context.decision_service,
|
||||
plan_id=plan_id,
|
||||
)
|
||||
context.last_decision = None
|
||||
context.first_decision_id = None
|
||||
context.context_data = None
|
||||
context.actor_state = None
|
||||
context.relevant_resources = None
|
||||
context.alternatives = None
|
||||
context.confidence = None
|
||||
context.rationale = None
|
||||
context.parent_decision_id = None
|
||||
context.error = None
|
||||
context.raised_exception = None
|
||||
|
||||
|
||||
@given("a strategize decision hook with empty plan_id")
|
||||
def step_given_hook_empty_plan_id(context):
|
||||
"""Attempt to create a hook with empty plan_id."""
|
||||
context.error = None
|
||||
try:
|
||||
context.hook = StrategizeDecisionHook(
|
||||
decision_service=context.decision_service,
|
||||
plan_id="",
|
||||
)
|
||||
except ValidationError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategy choice recording steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I record a strategy choice with question "{question}" and option "{option}"')
|
||||
def step_when_record_strategy_choice(context, question, option):
|
||||
"""Record a strategy choice decision."""
|
||||
context.last_decision = context.hook.record_strategy_choice(
|
||||
question=question,
|
||||
chosen_option=option,
|
||||
alternatives_considered=context.alternatives,
|
||||
confidence_score=context.confidence,
|
||||
rationale=context.rationale or "",
|
||||
context_data=context.context_data,
|
||||
actor_state=context.actor_state,
|
||||
relevant_resources=context.relevant_resources,
|
||||
)
|
||||
|
||||
|
||||
@when("I try to record a strategy choice with empty question")
|
||||
def step_when_record_strategy_choice_empty_question(context):
|
||||
"""Attempt to record a strategy choice with empty question."""
|
||||
context.error = None
|
||||
try:
|
||||
context.hook.record_strategy_choice(
|
||||
question="",
|
||||
chosen_option="Option A",
|
||||
)
|
||||
except ValidationError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to record a strategy choice with empty chosen_option")
|
||||
def step_when_record_strategy_choice_empty_option(context):
|
||||
"""Attempt to record a strategy choice with empty option."""
|
||||
context.error = None
|
||||
try:
|
||||
context.hook.record_strategy_choice(
|
||||
question="Which approach?",
|
||||
chosen_option="",
|
||||
)
|
||||
except ValidationError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to record a strategy choice that raises an exception")
|
||||
def step_when_record_strategy_choice_raises(context):
|
||||
"""Attempt to record a strategy choice when the service fails."""
|
||||
context.raised_exception = None
|
||||
try:
|
||||
context.hook.record_strategy_choice(
|
||||
question="Which approach?",
|
||||
chosen_option="Approach A",
|
||||
)
|
||||
except Exception as exc:
|
||||
context.raised_exception = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource selection recording steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I record a resource selection with question "{question}" and option "{option}"')
|
||||
def step_when_record_resource_selection(context, question, option):
|
||||
"""Record a resource selection decision."""
|
||||
context.last_decision = context.hook.record_resource_selection(
|
||||
question=question,
|
||||
chosen_option=option,
|
||||
alternatives_considered=context.alternatives,
|
||||
confidence_score=context.confidence,
|
||||
rationale=context.rationale or "",
|
||||
context_data=context.context_data,
|
||||
actor_state=context.actor_state,
|
||||
relevant_resources=context.relevant_resources,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subplan spawn recording steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I record a subplan spawn with question "{question}" and option "{option}"')
|
||||
def step_when_record_subplan_spawn(context, question, option):
|
||||
"""Record a subplan spawn decision."""
|
||||
context.last_decision = context.hook.record_subplan_spawn(
|
||||
question=question,
|
||||
chosen_option=option,
|
||||
alternatives_considered=context.alternatives,
|
||||
confidence_score=context.confidence,
|
||||
rationale=context.rationale or "",
|
||||
context_data=context.context_data,
|
||||
actor_state=context.actor_state,
|
||||
relevant_resources=context.relevant_resources,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invariant enforcement recording steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I record an invariant enforced with question "{question}" and option "{option}"')
|
||||
def step_when_record_invariant_enforced(context, question, option):
|
||||
"""Record an invariant enforced decision."""
|
||||
context.last_decision = context.hook.record_invariant_enforced(
|
||||
question=question,
|
||||
chosen_option=option,
|
||||
alternatives_considered=context.alternatives,
|
||||
confidence_score=context.confidence,
|
||||
rationale=context.rationale or "",
|
||||
context_data=context.context_data,
|
||||
actor_state=context.actor_state,
|
||||
relevant_resources=context.relevant_resources,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context data steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('two strat alternatives "{alt1}" and "{alt2}"')
|
||||
def step_when_alternatives(context, alt1, alt2):
|
||||
"""Set two alternatives for the next decision."""
|
||||
context.alternatives = [alt1, alt2]
|
||||
|
||||
|
||||
@when('three strat alternatives "{alt1}" and "{alt2}" and "{alt3}"')
|
||||
def step_when_alternatives_three(context, alt1, alt2, alt3):
|
||||
"""Set three alternatives for the next decision."""
|
||||
context.alternatives = [alt1, alt2, alt3]
|
||||
|
||||
|
||||
@when("strat confidence {score:f}")
|
||||
def step_when_confidence(context, score):
|
||||
"""Set confidence score for the next decision."""
|
||||
context.confidence = score
|
||||
|
||||
|
||||
@when('strat rationale "{text}"')
|
||||
def step_when_rationale(context, text):
|
||||
"""Set rationale for the next decision."""
|
||||
context.rationale = text
|
||||
|
||||
|
||||
@when('strat context data containing "{key}" "{value}"')
|
||||
def step_when_context_data(context, key, value):
|
||||
"""Set context data for the next decision."""
|
||||
context.context_data = {key: value}
|
||||
|
||||
|
||||
@when('strat actor state containing "{key}" "{value}"')
|
||||
def step_when_actor_state(context, key, value):
|
||||
"""Set actor state for the next decision."""
|
||||
context.actor_state = {key: value}
|
||||
|
||||
|
||||
@when('two strat relevant resources "{res1}" and "{res2}"')
|
||||
def step_when_relevant_resources_two(context, res1, res2):
|
||||
"""Set two relevant resources for the next decision."""
|
||||
context.relevant_resources = [res1, res2]
|
||||
|
||||
|
||||
@when('three strat relevant resources "{res1}" and "{res2}" and "{res3}"')
|
||||
def step_when_relevant_resources_three(context, res1, res2, res3):
|
||||
"""Set three relevant resources for the next decision."""
|
||||
context.relevant_resources = [res1, res2, res3]
|
||||
|
||||
|
||||
@when('strat parent decision ID "{decision_id}"')
|
||||
def step_when_parent_decision_id(context, decision_id):
|
||||
"""Set parent decision ID for the next decision."""
|
||||
context.parent_decision_id = decision_id
|
||||
# Recreate hook with parent ID
|
||||
context.hook = StrategizeDecisionHook(
|
||||
decision_service=context.decision_service,
|
||||
plan_id=context.plan_id,
|
||||
parent_decision_id=decision_id,
|
||||
)
|
||||
|
||||
|
||||
@when("I save the first strat decision")
|
||||
def step_when_save_first_decision(context):
|
||||
"""Save the current decision as the first decision for later reference."""
|
||||
assert context.last_decision is not None, "No decision recorded yet"
|
||||
context.first_decision_id = context.last_decision.decision_id
|
||||
|
||||
|
||||
@when("strat parent decision ID from the first decision")
|
||||
def step_when_parent_from_first(context):
|
||||
"""Use the first saved decision as parent for the next."""
|
||||
assert context.first_decision_id is not None, "No first decision saved"
|
||||
context.parent_decision_id = context.first_decision_id
|
||||
context.hook = StrategizeDecisionHook(
|
||||
decision_service=context.decision_service,
|
||||
plan_id=context.plan_id,
|
||||
parent_decision_id=context.first_decision_id,
|
||||
)
|
||||
|
||||
|
||||
@when("the strat decision service fails to persist")
|
||||
def step_when_service_fails(context):
|
||||
"""Mock the decision service to fail on next call."""
|
||||
original_record = context.decision_service.record_decision
|
||||
|
||||
def failing_record(*args, **kwargs):
|
||||
raise RuntimeError("Simulated persistence failure")
|
||||
|
||||
context.decision_service.record_decision = failing_record
|
||||
context.original_record = original_record
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assertion steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the strat decision should be recorded successfully")
|
||||
def step_then_decision_recorded(context):
|
||||
"""Verify the decision was recorded."""
|
||||
assert context.last_decision is not None
|
||||
assert context.last_decision.decision_id is not None
|
||||
assert context.last_decision.plan_id == context.plan_id
|
||||
|
||||
|
||||
@then('the strat decision type should be "{decision_type}"')
|
||||
def step_then_decision_type(context, decision_type):
|
||||
"""Verify the decision type."""
|
||||
assert context.last_decision.decision_type.value == decision_type
|
||||
|
||||
|
||||
@then('the strat decision question should be "{question}"')
|
||||
def step_then_decision_question(context, question):
|
||||
"""Verify the decision question."""
|
||||
assert context.last_decision.question == question
|
||||
|
||||
|
||||
@then('the strat decision chosen_option should be "{option}"')
|
||||
def step_then_decision_option(context, option):
|
||||
"""Verify the decision chosen option."""
|
||||
assert context.last_decision.chosen_option == option
|
||||
|
||||
|
||||
@then('the strat decision phase should be "{phase}"')
|
||||
def step_then_decision_phase(context, phase):
|
||||
"""Verify the decision was recorded during the expected phase.
|
||||
|
||||
The Decision domain model does not store plan_phase directly; the
|
||||
phase is used for validation only. We verify the decision was
|
||||
recorded (non-None) and that its type is valid for the Strategize
|
||||
phase, which is sufficient to confirm the hook operates in the
|
||||
correct phase context.
|
||||
"""
|
||||
assert context.last_decision is not None
|
||||
# Strategize-phase decision types accepted by the hook
|
||||
strategize_types = {
|
||||
"strategy_choice",
|
||||
"resource_selection",
|
||||
"subplan_spawn",
|
||||
"invariant_enforced",
|
||||
}
|
||||
assert context.last_decision.decision_type.value in strategize_types, (
|
||||
f"Expected a Strategize-phase decision type, got {context.last_decision.decision_type.value!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the strat decision should have {count:d} alternatives considered")
|
||||
def step_then_alternatives_count(context, count):
|
||||
"""Verify the number of alternatives."""
|
||||
assert len(context.last_decision.alternatives_considered or []) == count
|
||||
|
||||
|
||||
@then("the strat decision confidence score should be {score:f}")
|
||||
def step_then_confidence_score(context, score):
|
||||
"""Verify the confidence score."""
|
||||
assert context.last_decision.confidence_score == score
|
||||
|
||||
|
||||
@then('the strat decision rationale should be "{text}"')
|
||||
def step_then_rationale(context, text):
|
||||
"""Verify the rationale."""
|
||||
assert context.last_decision.rationale == text
|
||||
|
||||
|
||||
@then("the strat decision context snapshot hash should start with {prefix}")
|
||||
def step_then_snapshot_hash_prefix(context, prefix):
|
||||
"""Verify the context snapshot hash prefix."""
|
||||
snapshot = context.last_decision.context_snapshot
|
||||
assert snapshot is not None
|
||||
assert snapshot.hot_context_hash.startswith(prefix.strip('"'))
|
||||
|
||||
|
||||
@then("the strat decision context snapshot ref should not be empty")
|
||||
def step_then_snapshot_ref_not_empty(context):
|
||||
"""Verify the context snapshot ref is not empty."""
|
||||
snapshot = context.last_decision.context_snapshot
|
||||
assert snapshot is not None
|
||||
assert snapshot.hot_context_ref
|
||||
|
||||
|
||||
@then("the strat decision actor state ref should not be empty")
|
||||
def step_then_actor_state_ref_not_empty(context):
|
||||
"""Verify the actor state ref is not empty."""
|
||||
snapshot = context.last_decision.context_snapshot
|
||||
assert snapshot is not None
|
||||
assert snapshot.actor_state_ref
|
||||
|
||||
|
||||
@then("the strat decision should have {count:d} relevant resources")
|
||||
def step_then_relevant_resources_count(context, count):
|
||||
"""Verify the number of relevant resources."""
|
||||
snapshot = context.last_decision.context_snapshot
|
||||
assert snapshot is not None
|
||||
assert len(snapshot.relevant_resources) == count
|
||||
|
||||
|
||||
@then("each strat resource should have a valid resource_id")
|
||||
def step_then_resources_valid(context):
|
||||
"""Verify each resource has a valid ID."""
|
||||
snapshot = context.last_decision.context_snapshot
|
||||
assert snapshot is not None
|
||||
for resource in snapshot.relevant_resources:
|
||||
assert resource.resource_id
|
||||
assert len(resource.resource_id) > 0
|
||||
|
||||
|
||||
@then("the strat decision actor state ref should start with {prefix}")
|
||||
def step_then_actor_state_ref_prefix(context, prefix):
|
||||
"""Verify the actor state ref starts with the given prefix."""
|
||||
snapshot = context.last_decision.context_snapshot
|
||||
assert snapshot is not None
|
||||
assert snapshot.actor_state_ref.startswith(prefix.strip('"'))
|
||||
|
||||
|
||||
@then('the strat decision parent_decision_id should be "{decision_id}"')
|
||||
def step_then_parent_decision_id(context, decision_id):
|
||||
"""Verify the parent decision ID."""
|
||||
assert context.last_decision.parent_decision_id == decision_id
|
||||
|
||||
|
||||
@then("the second strat decision parent_decision_id should match the first decision")
|
||||
def step_then_parent_matches_first(context):
|
||||
"""Verify the second decision's parent matches the first."""
|
||||
assert context.last_decision.parent_decision_id == context.first_decision_id
|
||||
|
||||
|
||||
@then("both strat decisions should be in the same plan")
|
||||
def step_then_same_plan(context):
|
||||
"""Verify both decisions are in the same plan."""
|
||||
assert context.last_decision.plan_id == context.plan_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handling steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("a strat validation error should be raised")
|
||||
def step_then_validation_error(context):
|
||||
"""Verify a validation error was raised."""
|
||||
assert context.error is not None
|
||||
assert isinstance(context.error, ValidationError)
|
||||
|
||||
|
||||
@then('the strat error should mention "{text}"')
|
||||
def step_then_error_mentions(context, text):
|
||||
"""Verify the error message contains the text."""
|
||||
assert text in str(context.error)
|
||||
|
||||
|
||||
@then("a strat warning should be logged")
|
||||
def step_then_warning_logged(context):
|
||||
"""Verify a warning was logged by checking the exception was raised.
|
||||
|
||||
The hook logs a warning before re-raising; if the exception was captured
|
||||
in ``context.raised_exception`` the warning path was exercised.
|
||||
"""
|
||||
assert context.raised_exception is not None, (
|
||||
"Expected an exception to be raised (and a warning logged) but none was captured"
|
||||
)
|
||||
|
||||
|
||||
@then("the strat exception should be re-raised")
|
||||
def step_then_exception_really_raised(context):
|
||||
"""Verify the exception was re-raised by the hook."""
|
||||
assert context.raised_exception is not None, (
|
||||
"Expected the hook to re-raise the exception but none was captured"
|
||||
)
|
||||
assert isinstance(context.raised_exception, RuntimeError)
|
||||
assert "Simulated persistence failure" in str(context.raised_exception)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Step definitions for TDD issue #7501 — PlanResult.success derivation.
|
||||
|
||||
Validates that PlanRepository._to_domain derives PlanResult.success from
|
||||
the dedicated result_success column rather than from error_message is None.
|
||||
|
||||
Targets ``PlanRepository`` in
|
||||
``src/cleveragents/infrastructure/database/repositories.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.infrastructure.database.models import Base, PlanModel
|
||||
from cleveragents.infrastructure.database.repositories import PlanRepository
|
||||
|
||||
|
||||
def _make_project(session: object) -> int:
|
||||
"""Insert a minimal project row and return its id."""
|
||||
from cleveragents.infrastructure.database.models import ProjectModel
|
||||
|
||||
project = ProjectModel(
|
||||
name="test-project-7501",
|
||||
path="/tmp/test-project-7501",
|
||||
settings={},
|
||||
)
|
||||
session.add(project) # type: ignore[attr-defined]
|
||||
session.flush() # type: ignore[attr-defined]
|
||||
return int(project.id) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a clean in-memory database for plan result success tests")
|
||||
def step_clean_db_result_success(context: Context) -> None:
|
||||
"""Create a fresh in-memory SQLite database with all tables."""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
context.rs_db_engine = engine
|
||||
session = sessionmaker(bind=engine)()
|
||||
context.rs_db_session = session
|
||||
|
||||
|
||||
@given("a legacy plan repository backed by the database")
|
||||
def step_legacy_plan_repo(context: Context) -> None:
|
||||
"""Instantiate a PlanRepository using the in-memory session."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
context.rs_plan_repo = PlanRepository(session=context.rs_db_session)
|
||||
context.rs_retrieved_plan = None
|
||||
context.rs_project_id = _make_project(context.rs_db_session)
|
||||
context.rs_db_session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _insert_plan_model(
|
||||
session: object,
|
||||
project_id: int,
|
||||
applied_at: datetime | None = None,
|
||||
error_message: str | None = None,
|
||||
result_success: bool | None = None,
|
||||
) -> int:
|
||||
"""Insert a PlanModel row directly and return its id."""
|
||||
db_plan = PlanModel(
|
||||
project_id=project_id,
|
||||
name="test-plan-7501",
|
||||
prompt="Test prompt for issue 7501",
|
||||
status="pending",
|
||||
current=False,
|
||||
applied_at=applied_at,
|
||||
error_message=error_message,
|
||||
result_success=result_success,
|
||||
)
|
||||
session.add(db_plan) # type: ignore[attr-defined]
|
||||
session.flush() # type: ignore[attr-defined]
|
||||
session.commit() # type: ignore[attr-defined]
|
||||
return int(db_plan.id) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a legacy plan with applied_at set and result_success column True")
|
||||
def step_plan_result_success_true(context: Context) -> None:
|
||||
"""Insert a plan row with result_success=True."""
|
||||
context.rs_plan_id = _insert_plan_model(
|
||||
session=context.rs_db_session,
|
||||
project_id=context.rs_project_id,
|
||||
applied_at=datetime.now(),
|
||||
error_message=None,
|
||||
result_success=True,
|
||||
)
|
||||
|
||||
|
||||
@given("a legacy plan with applied_at set and result_success column False")
|
||||
def step_plan_result_success_false(context: Context) -> None:
|
||||
"""Insert a plan row with result_success=False."""
|
||||
context.rs_plan_id = _insert_plan_model(
|
||||
session=context.rs_db_session,
|
||||
project_id=context.rs_project_id,
|
||||
applied_at=datetime.now(),
|
||||
error_message=None,
|
||||
result_success=False,
|
||||
)
|
||||
|
||||
|
||||
@given("a legacy plan with a build error_message and result_success column True")
|
||||
def step_plan_build_error_result_success_true(context: Context) -> None:
|
||||
"""Insert a plan row with a build error but result_success=True.
|
||||
|
||||
This is the core bug scenario: a plan that had a build error but later
|
||||
succeeded in the apply phase. Without the fix, success would be derived
|
||||
as False (because error_message is not None). With the fix, success is
|
||||
correctly derived as True from result_success.
|
||||
"""
|
||||
context.rs_plan_id = _insert_plan_model(
|
||||
session=context.rs_db_session,
|
||||
project_id=context.rs_project_id,
|
||||
applied_at=datetime.now(),
|
||||
error_message="Build phase error: compilation failed",
|
||||
result_success=True,
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
"a legacy plan with applied_at set and result_success column NULL and no error_message"
|
||||
)
|
||||
def step_plan_null_result_success_no_error(context: Context) -> None:
|
||||
"""Insert a plan row with result_success=NULL and no error_message.
|
||||
|
||||
Simulates a pre-migration record. The legacy heuristic should mark it
|
||||
as success=True because error_message is None.
|
||||
"""
|
||||
context.rs_plan_id = _insert_plan_model(
|
||||
session=context.rs_db_session,
|
||||
project_id=context.rs_project_id,
|
||||
applied_at=datetime.now(),
|
||||
error_message=None,
|
||||
result_success=None,
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
"a legacy plan with applied_at set and result_success column NULL and an error_message"
|
||||
)
|
||||
def step_plan_null_result_success_with_error(context: Context) -> None:
|
||||
"""Insert a plan row with result_success=NULL and an error_message.
|
||||
|
||||
Simulates a pre-migration record that failed. The legacy heuristic
|
||||
should mark it as success=False because error_message is not None.
|
||||
"""
|
||||
context.rs_plan_id = _insert_plan_model(
|
||||
session=context.rs_db_session,
|
||||
project_id=context.rs_project_id,
|
||||
applied_at=datetime.now(),
|
||||
error_message="Apply phase error: deployment failed",
|
||||
result_success=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("the legacy plan is retrieved from the repository")
|
||||
def step_retrieve_legacy_plan(context: Context) -> None:
|
||||
"""Retrieve the plan via PlanRepository.get_by_id."""
|
||||
context.rs_retrieved_plan = context.rs_plan_repo.get_by_id(context.rs_plan_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the retrieved plan result success should be True")
|
||||
def step_check_result_success_true(context: Context) -> None:
|
||||
"""Assert that the retrieved plan's result.success is True."""
|
||||
plan = context.rs_retrieved_plan
|
||||
assert plan is not None, "Expected a plan but got None"
|
||||
assert plan.result is not None, "Expected plan.result to be set but it was None"
|
||||
assert plan.result.success is True, (
|
||||
f"Expected plan.result.success=True but got {plan.result.success!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the retrieved plan result success should be False")
|
||||
def step_check_result_success_false(context: Context) -> None:
|
||||
"""Assert that the retrieved plan's result.success is False."""
|
||||
plan = context.rs_retrieved_plan
|
||||
assert plan is not None, "Expected a plan but got None"
|
||||
assert plan.result is not None, "Expected plan.result to be set but it was None"
|
||||
assert plan.result.success is False, (
|
||||
f"Expected plan.result.success=False but got {plan.result.success!r}"
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Step definitions for tdd_session_create_suppress_exception.feature.
|
||||
|
||||
This test captures bug #10414: ``session create`` in
|
||||
``src/cleveragents/cli/commands/session.py`` used
|
||||
``contextlib.suppress(Exception)`` to silently discard ALL exceptions raised
|
||||
by ``_facade_dispatch()`` without any logging. Programming errors,
|
||||
unexpected failures, and infrastructure issues in the facade dispatch were
|
||||
completely invisible.
|
||||
|
||||
The fix replaces the suppress block with a ``try/except Exception`` that
|
||||
calls ``_log.warning(..., exc_info=True)`` so that the exception is
|
||||
recorded but the session creation remains non-fatal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
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 import session as session_mod
|
||||
from cleveragents.cli.commands.session import app as session_app
|
||||
from cleveragents.domain.models.core.session import Session
|
||||
|
||||
# A distinctive error message to confirm the right exception was raised.
|
||||
_DISPATCH_ERROR_MESSAGE = "facade dispatch failed for tdd_10414"
|
||||
|
||||
|
||||
def _make_mock_session() -> MagicMock:
|
||||
"""Build a minimal mock Session object sufficient for the create command."""
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session.session_id = "01JTEST000000000000000000000"
|
||||
mock_session.actor_name = None
|
||||
mock_session.namespace = "default"
|
||||
mock_session.message_count = 0
|
||||
mock_session.created_at = datetime(2026, 1, 1, 0, 0, 0)
|
||||
mock_session.updated_at = datetime(2026, 1, 1, 0, 0, 0)
|
||||
return mock_session
|
||||
|
||||
|
||||
@given("a session create command with a mocked session service")
|
||||
def step_given_mocked_session_service(context: Context) -> None:
|
||||
"""Set up a CLI runner with a mocked session service.
|
||||
|
||||
The mock service returns a valid session so that the create command
|
||||
proceeds past the service call and reaches the ``_facade_dispatch``
|
||||
try/except block.
|
||||
"""
|
||||
context.runner = CliRunner()
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.create.return_value = _make_mock_session()
|
||||
|
||||
# Patch the module-level service so the CLI uses our mock.
|
||||
context._orig_service = session_mod._service
|
||||
session_mod._service = mock_service
|
||||
|
||||
def _cleanup() -> None:
|
||||
session_mod._service = context._orig_service
|
||||
|
||||
context.add_cleanup(_cleanup)
|
||||
|
||||
|
||||
@given("the facade dispatch is patched to raise a RuntimeError")
|
||||
def step_given_facade_dispatch_raises(context: Context) -> None:
|
||||
"""Patch ``_facade_dispatch`` so it always raises a RuntimeError.
|
||||
|
||||
This simulates a real failure in the facade layer (e.g. the A2A
|
||||
bootstrap is unavailable) and exercises the ``try/except`` block in
|
||||
the ``create`` command.
|
||||
"""
|
||||
context._facade_patcher = patch(
|
||||
"cleveragents.cli.commands.session._facade_dispatch",
|
||||
side_effect=RuntimeError(_DISPATCH_ERROR_MESSAGE),
|
||||
)
|
||||
context._facade_patcher.start()
|
||||
|
||||
def _cleanup() -> None:
|
||||
context._facade_patcher.stop()
|
||||
|
||||
context.add_cleanup(_cleanup)
|
||||
|
||||
|
||||
@when("I invoke the session create command via the CLI runner")
|
||||
def step_when_invoke_create(context: Context) -> None:
|
||||
"""Invoke ``session create`` and capture log records during the call."""
|
||||
# Capture WARNING-level log records from the session module logger.
|
||||
session_logger = logging.getLogger("cleveragents.cli.commands.session")
|
||||
handler = _CapturingHandler()
|
||||
handler.setLevel(logging.WARNING)
|
||||
session_logger.addHandler(handler)
|
||||
session_logger.setLevel(logging.WARNING)
|
||||
|
||||
try:
|
||||
context.result = context.runner.invoke(session_app, ["create"])
|
||||
finally:
|
||||
session_logger.removeHandler(handler)
|
||||
|
||||
context.captured_log_records = handler.records
|
||||
|
||||
|
||||
class _CapturingHandler(logging.Handler):
|
||||
"""A logging handler that stores all emitted records in a list."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.records: list[logging.LogRecord] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
@then("a WARNING log entry should have been emitted for the facade dispatch failure")
|
||||
def step_then_warning_logged(context: Context) -> None:
|
||||
"""Assert that a WARNING log entry was emitted when the facade dispatch raised.
|
||||
|
||||
The fix replaces ``contextlib.suppress(Exception)`` with a
|
||||
``try/except Exception`` block that calls ``_log.warning(..., exc_info=True)``.
|
||||
This assertion verifies the fix is in place.
|
||||
"""
|
||||
# The session create command should still succeed (non-fatal).
|
||||
assert context.result.exit_code == 0, (
|
||||
f"Expected session create to exit 0 even when facade dispatch fails, "
|
||||
f"but got exit code {context.result.exit_code}.\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
# Assert that a WARNING log record was emitted.
|
||||
warning_records = [
|
||||
r for r in context.captured_log_records if r.levelno >= logging.WARNING
|
||||
]
|
||||
assert warning_records, (
|
||||
f"Bug #10414: No WARNING log record was emitted when _facade_dispatch() "
|
||||
f"raised a RuntimeError inside the try/except block. "
|
||||
f"Captured records: {context.captured_log_records}"
|
||||
)
|
||||
|
||||
# Assert the log record references the facade dispatch failure.
|
||||
all_messages = " ".join(r.getMessage() for r in warning_records)
|
||||
assert _DISPATCH_ERROR_MESSAGE in all_messages or any(
|
||||
r.exc_info is not None for r in warning_records
|
||||
), (
|
||||
f"Bug #10414: WARNING log record was found but did not contain the "
|
||||
f"expected error message {_DISPATCH_ERROR_MESSAGE!r} or exc_info. "
|
||||
f"Log messages: {all_messages!r}"
|
||||
)
|
||||
@@ -82,20 +82,20 @@ def _build_mock_textual():
|
||||
def update(self, text):
|
||||
self._text = text
|
||||
|
||||
class MockTextArea:
|
||||
"""Minimal TextArea stand-in for the Textual base class."""
|
||||
class MockInput:
|
||||
"""Minimal Input stand-in for the Textual base class."""
|
||||
|
||||
text = ""
|
||||
value = ""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.text = ""
|
||||
self.value = ""
|
||||
|
||||
mock_textual_app.App = MockApp
|
||||
mock_textual_containers.Vertical = MockVertical
|
||||
mock_textual_widgets.Header = MockHeader
|
||||
mock_textual_widgets.Footer = MockFooter
|
||||
mock_textual_widgets.Static = MockStatic
|
||||
mock_textual_widgets.TextArea = MockTextArea
|
||||
mock_textual_widgets.Input = MockInput
|
||||
|
||||
return {
|
||||
"textual": mock_textual,
|
||||
@@ -114,7 +114,7 @@ def _install_mock_textual(context):
|
||||
for key, mod in mocks.items():
|
||||
sys.modules[key] = mod
|
||||
|
||||
# Reload widget modules so they pick up the mock Static/TextArea base class
|
||||
# Reload widget modules so they pick up the mock Static/Input base class
|
||||
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
|
||||
import cleveragents.tui.widgets.persona_bar as pb_mod
|
||||
import cleveragents.tui.widgets.prompt as prompt_mod
|
||||
@@ -142,7 +142,7 @@ def _restore_modules(context):
|
||||
else:
|
||||
sys.modules[key] = val
|
||||
|
||||
# Reload widget modules so they pick up the real Static/TextArea base class again
|
||||
# Reload widget modules so they pick up the real Static/Input base class again
|
||||
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
|
||||
import cleveragents.tui.widgets.persona_bar as pb_mod
|
||||
import cleveragents.tui.widgets.prompt as prompt_mod
|
||||
@@ -370,7 +370,7 @@ def step_set_prompt_text(context, text):
|
||||
from cleveragents.tui.widgets.prompt import PromptInput
|
||||
|
||||
prompt = context._tui_app.query_one("#prompt", PromptInput)
|
||||
prompt.text = text
|
||||
prompt.value = text
|
||||
|
||||
|
||||
@then('the conversation widget should contain "{text}"')
|
||||
@@ -425,11 +425,11 @@ def step_persona_bar_shows_name(context):
|
||||
# on_input_submitted helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _submit_text(context, text):
|
||||
"""Set prompt text and fire on_input_submitted."""
|
||||
"""Set prompt value and fire on_input_submitted."""
|
||||
from cleveragents.tui.widgets.prompt import PromptInput
|
||||
|
||||
prompt = context._tui_app.query_one("#prompt", PromptInput)
|
||||
prompt.text = text
|
||||
prompt.value = text
|
||||
event = SimpleNamespace()
|
||||
context._tui_app.on_input_submitted(event)
|
||||
|
||||
|
||||
@@ -143,13 +143,3 @@ def step_run_fallback_tui_app(context: Context) -> None:
|
||||
def step_fallback_tui_fails(context: Context, message: str) -> None:
|
||||
assert context.tui_fallback_error is not None
|
||||
assert message in str(context.tui_fallback_error)
|
||||
|
||||
|
||||
@when('I detect mode for "{text}"')
|
||||
def step_detect_mode(context: Context, text: str) -> None:
|
||||
context.detected_mode = InputModeRouter.detect_mode(text)
|
||||
|
||||
|
||||
@then('the detected mode should be "{mode}"')
|
||||
def step_detected_mode_equals(context: Context, mode: str) -> None:
|
||||
assert context.detected_mode.value == mode
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Behave steps for TUI prompt symbol handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.tui.widgets.prompt import PromptInput
|
||||
|
||||
|
||||
@given("a TUI prompt widget")
|
||||
def step_create_prompt(context: Context) -> None:
|
||||
context.tui_prompt = PromptInput(
|
||||
placeholder="Type message, /command, or !shell ..."
|
||||
)
|
||||
|
||||
|
||||
@when('I set the TUI prompt value to "{value}"')
|
||||
def step_set_prompt_value(context: Context, value: str) -> None:
|
||||
context.tui_prompt.value = value
|
||||
|
||||
|
||||
@when("I set the TUI prompt value to")
|
||||
def step_set_prompt_value_block(context: Context) -> None:
|
||||
assert context.text is not None
|
||||
context.tui_prompt.value = context.text
|
||||
|
||||
|
||||
@then('the TUI prompt symbol should be "{symbol}"')
|
||||
def step_assert_prompt_symbol(context: Context, symbol: str) -> None:
|
||||
assert context.tui_prompt.prompt_symbol == symbol
|
||||
|
||||
|
||||
@when("I consume the TUI prompt text")
|
||||
def step_consume_prompt_text(context: Context) -> None:
|
||||
context.tui_consumed_prompt = context.tui_prompt.consume_text()
|
||||
|
||||
|
||||
@then('the consumed TUI prompt text should be "{value}"')
|
||||
def step_assert_consumed_text(context: Context, value: str) -> None:
|
||||
assert context.tui_consumed_prompt.text == value
|
||||
@@ -1,217 +0,0 @@
|
||||
"""Step definitions for tui_prompt_textarea.feature.
|
||||
|
||||
Tests that PromptInput uses TextArea (multi-line) instead of Input (single-line).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from typing import Any
|
||||
from types import ModuleType
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
_MOCK_TEXTUAL_KEYS = [
|
||||
"textual",
|
||||
"textual.app",
|
||||
"textual.containers",
|
||||
"textual.widgets",
|
||||
]
|
||||
|
||||
|
||||
def _build_mock_textual_with_textarea():
|
||||
"""Build mock textual modules that expose TextArea."""
|
||||
mock_textual = ModuleType("textual")
|
||||
mock_textual_app = ModuleType("textual.app")
|
||||
mock_textual_containers = ModuleType("textual.containers")
|
||||
mock_textual_widgets = ModuleType("textual.widgets")
|
||||
|
||||
class MockTextArea:
|
||||
"""Minimal TextArea stand-in for the Textual base class."""
|
||||
|
||||
text: str = ""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.text = ""
|
||||
|
||||
mock_textual_app.App = object
|
||||
mock_textual_containers.Vertical = object
|
||||
mock_textual_widgets.Header = object
|
||||
mock_textual_widgets.Footer = object
|
||||
mock_textual_widgets.Static = object
|
||||
mock_textual_widgets.TextArea = MockTextArea
|
||||
|
||||
return {
|
||||
"textual": mock_textual,
|
||||
"textual.app": mock_textual_app,
|
||||
"textual.containers": mock_textual_containers,
|
||||
"textual.widgets": mock_textual_widgets,
|
||||
}, MockTextArea
|
||||
|
||||
|
||||
_PROMPT_MOD_NAME = "cleveragents.tui.widgets.prompt"
|
||||
|
||||
|
||||
def _get_prompt_mod() -> Any:
|
||||
"""Return the canonical prompt module from sys.modules.
|
||||
|
||||
Uses ``importlib.import_module`` (which always returns
|
||||
``sys.modules[name]``) instead of ``import cleveragents.tui.widgets.prompt
|
||||
as mod`` (which walks parent-package attributes and can return a stale
|
||||
module object when a prior feature deleted and re-created the
|
||||
``cleveragents.tui.*`` namespace). The stale object causes
|
||||
``importlib.reload()`` to fail with
|
||||
``ImportError: module ... not in sys.modules`` because Python 3.13's
|
||||
reload checks ``sys.modules.get(name) is module``.
|
||||
"""
|
||||
return importlib.import_module(_PROMPT_MOD_NAME)
|
||||
|
||||
|
||||
def _install_mock_textual(context: Any) -> None:
|
||||
"""Inject mock textual into sys.modules and reload the prompt module."""
|
||||
mocks, mock_textarea_cls = _build_mock_textual_with_textarea()
|
||||
context._prompt_saved_modules = {}
|
||||
for key in _MOCK_TEXTUAL_KEYS:
|
||||
context._prompt_saved_modules[key] = sys.modules.pop(key, None)
|
||||
for key, mod in mocks.items():
|
||||
sys.modules[key] = mod
|
||||
|
||||
prompt_mod = _get_prompt_mod()
|
||||
importlib.reload(prompt_mod)
|
||||
context._prompt_mod = prompt_mod
|
||||
context._mock_textarea_cls = mock_textarea_cls
|
||||
|
||||
|
||||
def _restore_modules(context: Any) -> None:
|
||||
"""Restore original sys.modules and reload the prompt module."""
|
||||
for key, val in getattr(context, "_prompt_saved_modules", {}).items():
|
||||
if val is None:
|
||||
sys.modules.pop(key, None)
|
||||
else:
|
||||
sys.modules[key] = val
|
||||
|
||||
importlib.reload(_get_prompt_mod())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("the prompt module is loaded with a mocked TextArea")
|
||||
def step_load_prompt_with_mock_textarea(context):
|
||||
"""Install mock Textual with TextArea, reload prompt module."""
|
||||
_install_mock_textual(context)
|
||||
context.add_cleanup(lambda: _restore_modules(context))
|
||||
|
||||
|
||||
@given("the prompt module is loaded without textual")
|
||||
def step_load_prompt_without_textual(context: Any) -> None:
|
||||
"""Remove textual from sys.modules so the fallback path is used."""
|
||||
context._prompt_saved_modules_fallback = {}
|
||||
for key in _MOCK_TEXTUAL_KEYS:
|
||||
context._prompt_saved_modules_fallback[key] = sys.modules.pop(key, None)
|
||||
|
||||
prompt_mod = _get_prompt_mod()
|
||||
importlib.reload(prompt_mod)
|
||||
context._prompt_mod_fallback = prompt_mod
|
||||
|
||||
def restore() -> None:
|
||||
for key, val in context._prompt_saved_modules_fallback.items():
|
||||
if val is None:
|
||||
sys.modules.pop(key, None)
|
||||
else:
|
||||
sys.modules[key] = val
|
||||
importlib.reload(_get_prompt_mod())
|
||||
|
||||
context.add_cleanup(restore)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: PromptInput base class is TextArea not Input
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the PromptInput base class should be the mocked TextArea")
|
||||
def step_base_class_is_textarea(context):
|
||||
PromptInput = context._prompt_mod.PromptInput
|
||||
assert issubclass(PromptInput, context._mock_textarea_cls), (
|
||||
f"Expected PromptInput to subclass MockTextArea, "
|
||||
f"but got bases: {PromptInput.__bases__}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: PromptInput exposes a text property not value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I create a PromptInput instance")
|
||||
def step_create_prompt_input(context):
|
||||
context._prompt_instance = context._prompt_mod.PromptInput()
|
||||
|
||||
|
||||
@then("the PromptInput instance should have a text attribute")
|
||||
def step_has_text_attribute(context):
|
||||
assert hasattr(context._prompt_instance, "text"), (
|
||||
"PromptInput instance should have a 'text' attribute"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: consume_text returns the current text content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I set the PromptInput text to "{text}"')
|
||||
def step_set_prompt_input_text(context, text):
|
||||
context._prompt_instance.text = text
|
||||
|
||||
|
||||
@when("I call consume_text on the PromptInput")
|
||||
def step_call_consume_text(context):
|
||||
context._prompt_submitted = context._prompt_instance.consume_text()
|
||||
|
||||
|
||||
@then('the PromptSubmitted text should be "{expected}"')
|
||||
def step_prompt_submitted_text(context, expected):
|
||||
assert context._prompt_submitted.text == expected, (
|
||||
f"Expected '{expected}', got '{context._prompt_submitted.text}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: consume_text clears the text after consuming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the PromptInput text should be empty")
|
||||
def step_prompt_input_text_empty(context):
|
||||
assert context._prompt_instance.text == "", (
|
||||
f"Expected empty text, got '{context._prompt_instance.text}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: PromptInput fallback uses text attribute when TextArea unavailable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I create a PromptInput instance from the fallback")
|
||||
def step_create_fallback_prompt_input(context):
|
||||
context._fallback_prompt_instance = context._prompt_mod_fallback.PromptInput()
|
||||
|
||||
|
||||
@then("the fallback PromptInput instance should have a text attribute")
|
||||
def step_fallback_has_text_attribute(context):
|
||||
assert hasattr(context._fallback_prompt_instance, "text"), (
|
||||
"Fallback PromptInput instance should have a 'text' attribute"
|
||||
)
|
||||
|
||||
|
||||
@then("the fallback PromptInput text should be empty string")
|
||||
def step_fallback_text_empty(context):
|
||||
assert context._fallback_prompt_instance.text == "", (
|
||||
f"Expected empty string, got '{context._fallback_prompt_instance.text}'"
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
Feature: Decision recording hook in Strategize phase
|
||||
As a strategy actor
|
||||
I want to record decisions during the Strategize phase
|
||||
So that every choice point is captured with full context for replay and correction
|
||||
|
||||
Background:
|
||||
Given a strategize decision service
|
||||
And a strategize decision hook for plan "01JQAAAAAAAAAAAAAAAAAAAA01"
|
||||
|
||||
# --- Strategy Choice Recording ---
|
||||
|
||||
Scenario: Record a strategy choice decision
|
||||
When I record a strategy choice with question "Which approach?" and option "Approach A"
|
||||
Then the strat decision should be recorded successfully
|
||||
And the strat decision type should be "strategy_choice"
|
||||
And the strat decision question should be "Which approach?"
|
||||
And the strat decision chosen_option should be "Approach A"
|
||||
And the strat decision phase should be "strategize"
|
||||
|
||||
Scenario: Record strategy choice with alternatives
|
||||
When two strat alternatives "Approach B" and "Approach C"
|
||||
And I record a strategy choice with question "Which approach?" and option "Approach A"
|
||||
Then the strat decision should have 2 alternatives considered
|
||||
|
||||
Scenario: Record strategy choice with confidence score
|
||||
When strat confidence 0.85
|
||||
And I record a strategy choice with question "Which approach?" and option "Approach A"
|
||||
Then the strat decision confidence score should be 0.85
|
||||
|
||||
Scenario: Record strategy choice with rationale
|
||||
When strat rationale "Approach A is more efficient"
|
||||
And I record a strategy choice with question "Which approach?" and option "Approach A"
|
||||
Then the strat decision rationale should be "Approach A is more efficient"
|
||||
|
||||
Scenario: Record strategy choice with context snapshot
|
||||
When strat context data containing "key1" "value1"
|
||||
And I record a strategy choice with question "Which approach?" and option "Approach A"
|
||||
Then the strat decision context snapshot hash should start with "sha256:"
|
||||
And the strat decision context snapshot ref should not be empty
|
||||
|
||||
Scenario: Record strategy choice with actor state
|
||||
When strat actor state containing "reasoning" "step1"
|
||||
And I record a strategy choice with question "Which approach?" and option "Approach A"
|
||||
Then the strat decision actor state ref should not be empty
|
||||
|
||||
Scenario: Record strategy choice with relevant resources
|
||||
When two strat relevant resources "resource1" and "resource2"
|
||||
And I record a strategy choice with question "Which approach?" and option "Approach A"
|
||||
Then the strat decision should have 2 relevant resources
|
||||
|
||||
Scenario: Record strategy choice with empty question raises error
|
||||
When I try to record a strategy choice with empty question
|
||||
Then a strat validation error should be raised
|
||||
And the strat error should mention "question"
|
||||
|
||||
Scenario: Record strategy choice with empty option raises error
|
||||
When I try to record a strategy choice with empty chosen_option
|
||||
Then a strat validation error should be raised
|
||||
And the strat error should mention "chosen_option"
|
||||
|
||||
# --- Resource Selection Recording ---
|
||||
|
||||
Scenario: Record a resource selection decision
|
||||
When I record a resource selection with question "Which resources?" and option "src/main.py"
|
||||
Then the strat decision should be recorded successfully
|
||||
And the strat decision type should be "resource_selection"
|
||||
And the strat decision question should be "Which resources?"
|
||||
And the strat decision chosen_option should be "src/main.py"
|
||||
|
||||
Scenario: Record resource selection with alternatives
|
||||
When two strat alternatives "src/test.py" and "src/utils.py"
|
||||
And I record a resource selection with question "Which resources?" and option "src/main.py"
|
||||
Then the strat decision should have 2 alternatives considered
|
||||
|
||||
Scenario: Record resource selection with confidence
|
||||
When strat confidence 0.9
|
||||
And I record a resource selection with question "Which resources?" and option "src/main.py"
|
||||
Then the strat decision confidence score should be 0.9
|
||||
|
||||
# --- Subplan Spawn Recording ---
|
||||
|
||||
Scenario: Record a subplan spawn decision
|
||||
When I record a subplan spawn with question "Should we decompose?" and option "Create subplan for feature X"
|
||||
Then the strat decision should be recorded successfully
|
||||
And the strat decision type should be "subplan_spawn"
|
||||
And the strat decision question should be "Should we decompose?"
|
||||
And the strat decision chosen_option should be "Create subplan for feature X"
|
||||
|
||||
Scenario: Record subplan spawn with alternatives
|
||||
When two strat alternatives "Implement inline" and "Create parallel subplans"
|
||||
And I record a subplan spawn with question "Should we decompose?" and option "Create subplan for feature X"
|
||||
Then the strat decision should have 2 alternatives considered
|
||||
|
||||
Scenario: Record subplan spawn with confidence
|
||||
When strat confidence 0.75
|
||||
And I record a subplan spawn with question "Should we decompose?" and option "Create subplan for feature X"
|
||||
Then the strat decision confidence score should be 0.75
|
||||
|
||||
# --- Invariant Enforcement Recording ---
|
||||
|
||||
Scenario: Record an invariant enforced decision
|
||||
When I record an invariant enforced with question "Apply security invariant?" and option "Enforce code review"
|
||||
Then the strat decision should be recorded successfully
|
||||
And the strat decision type should be "invariant_enforced"
|
||||
And the strat decision question should be "Apply security invariant?"
|
||||
And the strat decision chosen_option should be "Enforce code review"
|
||||
|
||||
Scenario: Record invariant enforced with rationale
|
||||
When strat rationale "Security policy requires code review"
|
||||
And I record an invariant enforced with question "Apply security invariant?" and option "Enforce code review"
|
||||
Then the strat decision rationale should be "Security policy requires code review"
|
||||
|
||||
# --- Context Snapshot Capture ---
|
||||
|
||||
Scenario: Context snapshot captures hot context hash
|
||||
When strat context data containing "plan_id" "01JQAAAAAAAAAAAAAAAAAAAA01"
|
||||
And I record a strategy choice with question "Which approach?" and option "Approach A"
|
||||
Then the strat decision context snapshot hash should start with "sha256:"
|
||||
|
||||
Scenario: Context snapshot captures actor state reference
|
||||
When strat actor state containing "step" "1"
|
||||
And I record a strategy choice with question "Which approach?" and option "Approach A"
|
||||
Then the strat decision actor state ref should start with "checkpoint:"
|
||||
|
||||
Scenario: Context snapshot captures relevant resources
|
||||
When three strat relevant resources "res1" and "res2" and "res3"
|
||||
And I record a strategy choice with question "Which approach?" and option "Approach A"
|
||||
Then the strat decision should have 3 relevant resources
|
||||
And each strat resource should have a valid resource_id
|
||||
|
||||
# --- Error Handling ---
|
||||
|
||||
Scenario: Recording with invalid plan_id raises error
|
||||
Given a strategize decision hook with empty plan_id
|
||||
Then a strat validation error should be raised
|
||||
And the strat error should mention "plan_id"
|
||||
|
||||
Scenario: Recording failure logs warning and re-raises exception
|
||||
When the strat decision service fails to persist
|
||||
And I try to record a strategy choice that raises an exception
|
||||
Then a strat warning should be logged
|
||||
And the strat exception should be re-raised
|
||||
|
||||
# --- Parent Decision Tracking ---
|
||||
|
||||
Scenario: Record decision with parent decision ID
|
||||
When strat parent decision ID "01PARENT000000000000000000"
|
||||
And I record a strategy choice with question "Which approach?" and option "Approach A"
|
||||
Then the strat decision parent_decision_id should be "01PARENT000000000000000000"
|
||||
|
||||
Scenario: Record multiple decisions in tree structure
|
||||
When I record a strategy choice with question "Q1" and option "A1"
|
||||
And I save the first strat decision
|
||||
And strat parent decision ID from the first decision
|
||||
And I record a strategy choice with question "Q2" and option "A2"
|
||||
Then the second strat decision parent_decision_id should match the first decision
|
||||
And both strat decisions should be in the same plan
|
||||
@@ -25,7 +25,6 @@ Feature: TDD Issue #988 — ReactiveEventBus.emit() swallows exception details
|
||||
When I emit an event that triggers the failing handler
|
||||
Then the warning log should contain the exception message text
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Bug #988 — emit() logs traceback via exc_info when handler raises
|
||||
Given a ReactiveEventBus with a handler that raises a ValueError
|
||||
When I emit an event that triggers the failing handler
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
@tdd_issue @tdd_issue_7501
|
||||
Feature: TDD Issue #7501 — PlanResult.success derived from result_success column
|
||||
As a system operator managing plan lifecycle
|
||||
I want PlanResult.success to be derived from the dedicated result_success column
|
||||
So that plans with historical build errors are not incorrectly marked as failed
|
||||
|
||||
The root cause is that PlanRepository._to_domain derived PlanResult.success
|
||||
from `error_message is None`. Because error_message is shared between the
|
||||
build phase and the result phase, a plan that had a build error but later
|
||||
succeeded in the apply phase would be incorrectly reconstructed as failed.
|
||||
|
||||
The fix adds a dedicated result_success column to the plans table and updates
|
||||
_to_domain to use it. For backward compatibility, when result_success is NULL
|
||||
(pre-migration records), the legacy heuristic (error_message is None) is used.
|
||||
|
||||
Background:
|
||||
Given a clean in-memory database for plan result success tests
|
||||
And a legacy plan repository backed by the database
|
||||
|
||||
Scenario: Plan with result_success=True is reconstructed as success=True
|
||||
Given a legacy plan with applied_at set and result_success column True
|
||||
When the legacy plan is retrieved from the repository
|
||||
Then the retrieved plan result success should be True
|
||||
|
||||
Scenario: Plan with result_success=False is reconstructed as success=False
|
||||
Given a legacy plan with applied_at set and result_success column False
|
||||
When the legacy plan is retrieved from the repository
|
||||
Then the retrieved plan result success should be False
|
||||
|
||||
Scenario: Plan with build error but result_success=True is reconstructed as success=True
|
||||
Given a legacy plan with a build error_message and result_success column True
|
||||
When the legacy plan is retrieved from the repository
|
||||
Then the retrieved plan result success should be True
|
||||
|
||||
Scenario: Plan with NULL result_success falls back to error_message heuristic when no error
|
||||
Given a legacy plan with applied_at set and result_success column NULL and no error_message
|
||||
When the legacy plan is retrieved from the repository
|
||||
Then the retrieved plan result success should be True
|
||||
|
||||
Scenario: Plan with NULL result_success falls back to error_message heuristic when error present
|
||||
Given a legacy plan with applied_at set and result_success column NULL and an error_message
|
||||
When the legacy plan is retrieved from the repository
|
||||
Then the retrieved plan result success should be False
|
||||
@@ -0,0 +1,25 @@
|
||||
@tdd_issue @tdd_issue_10414
|
||||
Feature: TDD Issue #10414 — session create silently suppresses facade dispatch exceptions without logging
|
||||
As a developer debugging a session creation failure
|
||||
I want exceptions from _facade_dispatch() to be logged at WARNING level
|
||||
So that I can diagnose why the facade layer failed without silent data loss
|
||||
|
||||
The ``session create`` command in
|
||||
``src/cleveragents/cli/commands/session.py`` previously used
|
||||
``contextlib.suppress(Exception)`` to silently discard ALL exceptions
|
||||
raised by ``_facade_dispatch()``. There was no logging call before or
|
||||
after the suppress block, making it impossible to diagnose failures in
|
||||
the facade layer.
|
||||
|
||||
The fix replaces the suppress block with a ``try/except Exception`` that
|
||||
calls ``_log.warning(..., exc_info=True)`` so that the exception is
|
||||
recorded but the session creation remains non-fatal.
|
||||
|
||||
See CONTRIBUTING.md > Bug Fix Workflow > TDD Issue Test Tags.
|
||||
|
||||
@tdd_issue @tdd_issue_10414
|
||||
Scenario: Bug #10414 — session create logs a warning when facade dispatch raises
|
||||
Given a session create command with a mocked session service
|
||||
And the facade dispatch is patched to raise a RuntimeError
|
||||
When I invoke the session create command via the CLI runner
|
||||
Then a WARNING log entry should have been emitted for the facade dispatch failure
|
||||
@@ -37,23 +37,3 @@ Feature: TUI input modes
|
||||
Then TUI textual availability should be boolean
|
||||
When I run fallback TUI app
|
||||
Then fallback TUI app should fail with "Textual dependency missing."
|
||||
|
||||
Scenario: Dollar prefix activates shell mode
|
||||
When I route TUI input "$echo hello"
|
||||
Then the TUI mode should be "shell"
|
||||
And the TUI shell stdout should contain "hello"
|
||||
|
||||
Scenario: Dollar prefix detect_mode returns shell
|
||||
When I detect mode for "$echo hello"
|
||||
Then the detected mode should be "shell"
|
||||
|
||||
|
||||
Scenario: Dollar prefix with leading whitespace activates shell mode
|
||||
When I detect mode for " $echo hello"
|
||||
Then the detected mode should be "shell"
|
||||
|
||||
|
||||
Scenario: Dollar prefix blocks dangerous command by default
|
||||
When I route TUI input "$rm -rf /"
|
||||
Then the TUI mode should be "shell"
|
||||
And the TUI shell stderr should contain "blocked dangerous shell command"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
Feature: TUI prompt symbol reflects input mode
|
||||
The prompt must display the correct mode symbol so users know
|
||||
whether they are typing a normal message, a command, or shell input.
|
||||
|
||||
Scenario Outline: Symbol updates when mode changes
|
||||
Given a TUI prompt widget
|
||||
When I set the TUI prompt value to "<input>"
|
||||
Then the TUI prompt symbol should be "<symbol>"
|
||||
|
||||
Examples:
|
||||
| input | symbol |
|
||||
| hello | ❯ |
|
||||
| /help | / |
|
||||
| !ls | $ |
|
||||
|
||||
Scenario: Consuming text resets the prompt symbol
|
||||
Given a TUI prompt widget
|
||||
When I set the TUI prompt value to "/metrics"
|
||||
And I consume the TUI prompt text
|
||||
Then the TUI prompt symbol should be "❯"
|
||||
And the consumed TUI prompt text should be "/metrics"
|
||||
|
||||
Scenario: Multi-line input toggles the multi-line prompt symbol
|
||||
Given a TUI prompt widget
|
||||
When I set the TUI prompt value to
|
||||
"""
|
||||
line one
|
||||
line two
|
||||
"""
|
||||
Then the TUI prompt symbol should be "☰"
|
||||
@@ -1,37 +0,0 @@
|
||||
Feature: PromptInput uses multi-line TextArea widget
|
||||
The PromptInput widget must use a multi-line TextArea widget (not a
|
||||
single-line Input widget) to enable multi-line prompt composition.
|
||||
|
||||
Background:
|
||||
Given the prompt module is loaded with a mocked TextArea
|
||||
|
||||
Scenario: PromptInput base class is TextArea not Input
|
||||
Then the PromptInput base class should be the mocked TextArea
|
||||
|
||||
Scenario: PromptInput exposes a text property not value
|
||||
When I create a PromptInput instance
|
||||
Then the PromptInput instance should have a text attribute
|
||||
|
||||
Scenario: consume_text returns the current text content
|
||||
When I create a PromptInput instance
|
||||
And I set the PromptInput text to "hello world"
|
||||
And I call consume_text on the PromptInput
|
||||
Then the PromptSubmitted text should be "hello world"
|
||||
|
||||
Scenario: consume_text clears the text after consuming
|
||||
When I create a PromptInput instance
|
||||
And I set the PromptInput text to "some prompt"
|
||||
And I call consume_text on the PromptInput
|
||||
Then the PromptInput text should be empty
|
||||
|
||||
Scenario: consume_text supports multi-line text
|
||||
When I create a PromptInput instance
|
||||
And I set the PromptInput text to "line one\nline two\nline three"
|
||||
And I call consume_text on the PromptInput
|
||||
Then the PromptSubmitted text should be "line one\nline two\nline three"
|
||||
|
||||
Scenario: PromptInput fallback uses text attribute when TextArea unavailable
|
||||
Given the prompt module is loaded without textual
|
||||
When I create a PromptInput instance from the fallback
|
||||
Then the fallback PromptInput instance should have a text attribute
|
||||
And the fallback PromptInput text should be empty string
|
||||
@@ -111,6 +111,13 @@ def _install_behave_parallel(session: nox.Session) -> None:
|
||||
runner_script = Path(__file__).parent / "scripts" / "run_behave_parallel.py"
|
||||
(pkg_dir / "cli.py").write_text(runner_script.read_text(encoding="utf-8"))
|
||||
|
||||
formatter_script = (
|
||||
Path(__file__).parent / "scripts" / "behave_pass_suppress_formatter.py"
|
||||
)
|
||||
(pkg_dir / "behave_pass_suppress_formatter.py").write_text(
|
||||
formatter_script.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
setup_path = source_dir / "setup.py"
|
||||
setup_path.write_text(
|
||||
"from setuptools import find_packages, setup\n"
|
||||
|
||||
@@ -26,6 +26,15 @@ def _load_runner_module() -> ModuleType:
|
||||
first or modify the path to be anchored relative to ``__file__``.
|
||||
"""
|
||||
script_path = Path("scripts") / "run_behave_parallel.py"
|
||||
# Ensure the scripts/ directory is on sys.path so that
|
||||
# ``run_behave_parallel.py`` can ``from behave_pass_suppress_formatter
|
||||
# import PassSuppressFormatter``. This is necessary when the helper is
|
||||
# invoked outside of a nox session (e.g. integration tests), where the
|
||||
# behave_parallel package created by noxfile.py for unit_tests is not
|
||||
# available.
|
||||
scripts_dir = str(script_path.parent.resolve())
|
||||
if scripts_dir not in sys.path:
|
||||
sys.path.insert(0, scripts_dir)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"run_behave_parallel", str(script_path)
|
||||
)
|
||||
|
||||
@@ -23,8 +23,7 @@ TDD Resource Add Succeeds Without Explicit Init
|
||||
... The helper exits 0 with a sentinel when the command
|
||||
... succeeds (bug is fixed), and exits 1 when the bug is
|
||||
... present (OperationalError).
|
||||
[Tags] tdd_issue tdd_issue_1023 tdd_issue tdd_issue_4317 tdd_expected_fail
|
||||
|
||||
[Tags] tdd_issue tdd_issue_1023 tdd_issue_4317 tdd_expected_fail
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resource-add-no-init cwd=${WORKSPACE} timeout=120s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
@@ -38,8 +37,7 @@ TDD Project Create Succeeds Without Explicit Init
|
||||
... The helper exits 0 with a sentinel when the command
|
||||
... succeeds (bug is fixed), and exits 1 when the bug is
|
||||
... present (OperationalError).
|
||||
[Tags] tdd_issue tdd_issue_1023 tdd_issue tdd_issue_4317 tdd_expected_fail
|
||||
|
||||
[Tags] tdd_issue tdd_issue_1023 tdd_issue_4317 tdd_expected_fail
|
||||
${result}= Run Process ${PYTHON} ${HELPER} project-create-no-init cwd=${WORKSPACE} timeout=120s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
|
||||
+33
-1
@@ -17,6 +17,38 @@ TUI Headless Works When Shell Disabled
|
||||
Should Contain ${result.stdout} textual_available
|
||||
Should Contain ${result.stdout} default_persona
|
||||
|
||||
TUI Prompt Symbol Updates For Input Modes
|
||||
[Tags] regression tdd_issue tdd_issue_6431 prompt_symbol
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... import sys
|
||||
... from pathlib import Path
|
||||
...
|
||||
... sys.path.insert(0, str((Path.cwd() / "src").resolve()))
|
||||
... from cleveragents.tui.widgets.prompt import PromptInput
|
||||
...
|
||||
... prompt = PromptInput()
|
||||
... assert prompt.prompt_symbol == "❯", f"Expected normal mode symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... prompt.value = "/help"
|
||||
... assert prompt.prompt_symbol == "/", f"Expected command mode symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... prompt.value = "!ls"
|
||||
... assert prompt.prompt_symbol == "$", f"Expected shell mode symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... prompt.value = "@plan/123"
|
||||
... assert prompt.prompt_symbol == "❯", f"References should keep normal symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... prompt.value = "line one\\nline two"
|
||||
... assert prompt.prompt_symbol == "☰", f"Expected multiline symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... prompt.value = ""
|
||||
... assert prompt.prompt_symbol == "❯", f"Prompt should reset to normal symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... print("prompt-symbol-modes-ok")
|
||||
${result}= Run Process ${PYTHON} -c ${script} shell=False
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} prompt-symbol-modes-ok
|
||||
|
||||
TUI Input Mode Router And Prompt Widget Behavior
|
||||
[Tags] tdd_issue tdd_issue_4193 tdd_issue_4297 tdd_expected_fail
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
@@ -95,4 +127,4 @@ TUI Shell Mode Detection
|
||||
... print("shell-mode-detection-ok")
|
||||
${result}= Run Process ${PYTHON} -c ${script} shell=False
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} shell-mode-detection-ok
|
||||
Should Contain ${result.stdout} shell-mode-detection-ok
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""PassSuppressFormatter — a custom Behave formatter that suppresses output
|
||||
for passing and skipped scenarios, reducing CI log noise from ~100,000 lines
|
||||
to ≤ 10 lines for an all-passing suite.
|
||||
|
||||
This module is embedded in the same directory as ``run_behave_parallel.py``
|
||||
so that ``_install_behave_parallel()`` in ``noxfile.py`` can copy both files
|
||||
into the temporary ``behave_parallel`` package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from behave.formatter.base import (
|
||||
Formatter as _BehaveFormatter, # type: ignore[import-untyped]
|
||||
)
|
||||
|
||||
# Sentinel set of statuses whose output should be suppressed.
|
||||
# Every status not in this set (e.g. "failed", "undefined") causes the
|
||||
# buffered scenario output to be flushed to the real output stream.
|
||||
_SUPPRESS_STATUSES: frozenset[str] = frozenset({"passed", "skipped"})
|
||||
|
||||
|
||||
class PassSuppressFormatter(_BehaveFormatter):
|
||||
"""Behave formatter that suppresses output for passing scenarios.
|
||||
|
||||
All per-scenario output is buffered in a :class:`io.StringIO`. When a
|
||||
scenario ends (signalled by the next :meth:`scenario` call or by
|
||||
:meth:`eof`):
|
||||
|
||||
* **Failed / errored scenario** — the buffer is flushed to the real
|
||||
output stream so developers and CI tools see the full step-level
|
||||
details.
|
||||
* **Passed / skipped scenario** — the buffer is silently discarded.
|
||||
|
||||
The result for an all-passing suite is zero formatter output, leaving
|
||||
only the ``_print_overall_summary`` block emitted by the runner as
|
||||
visible output (~5-10 lines regardless of suite size).
|
||||
|
||||
Coverage mode (``BEHAVE_PARALLEL_COVERAGE=1``) bypasses this formatter
|
||||
entirely via ``_make_runner()``, which falls back to behave's default
|
||||
format so that slipcover can instrument a single sequential process
|
||||
without output interference.
|
||||
"""
|
||||
|
||||
name: str = "pass_suppress"
|
||||
description: str = "Suppress passing scenario output; show failures fully"
|
||||
|
||||
def __init__(self, stream_opener: Any, config: Any) -> None:
|
||||
super().__init__(stream_opener, config)
|
||||
# Ensure the real output stream is open (opens it if stream_opener
|
||||
# was constructed with only a filename, not a pre-opened stream).
|
||||
self.stream = self.open()
|
||||
|
||||
# Per-scenario buffering state.
|
||||
self._current_scenario: Any = None
|
||||
self._scenario_buf: io.StringIO = io.StringIO()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _finalize_previous_scenario(self) -> None:
|
||||
"""Flush the scenario buffer to the real stream if scenario failed.
|
||||
|
||||
Called at the start of each new scenario and at end-of-feature so
|
||||
the previous scenario's buffered output is either committed or
|
||||
discarded based on the scenario's final status.
|
||||
"""
|
||||
if self._current_scenario is None:
|
||||
return
|
||||
status: Any = getattr(self._current_scenario, "status", None)
|
||||
status_name: str = (
|
||||
getattr(status, "name", str(status)) if status is not None else ""
|
||||
)
|
||||
if status_name not in _SUPPRESS_STATUSES:
|
||||
output = self._scenario_buf.getvalue()
|
||||
if output:
|
||||
self.stream.write(output)
|
||||
self.stream.flush()
|
||||
# Reset the buffer regardless — the next scenario starts fresh.
|
||||
self._scenario_buf = io.StringIO()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Formatter interface (behave lifecycle hooks)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def uri(self, uri: str) -> None:
|
||||
"""Called before each feature file; no output needed."""
|
||||
|
||||
def feature(self, feature: Any) -> None:
|
||||
"""Called at feature start; no output needed."""
|
||||
|
||||
def background(self, background: Any) -> None:
|
||||
"""Called when a feature background is declared; no output needed."""
|
||||
|
||||
def scenario(self, scenario: Any) -> None:
|
||||
"""Start buffering output for *scenario*, finalising the previous one."""
|
||||
self._finalize_previous_scenario()
|
||||
self._current_scenario = scenario
|
||||
# Write the scenario header into the new buffer.
|
||||
self._scenario_buf.write(f"\n {scenario.keyword}: {scenario.name}\n")
|
||||
|
||||
def step(self, step: Any) -> None:
|
||||
"""Step announced before execution; no output needed at this point."""
|
||||
|
||||
def match(self, match: Any) -> None:
|
||||
"""Step matched; no output needed."""
|
||||
|
||||
def result(self, step: Any) -> None:
|
||||
"""Record a step result in the per-scenario buffer."""
|
||||
status: Any = getattr(step, "status", None)
|
||||
status_name: str = (
|
||||
getattr(status, "name", "unknown") if status is not None else "unknown"
|
||||
)
|
||||
self._scenario_buf.write(f" {step.keyword} {step.name} ... {status_name}\n")
|
||||
error_msg: str | None = getattr(step, "error_message", None)
|
||||
if error_msg:
|
||||
self._scenario_buf.write(f"{error_msg}\n")
|
||||
|
||||
def eof(self) -> None:
|
||||
"""End of feature file: finalise the current (last) scenario."""
|
||||
self._finalize_previous_scenario()
|
||||
self._current_scenario = None
|
||||
@@ -1,20 +1,16 @@
|
||||
"""In-process parallel behave runner.
|
||||
|
||||
Replaces the old subprocess-per-feature model with direct use of
|
||||
behave's ``Runner`` API. Step definitions and environment hooks are
|
||||
loaded once per process; feature files are parsed and executed without
|
||||
Python interpreter startup overhead.
|
||||
Uses behave's ``Runner`` API directly: step definitions and environment
|
||||
hooks are loaded once per process; feature files are parsed and executed
|
||||
without Python interpreter startup overhead.
|
||||
|
||||
Parallelism modes
|
||||
-----------------
|
||||
* **Sequential** (``--processes 1`` or ``BEHAVE_PARALLEL_COVERAGE=1``):
|
||||
All features run in a single ``Runner.run()`` call.
|
||||
* **Parallel** (``--processes N``, N > 1, no coverage):
|
||||
Features are split into *N* equal-size chunks. A
|
||||
``multiprocessing.Pool`` with the ``fork`` start method dispatches
|
||||
each chunk to a worker that creates its own ``Runner`` (hooks and
|
||||
step definitions are re-loaded cheaply because all heavy modules are
|
||||
already in memory from the parent).
|
||||
Features split into *N* equal-size chunks dispatched via
|
||||
``multiprocessing.Pool`` with the ``fork`` start method.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,6 +27,13 @@ from contextlib import redirect_stderr, redirect_stdout, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from behave_pass_suppress_formatter import PassSuppressFormatter
|
||||
except ImportError:
|
||||
from behave_parallel.behave_pass_suppress_formatter import ( # type: ignore[import-untyped,unused-ignore]
|
||||
PassSuppressFormatter,
|
||||
)
|
||||
|
||||
DEFAULT_FEATURE_ROOT = "features/"
|
||||
|
||||
|
||||
@@ -79,6 +82,7 @@ def _is_btrfs_or_overlayfs() -> bool:
|
||||
|
||||
# Type alias for summary dictionaries
|
||||
Summary = dict[str, Any]
|
||||
WorkerResult = tuple[bool, str, str, Summary]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -174,10 +178,7 @@ def _format_duration(seconds: float) -> str:
|
||||
return f"{remainder:.3f}s"
|
||||
|
||||
|
||||
def _print_overall_summary(
|
||||
total: Summary,
|
||||
wall_seconds: float | None = None,
|
||||
) -> None:
|
||||
def _print_overall_summary(total: Summary, wall_seconds: float | None = None) -> None:
|
||||
print("\nOverall summary:")
|
||||
for bucket in ("features", "scenarios", "steps"):
|
||||
b = total[bucket]
|
||||
@@ -272,35 +273,50 @@ def _make_runner(feature_paths: list[str], behave_args: list[str]) -> Any:
|
||||
Mirrors the format-defaulting logic from ``behave.__main__.run_behave``
|
||||
so that ``-q`` and bare invocations get a sensible formatter instead
|
||||
of crashing on ``config.format is None``.
|
||||
|
||||
When no explicit ``--format``/``-f`` flag is present in *behave_args* and
|
||||
``BEHAVE_PARALLEL_COVERAGE`` is **not** set, the runner uses
|
||||
:class:`PassSuppressFormatter` so that passing scenarios produce no
|
||||
output. Coverage mode falls back to ``config.default_format`` (normally
|
||||
``pretty``) so that slipcover can instrument a single sequential process
|
||||
without output interference.
|
||||
"""
|
||||
from behave.configuration import Configuration
|
||||
from behave.formatter._registry import register_as
|
||||
from behave.runner import Runner
|
||||
from behave.runner_util import reset_runtime
|
||||
|
||||
reset_runtime()
|
||||
# Register our custom formatter so behave can resolve it by name when
|
||||
# make_formatters() is called during Runner initialisation.
|
||||
register_as(PassSuppressFormatter.name, PassSuppressFormatter)
|
||||
|
||||
args = list(behave_args) + [str(p) for p in feature_paths]
|
||||
config = Configuration(command_args=args)
|
||||
if not config.format:
|
||||
config.format = [config.default_format]
|
||||
# No explicit format was requested by the caller. Use pass_suppress
|
||||
# unless coverage instrumentation is active (BEHAVE_PARALLEL_COVERAGE=1),
|
||||
# where slipcover needs unmodified output from a single sequential process.
|
||||
coverage_mode = bool(os.environ.get("BEHAVE_PARALLEL_COVERAGE"))
|
||||
if coverage_mode:
|
||||
config.format = [config.default_format]
|
||||
else:
|
||||
config.format = [PassSuppressFormatter.name]
|
||||
return Runner(config)
|
||||
|
||||
|
||||
def _run_features_inprocess(
|
||||
feature_paths: list[str], behave_args: list[str]
|
||||
) -> tuple[bool, Summary]:
|
||||
def _run_features_inprocess(paths: list[str], args: list[str]) -> tuple[bool, Summary]:
|
||||
"""Run *all* feature_paths in a single behave Runner invocation.
|
||||
|
||||
Returns ``(failed: bool, summary: dict)``.
|
||||
"""
|
||||
runner = _make_runner(feature_paths, behave_args)
|
||||
runner = _make_runner(paths, args)
|
||||
failed = runner.run()
|
||||
summary = _extract_summary(runner)
|
||||
return failed, summary
|
||||
|
||||
|
||||
def _worker_run_features(
|
||||
payload: tuple[list[str], list[str]],
|
||||
) -> tuple[bool, str, str, Summary]:
|
||||
def _worker_run_features(payload: tuple[list[str], list[str]]) -> WorkerResult:
|
||||
"""Entry point for multiprocessing workers.
|
||||
|
||||
Runs a chunk of feature files in a forked child process. Heavy
|
||||
@@ -348,9 +364,7 @@ def _worker_run_features(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _aggregate_worker_results(
|
||||
results: list[tuple[bool, str, str, Summary]],
|
||||
) -> Summary:
|
||||
def _aggregate_worker_results(results: list[WorkerResult]) -> Summary:
|
||||
"""Aggregate worker results, replaying logs only for failed chunks.
|
||||
|
||||
Iterates over the list of ``(worker_failed, stdout, stderr, summary)``
|
||||
|
||||
@@ -10,11 +10,12 @@ Based on issue #9579 and ``docs/specification.md`` ~lines 44405-44420.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FileType(StrEnum):
|
||||
"""File type enumeration for index entries."""
|
||||
@@ -45,8 +46,7 @@ class TierLevel(StrEnum):
|
||||
ARCHIVE = "archive" # Reference
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexEntry:
|
||||
class IndexEntry(BaseModel):
|
||||
"""Represents a single indexed context entry.
|
||||
|
||||
Attributes:
|
||||
@@ -65,9 +65,9 @@ class IndexEntry:
|
||||
size_bytes: int
|
||||
created_at: datetime
|
||||
modified_at: datetime
|
||||
tags: set[str] = field(default_factory=set)
|
||||
tags: set[str] = Field(default_factory=set)
|
||||
tier: TierLevel = TierLevel.COLD
|
||||
metadata: dict[str, str] = field(default_factory=dict)
|
||||
metadata: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
def add_tag(self, tag: str) -> None:
|
||||
"""Add a tag to this entry.
|
||||
@@ -104,7 +104,6 @@ class IndexEntry:
|
||||
self.tier = tier
|
||||
|
||||
|
||||
@dataclass
|
||||
class ACMSIndex:
|
||||
"""ACMS Index for storing and querying indexed context entries.
|
||||
|
||||
@@ -115,7 +114,8 @@ class ACMSIndex:
|
||||
entries: Dictionary mapping file paths to IndexEntry objects
|
||||
"""
|
||||
|
||||
entries: dict[str, IndexEntry] = field(default_factory=dict)
|
||||
def __init__(self) -> None:
|
||||
self.entries: dict[str, IndexEntry] = {}
|
||||
|
||||
def add_entry(self, entry: IndexEntry) -> None:
|
||||
"""Add an index entry to the index.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Application ports — protocol interfaces for external dependencies."""
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Decision recorder port — protocol interface for recording decisions.
|
||||
|
||||
This module defines the ``DecisionRecorder`` protocol, which is the
|
||||
shared interface used by both ``StrategizeDecisionHook`` and the future
|
||||
``ExecuteDecisionHook`` to record decisions without coupling to a
|
||||
concrete ``DecisionService`` implementation.
|
||||
|
||||
Based on:
|
||||
- docs/adr/ADR-033-decision-recording-protocol.md
|
||||
- Forgejo issue #8522
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.domain.models.core.decision import (
|
||||
ContextSnapshot,
|
||||
Decision,
|
||||
DecisionType,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import PlanPhase
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DecisionRecorder(Protocol):
|
||||
"""Protocol for recording decisions (subset of DecisionService API).
|
||||
|
||||
Both ``StrategizeDecisionHook`` and the future ``ExecuteDecisionHook``
|
||||
depend on this protocol rather than the concrete ``DecisionService``,
|
||||
keeping the hooks decoupled from the persistence layer.
|
||||
"""
|
||||
|
||||
def record_decision(
|
||||
self,
|
||||
plan_id: str,
|
||||
decision_type: DecisionType | str,
|
||||
question: str,
|
||||
chosen_option: str,
|
||||
*,
|
||||
parent_decision_id: str | None = None,
|
||||
alternatives_considered: list[str] | None = None,
|
||||
confidence_score: float | None = None,
|
||||
rationale: str = "",
|
||||
actor_reasoning: str | None = None,
|
||||
context_snapshot: ContextSnapshot | None = None,
|
||||
plan_phase: PlanPhase | str | None = None,
|
||||
) -> Decision: ...
|
||||
@@ -6,11 +6,17 @@ through a deterministic scope chain:
|
||||
plan-level > project-level > global default
|
||||
|
||||
This allows any Protocol-based component to be overridden at the plan,
|
||||
project, or global level. The specification defines 27 extension points
|
||||
project, or global level. The specification defines 28 extension points
|
||||
across ACMS pipeline components, context strategies, sandbox strategies,
|
||||
UKO vocabulary/analyzers, and validation implementations — all of which
|
||||
can be resolved through this scope chain.
|
||||
|
||||
The scope chain resolution itself is **pluggable** via the
|
||||
``context.scope_chain`` extension point (introduced in PR #10658).
|
||||
Custom scope chain strategies can override the default plan > project > global
|
||||
ordering, support additional scope levels, or implement entirely custom
|
||||
resolution algorithms.
|
||||
|
||||
Plan-level overrides are specified in plan metadata. Project-level
|
||||
overrides are specified in ``config.toml`` under ``[extensions]`` or
|
||||
registered programmatically. Global defaults are registered at
|
||||
@@ -21,7 +27,8 @@ implementations at a scope level transparently fall through to the
|
||||
next scope.
|
||||
|
||||
Based on ``docs/specification.md`` §§ 44369-44396 (Extension Points
|
||||
Summary) and issue #552.
|
||||
Summary) and issue #552. PR #10658 adds the pluggable scope chain API
|
||||
via the ``context.scope_chain`` extension point.
|
||||
|
||||
ISSUES CLOSED: #552
|
||||
"""
|
||||
@@ -32,10 +39,14 @@ import importlib
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Final, TypeVar
|
||||
from typing import Any, Final, Sequence, Tuple, TypeVar
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.infrastructure.plugins.extension_protocols import (
|
||||
ContextScopeChainExtension,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -175,6 +186,13 @@ class ComponentResolver:
|
||||
implementations at a scope level transparently fall through to
|
||||
the next scope.
|
||||
|
||||
As of PR #10658, scope chain resolution is **pluggable** via the
|
||||
``context.scope_chain`` extension point. A custom
|
||||
``ContextScopeChainExtension`` implementation can be set with
|
||||
:meth:`set_scope_chain` to override the default plan > project > global
|
||||
ordering, support additional scopes, or implement a entirely custom
|
||||
resolution algorithm.
|
||||
|
||||
Example::
|
||||
|
||||
resolver = ComponentResolver()
|
||||
@@ -186,6 +204,7 @@ class ComponentResolver:
|
||||
Thread-safety: all public methods are thread-safe.
|
||||
|
||||
Based on ``docs/specification.md`` §§ 44369-44396 and issue #552.
|
||||
PR #10658 added the pluggable scope chain API.
|
||||
"""
|
||||
|
||||
# Module prefixes allowed for dynamic import (security).
|
||||
@@ -200,8 +219,41 @@ class ComponentResolver:
|
||||
tuple[type[Any], str | None, str | None], ResolutionResult
|
||||
] = {}
|
||||
self._extension_points: dict[type[Any], ExtensionPointEntry] = {}
|
||||
self._scope_chain_resolver: ContextScopeChainExtension | None = None
|
||||
self._logger = logger.bind(service="component_resolver")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pluggable scope chain (PR #10658)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_scope_chain(
|
||||
self,
|
||||
scope_chain_resolver: ContextScopeChainExtension | None,
|
||||
) -> None:
|
||||
"""Set or replace the pluggable scope chain resolver.
|
||||
|
||||
When set, :meth:`resolve` delegates to this resolver instead of
|
||||
using the built-in plan > project > global default chain. Set to
|
||||
``None`` to revert to the built-in resolver.
|
||||
|
||||
Args:
|
||||
scope_chain_resolver: Instance implementing
|
||||
``ContextScopeChainExtension``, or ``None`` for default.
|
||||
|
||||
Example::
|
||||
|
||||
class CustomChain:
|
||||
@property
|
||||
def scope_chain_name(self) -> str: return "custom"
|
||||
|
||||
def resolve(...):
|
||||
... custom logic ...
|
||||
|
||||
resolver.set_scope_chain(CustomChain())
|
||||
"""
|
||||
with self._lock:
|
||||
self._scope_chain_resolver = scope_chain_resolver
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Extension point catalog
|
||||
# ------------------------------------------------------------------
|
||||
@@ -395,9 +447,20 @@ class ComponentResolver:
|
||||
plan_id: str | None,
|
||||
project_id: str | None,
|
||||
) -> ResolutionResult:
|
||||
"""Internal resolution without cache (must hold lock)."""
|
||||
"""Internal resolution without cache (must hold lock).
|
||||
|
||||
When a pluggable scope chain resolver is set via :meth:`set_scope_chain`,
|
||||
delegates to it. Otherwise uses the built-in plan > project > global chain.
|
||||
"""
|
||||
type_name = component_type.__name__
|
||||
|
||||
# --- Pluggable scope chain (PR #10658) ---------------------------
|
||||
if self._scope_chain_resolver is not None:
|
||||
return self._resolve_with_pluggable_scope_chain(
|
||||
component_type, plan_id, project_id, type_name
|
||||
)
|
||||
|
||||
# --- Built-in 3-level scope chain --------------------------------
|
||||
# Level 1: Plan scope
|
||||
if plan_id:
|
||||
plan_registry = self._plans.get(plan_id)
|
||||
@@ -454,6 +517,78 @@ class ComponentResolver:
|
||||
)
|
||||
raise ComponentNotFoundError(msg)
|
||||
|
||||
def _resolve_with_pluggable_scope_chain(
|
||||
self,
|
||||
component_type: type[Any],
|
||||
plan_id: str | None,
|
||||
project_id: str | None,
|
||||
type_name: str,
|
||||
) -> ResolutionResult:
|
||||
"""Resolve using the pluggable scope chain resolver (PR #10658).
|
||||
|
||||
The custom resolver receives scope metadata and is expected to return
|
||||
a ``(component, scope_label)`` tuple. A fallback implementation
|
||||
built from the three registries is passed so the pluggable resolver
|
||||
can delegate to built-in scopes when desired.
|
||||
"""
|
||||
chain = self._scope_chain_resolver
|
||||
|
||||
# Build default fallback scope layers for custom resolvers to
|
||||
# compose with when they want plan > project > global semantics.
|
||||
def _default_fallback(component_type_arg: type[Any]) -> Tuple[object, str]:
|
||||
impl = self._global.get(component_type_arg)
|
||||
if impl is not None and plan_id is None and project_id is None:
|
||||
return impl, ScopeLevel.GLOBAL.value
|
||||
if plan_id:
|
||||
pr = self._plans.get(plan_id)
|
||||
if pr is not None:
|
||||
pimpl = pr.get(component_type_arg)
|
||||
if pimpl is not None:
|
||||
return pimpl, ScopeLevel.PLAN.value
|
||||
if project_id:
|
||||
projr = self._projects.get(project_id)
|
||||
if projr is not None:
|
||||
pimp = projr.get(component_type_arg)
|
||||
if pimp is not None:
|
||||
return pimp, ScopeLevel.PROJECT.value
|
||||
raise ComponentNotFoundError(
|
||||
f"No implementation found for {type_name} at any scope level."
|
||||
)
|
||||
|
||||
# Scopes dict passed to the pluggable resolver.
|
||||
scopes: list[dict[str, Any]] = []
|
||||
if plan_id:
|
||||
pr = self._plans.get(plan_id)
|
||||
if pr is not None and component_type in pr.implementations:
|
||||
scopes.append({"scope": "plan", "id": plan_id})
|
||||
else:
|
||||
scopes.append({'scope': 'plan', 'id': plan_id, 'hit': False})
|
||||
if project_id:
|
||||
projr = self._projects.get(project_id)
|
||||
if projr is not None and component_type in projr.implementations:
|
||||
scopes.append({"scope": "project", "id": project_id})
|
||||
else:
|
||||
scopes.append({"scope": "project", "id": project_id, "hit": False})
|
||||
scopes.append({'scope': 'global'})
|
||||
|
||||
result_impl, scope_label = chain.resolve(
|
||||
component_type=component_type,
|
||||
scopes=scopes,
|
||||
fallback=_default_fallback(component_type),
|
||||
)
|
||||
|
||||
self._logger.debug(
|
||||
"component.resolved",
|
||||
scope=scope_label,
|
||||
chain_name=chain.scope_chain_name,
|
||||
component_type=type_name,
|
||||
)
|
||||
return ResolutionResult(
|
||||
component=result_impl,
|
||||
scope=ScopeLevel(scope_label),
|
||||
component_type_name=type_name,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config.toml extension loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -31,10 +31,20 @@ def _path_matches(path: str, include_patterns: list[str]) -> bool:
|
||||
|
||||
|
||||
def _matches_pattern(path_obj: PurePosixPath, pattern: str) -> bool:
|
||||
"""Match with a small compatibility shim for ``**/`` zero-depth cases."""
|
||||
return path_obj.match(pattern) or (
|
||||
"**/" in pattern and path_obj.match(pattern.replace("**/", ""))
|
||||
)
|
||||
"""Match a path against a glob pattern, handling absolute vs relative paths.
|
||||
|
||||
Tries ``full_match()`` with the pattern as-is, then with a ``**/``
|
||||
prefix so that relative globs (e.g. ``.opencode/*``) correctly match
|
||||
absolute paths (e.g. ``/app/.opencode/skills/SKILL.md``). Also
|
||||
handles the ``**/`` zero-depth compatibility shim.
|
||||
"""
|
||||
if path_obj.full_match(pattern):
|
||||
return True
|
||||
# Auto-prefix with **/ so relative patterns match absolute paths
|
||||
if not pattern.startswith("**/") and path_obj.full_match(f"**/{pattern}"):
|
||||
return True
|
||||
# Zero-depth shim: "**/" in pattern but full_match already tried above
|
||||
return bool("**/" in pattern and path_obj.full_match(pattern.replace("**/", "")))
|
||||
|
||||
|
||||
def _extract_path(fragment: TieredFragment) -> str:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Decision context snapshot utility.
|
||||
|
||||
Provides the ``capture_context_snapshot`` function for capturing a
|
||||
context snapshot at decision time. This utility is shared between
|
||||
``StrategizeDecisionHook`` and the future ``ExecuteDecisionHook``.
|
||||
|
||||
Based on:
|
||||
- docs/specification.md §Strategize-Phase Recording Loop
|
||||
- docs/adr/ADR-033-decision-recording-protocol.md
|
||||
- Forgejo issue #8522
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.domain.models.core.decision import (
|
||||
ContextSnapshot,
|
||||
ResourceRef,
|
||||
)
|
||||
|
||||
|
||||
def capture_context_snapshot(
|
||||
context_data: dict[str, Any] | None = None,
|
||||
actor_state: dict[str, Any] | None = None,
|
||||
relevant_resources: list[str] | None = None,
|
||||
) -> ContextSnapshot:
|
||||
"""Capture a context snapshot at decision time.
|
||||
|
||||
Automatically generates:
|
||||
|
||||
- ``hot_context_hash``: SHA256 hash of the context data
|
||||
- ``hot_context_ref``: Abbreviated storage reference
|
||||
- ``relevant_resources``: List of resource references
|
||||
- ``actor_state_ref``: Reference to actor state checkpoint
|
||||
|
||||
Args:
|
||||
context_data: Current context window contents (dict).
|
||||
actor_state: Actor's current state (dict).
|
||||
relevant_resources: List of resource IDs that influenced the decision.
|
||||
|
||||
Returns:
|
||||
A :class:`~cleveragents.domain.models.core.decision.ContextSnapshot`
|
||||
with auto-captured fields.
|
||||
"""
|
||||
# Generate hot context hash
|
||||
context_json = json.dumps(context_data or {}, sort_keys=True, default=str)
|
||||
hot_context_hash = f"sha256:{hashlib.sha256(context_json.encode()).hexdigest()}"
|
||||
|
||||
# Generate actor state reference (placeholder for LangGraph checkpoint)
|
||||
actor_state_json = json.dumps(actor_state or {}, sort_keys=True, default=str)
|
||||
actor_state_ref = (
|
||||
f"checkpoint:{hashlib.sha256(actor_state_json.encode()).hexdigest()[:16]}"
|
||||
)
|
||||
|
||||
# Convert resource IDs to ResourceRef objects
|
||||
resource_refs = [ResourceRef(resource_id=rid) for rid in (relevant_resources or [])]
|
||||
|
||||
return ContextSnapshot(
|
||||
hot_context_hash=hot_context_hash,
|
||||
hot_context_ref=f"context:{hot_context_hash[7:23]}", # Abbreviated ref
|
||||
relevant_resources=resource_refs,
|
||||
actor_state_ref=actor_state_ref,
|
||||
)
|
||||
@@ -72,13 +72,32 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler):
|
||||
|
||||
@staticmethod
|
||||
def _path_matches(path: str, include: list[str], exclude: list[str]) -> bool:
|
||||
"""Return whether *path* passes include/exclude path globs."""
|
||||
"""Return whether *path* passes include/exclude path globs.
|
||||
|
||||
Handles both absolute paths (e.g. ``/app/.opencode/skills/SKILL.md``)
|
||||
and relative paths (e.g. ``src/foo.py``) against relative glob
|
||||
patterns (e.g. ``.opencode/**``, ``src/**/*.py``).
|
||||
|
||||
Each pattern is tried with ``full_match()`` as-is (handles
|
||||
relative paths and ``**`` patterns). If the pattern is not
|
||||
already anchored with ``**/``, a second attempt prefixes
|
||||
``**/`` so that relative globs also match absolute paths.
|
||||
"""
|
||||
pure_path = PurePath(path)
|
||||
if include and not any(pure_path.full_match(pattern) for pattern in include):
|
||||
|
||||
def _matches_any(patterns: list[str]) -> bool:
|
||||
for pattern in patterns:
|
||||
if pure_path.full_match(pattern):
|
||||
return True
|
||||
if not pattern.startswith("**/") and pure_path.full_match(
|
||||
f"**/{pattern}"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
return not (
|
||||
exclude and any(pure_path.full_match(pattern) for pattern in exclude)
|
||||
)
|
||||
|
||||
if include and not _matches_any(include):
|
||||
return False
|
||||
return not (exclude and _matches_any(exclude))
|
||||
|
||||
@staticmethod
|
||||
def _resource_matches(
|
||||
|
||||
@@ -51,6 +51,8 @@ Based on ``docs/specification.md`` and implementation plan Stage A3.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -77,7 +79,7 @@ from cleveragents.domain.models.core.automation_profile import (
|
||||
BUILTIN_PROFILES,
|
||||
AutomationProfile,
|
||||
)
|
||||
from cleveragents.domain.models.core.decision import DecisionType
|
||||
from cleveragents.domain.models.core.decision import ContextSnapshot, DecisionType
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
@@ -265,11 +267,23 @@ class PlanLifecycleService:
|
||||
question: str,
|
||||
chosen_option: str,
|
||||
parent_decision_id: str | None = None,
|
||||
context_snapshot: ContextSnapshot | None = None,
|
||||
) -> None:
|
||||
"""Record a decision if DecisionService is available.
|
||||
|
||||
Failures are logged but never propagated — decision recording
|
||||
must not block lifecycle transitions.
|
||||
|
||||
Args:
|
||||
plan_id: ULID of the plan.
|
||||
decision_type: Type of decision being recorded.
|
||||
question: What question was being answered.
|
||||
chosen_option: The option that was chosen.
|
||||
parent_decision_id: Optional parent in the decision tree.
|
||||
context_snapshot: Optional full context snapshot. When
|
||||
provided, it is forwarded to
|
||||
:meth:`DecisionService.record_decision` so that the
|
||||
decision is stored with a complete context snapshot
|
||||
"""
|
||||
if self.decision_service is None:
|
||||
return
|
||||
@@ -281,6 +295,7 @@ class PlanLifecycleService:
|
||||
question=question,
|
||||
chosen_option=chosen_option,
|
||||
parent_decision_id=parent_decision_id,
|
||||
context_snapshot=context_snapshot,
|
||||
)
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
@@ -1398,15 +1413,72 @@ class PlanLifecycleService:
|
||||
self._commit_plan(plan)
|
||||
self._logger.info("Strategize started", plan_id=plan_id)
|
||||
|
||||
context_snapshot = self._build_strategize_context_snapshot(plan)
|
||||
self._try_record_decision(
|
||||
plan_id=plan_id,
|
||||
decision_type="strategy_choice",
|
||||
question="Which strategy should the plan follow?",
|
||||
chosen_option=f"Begin strategize phase for plan {plan_id}",
|
||||
context_snapshot=context_snapshot,
|
||||
)
|
||||
|
||||
return plan
|
||||
|
||||
def _build_strategize_context_snapshot(self, plan: Plan) -> ContextSnapshot:
|
||||
"""Build a full context snapshot for a Strategize-phase decision.
|
||||
|
||||
Captures the plan description, action name, strategy actor, and
|
||||
project references as the hot context window. The hash is
|
||||
computed over the serialised context so that identical context
|
||||
windows produce the same hash (content-addressable).
|
||||
|
||||
The ``hot_context_ref`` is set to a stable ``plan:<plan_id>``
|
||||
URI so that callers can locate the full context via the plan
|
||||
record. ``relevant_resources`` is populated from the plan's
|
||||
project links. ``actor_state_ref`` is set to the strategy
|
||||
actor name when available.
|
||||
|
||||
Per the v3.2.0 acceptance criteria, decisions recorded during
|
||||
the Strategize phase must include full context snapshots with
|
||||
all four :class:`ContextSnapshot` fields populated.
|
||||
|
||||
Args:
|
||||
plan: The plan entering the Strategize phase.
|
||||
|
||||
Returns:
|
||||
A :class:`ContextSnapshot` with all four fields populated.
|
||||
"""
|
||||
from cleveragents.domain.models.core.decision import ResourceRef
|
||||
|
||||
plan_id = plan.identity.plan_id
|
||||
|
||||
# Build the hot context window from plan metadata available at
|
||||
# the start of the Strategize phase.
|
||||
hot_context: dict[str, object] = {
|
||||
"plan_id": plan_id,
|
||||
"action_name": plan.action_name,
|
||||
"description": plan.description or "",
|
||||
"strategy_actor": plan.strategy_actor or "",
|
||||
"projects": [pl.project_name for pl in plan.project_links],
|
||||
}
|
||||
context_json = json.dumps(hot_context, sort_keys=True)
|
||||
context_hash = hashlib.sha256(context_json.encode()).hexdigest()
|
||||
|
||||
# Build resource refs from project links so the snapshot records
|
||||
# which projects influenced the strategy decision.
|
||||
relevant_resources = [
|
||||
ResourceRef(resource_id=pl.project_name)
|
||||
for pl in plan.project_links
|
||||
if pl.project_name
|
||||
]
|
||||
|
||||
return ContextSnapshot(
|
||||
hot_context_hash=f"sha256:{context_hash}",
|
||||
hot_context_ref=f"plan:{plan_id}",
|
||||
relevant_resources=relevant_resources,
|
||||
actor_state_ref=plan.strategy_actor or "",
|
||||
)
|
||||
|
||||
def complete_strategize(self, plan_id: str) -> Plan:
|
||||
"""Complete the Strategize phase.
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Decision recording hook for the Strategize phase.
|
||||
|
||||
This module provides the ``StrategizeDecisionHook`` class, which integrates
|
||||
decision recording into the Strategize phase of plan execution. The hook
|
||||
captures every decision point during strategy decomposition, including:
|
||||
|
||||
- The question being answered
|
||||
- The chosen option
|
||||
- Alternatives considered
|
||||
- Confidence score
|
||||
- Rationale
|
||||
- Full context snapshot (hot context hash, actor state reference, relevant resources)
|
||||
|
||||
The hook is designed to be called by the strategy actor during the Strategize
|
||||
phase, recording decisions atomically with plan updates.
|
||||
|
||||
Based on:
|
||||
- docs/specification.md §Strategize-Phase Recording Loop
|
||||
- docs/adr/ADR-033-decision-recording-protocol.md
|
||||
- Forgejo issue #8522
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.application.ports.decision_recorder import DecisionRecorder
|
||||
from cleveragents.application.services.decision_context import capture_context_snapshot
|
||||
from cleveragents.core.exceptions import ValidationError
|
||||
from cleveragents.domain.models.core.decision import (
|
||||
Decision,
|
||||
DecisionType,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import PlanPhase
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategize decision hook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StrategizeDecisionHook:
|
||||
"""Hook for recording decisions during the Strategize phase.
|
||||
|
||||
Integrates with the strategy actor to capture every decision point,
|
||||
including the question, chosen option, alternatives, confidence,
|
||||
rationale, and full context snapshot.
|
||||
|
||||
The hook is designed to be called by the strategy actor during
|
||||
Strategize, and records decisions atomically with plan updates.
|
||||
|
||||
Attributes:
|
||||
decision_service: The
|
||||
:class:`~cleveragents.application.ports.decision_recorder.DecisionRecorder`
|
||||
instance for persisting decisions.
|
||||
plan_id: ULID of the plan being strategized.
|
||||
parent_decision_id: Optional parent decision ID for tree structure.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
decision_service: DecisionRecorder,
|
||||
plan_id: str,
|
||||
parent_decision_id: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Strategize decision hook.
|
||||
|
||||
Args:
|
||||
decision_service: DecisionRecorder for recording decisions.
|
||||
plan_id: ULID of the plan.
|
||||
parent_decision_id: Optional parent decision ID.
|
||||
|
||||
Raises:
|
||||
ValidationError: If plan_id is empty.
|
||||
"""
|
||||
if not plan_id or not plan_id.strip():
|
||||
raise ValidationError("plan_id must not be empty")
|
||||
|
||||
self.decision_service = decision_service
|
||||
self.plan_id = plan_id
|
||||
self.parent_decision_id = parent_decision_id
|
||||
self._logger = logger.bind(
|
||||
hook="strategize_decision",
|
||||
plan_id=plan_id,
|
||||
)
|
||||
|
||||
def record_strategy_choice(
|
||||
self,
|
||||
question: str,
|
||||
chosen_option: str,
|
||||
alternatives_considered: list[str] | None = None,
|
||||
confidence_score: float | None = None,
|
||||
rationale: str = "",
|
||||
context_data: dict[str, Any] | None = None,
|
||||
actor_state: dict[str, Any] | None = None,
|
||||
relevant_resources: list[str] | None = None,
|
||||
) -> Decision:
|
||||
"""Record a strategy choice decision during Strategize.
|
||||
|
||||
Args:
|
||||
question: What strategic question was being answered.
|
||||
chosen_option: The chosen approach.
|
||||
alternatives_considered: Other approaches evaluated.
|
||||
confidence_score: Confidence in the choice (0.0-1.0).
|
||||
rationale: Why this option was chosen.
|
||||
context_data: Current context window contents.
|
||||
actor_state: Actor's current state.
|
||||
relevant_resources: Resource IDs that influenced the decision.
|
||||
|
||||
Returns:
|
||||
The recorded Decision.
|
||||
|
||||
Raises:
|
||||
ValidationError: If required fields are missing.
|
||||
"""
|
||||
if not question or not question.strip():
|
||||
raise ValidationError("question must not be empty")
|
||||
if not chosen_option or not chosen_option.strip():
|
||||
raise ValidationError("chosen_option must not be empty")
|
||||
|
||||
snapshot = capture_context_snapshot(
|
||||
context_data=context_data,
|
||||
actor_state=actor_state,
|
||||
relevant_resources=relevant_resources,
|
||||
)
|
||||
|
||||
self._logger.info(
|
||||
"Recording strategy choice decision",
|
||||
question=question,
|
||||
chosen_option=chosen_option,
|
||||
confidence=confidence_score,
|
||||
)
|
||||
|
||||
try:
|
||||
decision = self.decision_service.record_decision(
|
||||
plan_id=self.plan_id,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
question=question,
|
||||
chosen_option=chosen_option,
|
||||
parent_decision_id=self.parent_decision_id,
|
||||
alternatives_considered=alternatives_considered,
|
||||
confidence_score=confidence_score,
|
||||
rationale=rationale,
|
||||
context_snapshot=snapshot,
|
||||
plan_phase=PlanPhase.STRATEGIZE,
|
||||
)
|
||||
self._logger.debug(
|
||||
"Strategy choice decision recorded",
|
||||
decision_id=decision.decision_id,
|
||||
)
|
||||
return decision
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to record strategy choice decision",
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
|
||||
def record_resource_selection(
|
||||
self,
|
||||
question: str,
|
||||
chosen_option: str,
|
||||
alternatives_considered: list[str] | None = None,
|
||||
confidence_score: float | None = None,
|
||||
rationale: str = "",
|
||||
context_data: dict[str, Any] | None = None,
|
||||
actor_state: dict[str, Any] | None = None,
|
||||
relevant_resources: list[str] | None = None,
|
||||
) -> Decision:
|
||||
"""Record a resource selection decision during Strategize.
|
||||
|
||||
Args:
|
||||
question: What resources should be selected.
|
||||
chosen_option: The selected resources.
|
||||
alternatives_considered: Other resource selections evaluated.
|
||||
confidence_score: Confidence in the selection (0.0-1.0).
|
||||
rationale: Why these resources were selected.
|
||||
context_data: Current context window contents.
|
||||
actor_state: Actor's current state.
|
||||
relevant_resources: Resource IDs that influenced the decision.
|
||||
|
||||
Returns:
|
||||
The recorded Decision.
|
||||
|
||||
Raises:
|
||||
ValidationError: If required fields are missing.
|
||||
"""
|
||||
if not question or not question.strip():
|
||||
raise ValidationError("question must not be empty")
|
||||
if not chosen_option or not chosen_option.strip():
|
||||
raise ValidationError("chosen_option must not be empty")
|
||||
|
||||
snapshot = capture_context_snapshot(
|
||||
context_data=context_data,
|
||||
actor_state=actor_state,
|
||||
relevant_resources=relevant_resources,
|
||||
)
|
||||
|
||||
self._logger.info(
|
||||
"Recording resource selection decision",
|
||||
question=question,
|
||||
chosen_option=chosen_option,
|
||||
)
|
||||
|
||||
try:
|
||||
decision = self.decision_service.record_decision(
|
||||
plan_id=self.plan_id,
|
||||
decision_type=DecisionType.RESOURCE_SELECTION,
|
||||
question=question,
|
||||
chosen_option=chosen_option,
|
||||
parent_decision_id=self.parent_decision_id,
|
||||
alternatives_considered=alternatives_considered,
|
||||
confidence_score=confidence_score,
|
||||
rationale=rationale,
|
||||
context_snapshot=snapshot,
|
||||
plan_phase=PlanPhase.STRATEGIZE,
|
||||
)
|
||||
self._logger.debug(
|
||||
"Resource selection decision recorded",
|
||||
decision_id=decision.decision_id,
|
||||
)
|
||||
return decision
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to record resource selection decision",
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
|
||||
def record_subplan_spawn(
|
||||
self,
|
||||
question: str,
|
||||
chosen_option: str,
|
||||
alternatives_considered: list[str] | None = None,
|
||||
confidence_score: float | None = None,
|
||||
rationale: str = "",
|
||||
context_data: dict[str, Any] | None = None,
|
||||
actor_state: dict[str, Any] | None = None,
|
||||
relevant_resources: list[str] | None = None,
|
||||
) -> Decision:
|
||||
"""Record a subplan spawn decision during Strategize.
|
||||
|
||||
Args:
|
||||
question: Why is a subplan being spawned.
|
||||
chosen_option: The subplan goal/description.
|
||||
alternatives_considered: Other decomposition approaches.
|
||||
confidence_score: Confidence in the decomposition (0.0-1.0).
|
||||
rationale: Why this decomposition was chosen.
|
||||
context_data: Current context window contents.
|
||||
actor_state: Actor's current state.
|
||||
relevant_resources: Resource IDs that influenced the decision.
|
||||
|
||||
Returns:
|
||||
The recorded Decision.
|
||||
|
||||
Raises:
|
||||
ValidationError: If required fields are missing.
|
||||
"""
|
||||
if not question or not question.strip():
|
||||
raise ValidationError("question must not be empty")
|
||||
if not chosen_option or not chosen_option.strip():
|
||||
raise ValidationError("chosen_option must not be empty")
|
||||
|
||||
snapshot = capture_context_snapshot(
|
||||
context_data=context_data,
|
||||
actor_state=actor_state,
|
||||
relevant_resources=relevant_resources,
|
||||
)
|
||||
|
||||
self._logger.info(
|
||||
"Recording subplan spawn decision",
|
||||
question=question,
|
||||
chosen_option=chosen_option,
|
||||
)
|
||||
|
||||
try:
|
||||
decision = self.decision_service.record_decision(
|
||||
plan_id=self.plan_id,
|
||||
decision_type=DecisionType.SUBPLAN_SPAWN,
|
||||
question=question,
|
||||
chosen_option=chosen_option,
|
||||
parent_decision_id=self.parent_decision_id,
|
||||
alternatives_considered=alternatives_considered,
|
||||
confidence_score=confidence_score,
|
||||
rationale=rationale,
|
||||
context_snapshot=snapshot,
|
||||
plan_phase=PlanPhase.STRATEGIZE,
|
||||
)
|
||||
self._logger.debug(
|
||||
"Subplan spawn decision recorded",
|
||||
decision_id=decision.decision_id,
|
||||
)
|
||||
return decision
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to record subplan spawn decision",
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
|
||||
def record_invariant_enforced(
|
||||
self,
|
||||
question: str,
|
||||
chosen_option: str,
|
||||
alternatives_considered: list[str] | None = None,
|
||||
confidence_score: float | None = None,
|
||||
rationale: str = "",
|
||||
context_data: dict[str, Any] | None = None,
|
||||
actor_state: dict[str, Any] | None = None,
|
||||
relevant_resources: list[str] | None = None,
|
||||
) -> Decision:
|
||||
"""Record an invariant enforcement decision during Strategize.
|
||||
|
||||
Args:
|
||||
question: What invariant is being enforced.
|
||||
chosen_option: How the invariant is being enforced.
|
||||
alternatives_considered: Other enforcement approaches evaluated.
|
||||
confidence_score: Confidence in the enforcement approach (0.0-1.0).
|
||||
rationale: Why this enforcement approach was chosen.
|
||||
context_data: Current context window contents.
|
||||
actor_state: Actor's current state.
|
||||
relevant_resources: Resource IDs that influenced the decision.
|
||||
|
||||
Returns:
|
||||
The recorded Decision.
|
||||
|
||||
Raises:
|
||||
ValidationError: If required fields are missing.
|
||||
"""
|
||||
if not question or not question.strip():
|
||||
raise ValidationError("question must not be empty")
|
||||
if not chosen_option or not chosen_option.strip():
|
||||
raise ValidationError("chosen_option must not be empty")
|
||||
|
||||
snapshot = capture_context_snapshot(
|
||||
context_data=context_data,
|
||||
actor_state=actor_state,
|
||||
relevant_resources=relevant_resources,
|
||||
)
|
||||
|
||||
self._logger.info(
|
||||
"Recording invariant enforced decision",
|
||||
question=question,
|
||||
chosen_option=chosen_option,
|
||||
)
|
||||
|
||||
try:
|
||||
decision = self.decision_service.record_decision(
|
||||
plan_id=self.plan_id,
|
||||
decision_type=DecisionType.INVARIANT_ENFORCED,
|
||||
question=question,
|
||||
chosen_option=chosen_option,
|
||||
parent_decision_id=self.parent_decision_id,
|
||||
alternatives_considered=alternatives_considered,
|
||||
confidence_score=confidence_score,
|
||||
rationale=rationale,
|
||||
context_snapshot=snapshot,
|
||||
plan_phase=PlanPhase.STRATEGIZE,
|
||||
)
|
||||
self._logger.debug(
|
||||
"Invariant enforced decision recorded",
|
||||
decision_id=decision.decision_id,
|
||||
)
|
||||
return decision
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to record invariant enforced decision",
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
@@ -534,12 +534,13 @@ def _print_role_warnings(config_blob: dict[str, Any]) -> None:
|
||||
@app.command()
|
||||
def add(
|
||||
name: Annotated[
|
||||
str,
|
||||
str | None,
|
||||
typer.Argument(
|
||||
help="Namespaced actor name (e.g. local/my-actor)",
|
||||
help="Namespaced actor name (e.g. local/my-actor). "
|
||||
"If omitted, the name is derived from the config file.",
|
||||
metavar="NAME",
|
||||
),
|
||||
],
|
||||
] = None,
|
||||
config: Annotated[
|
||||
Path | None,
|
||||
typer.Option("--config", "-c", help="Path to JSON/YAML actor config"),
|
||||
@@ -569,19 +570,19 @@ def add(
|
||||
) -> None:
|
||||
"""Add a new actor configuration.
|
||||
|
||||
The actor name is provided as a required positional argument. The YAML/JSON
|
||||
configuration file specified with ``--config`` supplies the actor settings.
|
||||
The positional ``NAME`` takes precedence over any ``name`` field in the
|
||||
config file.
|
||||
The YAML/JSON configuration file specified with ``--config`` supplies the
|
||||
actor settings. The actor's registered name is taken from the config file's
|
||||
``name`` field, unless overridden by the positional ``NAME`` argument.
|
||||
|
||||
Signature:
|
||||
``agents actor add <NAME> --config <FILE> [--update] [--unsafe]
|
||||
``agents actor add [--config|-c <FILE>] [<NAME>] [--update] [--unsafe]
|
||||
[--set-default] [--option key=value] [--format FORMAT]``
|
||||
|
||||
Examples:
|
||||
agents actor add --config ./actors/my-actor.yaml
|
||||
agents actor add local/my-actor --config ./actors/my-actor.yaml
|
||||
agents actor add local/my-actor --config ./actors/my-actor.yaml --update
|
||||
agents actor add local/my-actor --config actor.yaml --format json
|
||||
agents actor add --config ./actors/my-actor.yaml --update
|
||||
agents actor add --config actor.yaml --format json
|
||||
"""
|
||||
service, registry = _get_services()
|
||||
option_overrides = _parse_option_overrides(option)
|
||||
@@ -597,6 +598,15 @@ def add(
|
||||
assert loaded is not None, "unreachable: config is not None"
|
||||
yaml_text, config_blob = loaded
|
||||
|
||||
# Derive actor name from config when not provided as argument.
|
||||
if name is None:
|
||||
name = config_blob.get("name")
|
||||
if not name:
|
||||
raise typer.BadParameter(
|
||||
"Actor name is required. Provide it as a positional argument "
|
||||
"or as a 'name' field in the config file."
|
||||
)
|
||||
|
||||
# Validate v3 config via ActorConfigSchema if detected.
|
||||
# This ensures v3 actors are fully validated (cycle detection, required
|
||||
# fields, enum values) before the registry stores them.
|
||||
|
||||
@@ -213,13 +213,17 @@ def create(
|
||||
# Notify the facade layer for A2A protocol bookkeeping.
|
||||
# Pass session_id so the facade handler acknowledges the already-
|
||||
# persisted session instead of creating a duplicate (#1141).
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
_facade_dispatch(
|
||||
"session.create",
|
||||
{"actor_name": actor or "", "session_id": session.session_id},
|
||||
)
|
||||
except Exception as _exc:
|
||||
_log.warning(
|
||||
"session_create_facade_dispatch_failed",
|
||||
extra={"session_id": session.session_id, "error": str(_exc)},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
data = _session_summary_dict(session)
|
||||
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"""Add result_success column to plans table.
|
||||
|
||||
This migration adds a dedicated ``result_success`` boolean column to the
|
||||
``plans`` table to accurately track the final result status of a plan's
|
||||
apply phase.
|
||||
|
||||
Previously, ``PlanRepository._to_domain`` derived ``PlanResult.success``
|
||||
from ``error_message is None``. Because ``error_message`` is shared
|
||||
between the build phase and the result phase, a plan that encountered a
|
||||
build error but later succeeded in the apply phase would be incorrectly
|
||||
reconstructed as failed.
|
||||
|
||||
The new ``result_success`` column is nullable to preserve backward
|
||||
compatibility with existing database records. When ``result_success``
|
||||
is NULL (pre-migration records), the repository falls back to the legacy
|
||||
``error_message is None`` heuristic.
|
||||
|
||||
Revision ID: m9_003_plan_result_success_column
|
||||
Revises: m10_001_virtual_builtin_actors
|
||||
Create Date: 2026-05-05 00:00:00
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "m9_003_plan_result_success_column"
|
||||
down_revision: str | Sequence[str] | None = "m10_001_virtual_builtin_actors"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add ``result_success`` column to the ``plans`` table.
|
||||
|
||||
The column is nullable so that existing rows (which have no explicit
|
||||
result-phase success signal) are not affected. New rows written by
|
||||
the updated repository will always populate this column.
|
||||
"""
|
||||
op.add_column(
|
||||
"plans",
|
||||
sa.Column("result_success", sa.Boolean(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove ``result_success`` column from the ``plans`` table."""
|
||||
op.drop_column("plans", "result_success")
|
||||
@@ -142,6 +142,7 @@ class PlanModel(Base):
|
||||
files_created = Column(Integer, nullable=True, default=0)
|
||||
files_modified = Column(Integer, nullable=True, default=0)
|
||||
files_deleted = Column(Integer, nullable=True, default=0)
|
||||
result_success = Column(Boolean, nullable=True)
|
||||
|
||||
# Relationships
|
||||
project = relationship("ProjectModel", back_populates="plans")
|
||||
|
||||
@@ -294,6 +294,7 @@ class PlanRepository:
|
||||
files_modified=plan.files_modified,
|
||||
files_deleted=plan.files_deleted,
|
||||
error_message=plan.build.error_message if plan.build else None,
|
||||
result_success=plan.result.success if plan.result else None,
|
||||
)
|
||||
|
||||
self.session.add(db_plan)
|
||||
@@ -372,6 +373,7 @@ class PlanRepository:
|
||||
db_plan.files_created = plan.result.files_created # type: ignore
|
||||
db_plan.files_modified = plan.result.files_modified # type: ignore
|
||||
db_plan.files_deleted = plan.result.files_deleted # type: ignore
|
||||
db_plan.result_success = plan.result.success # type: ignore
|
||||
|
||||
self.session.flush()
|
||||
|
||||
@@ -420,8 +422,20 @@ class PlanRepository:
|
||||
|
||||
result = None
|
||||
if db_plan.applied_at: # type: ignore
|
||||
# Derive success from the dedicated result_success column when
|
||||
# available. For legacy records where result_success is NULL
|
||||
# (written before migration m9_003), fall back to the old
|
||||
# heuristic of checking whether error_message is None.
|
||||
result_success_col = getattr(db_plan, "result_success", None)
|
||||
if result_success_col is True:
|
||||
plan_success = True
|
||||
elif result_success_col is False:
|
||||
plan_success = False
|
||||
else:
|
||||
# NULL — pre-migration record; use legacy heuristic
|
||||
plan_success = db_plan.error_message is None # type: ignore
|
||||
result = PlanResult(
|
||||
success=db_plan.error_message is None, # type: ignore
|
||||
success=plan_success,
|
||||
files_created=db_plan.files_created or 0, # type: ignore
|
||||
files_modified=db_plan.files_modified or 0, # type: ignore
|
||||
files_deleted=db_plan.files_deleted or 0, # type: ignore
|
||||
|
||||
@@ -135,6 +135,7 @@ class ReactiveEventBus:
|
||||
handler=getattr(handler, "__qualname__", repr(handler)),
|
||||
error_type=type(exc).__name__,
|
||||
error=str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def subscribe(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Extension point catalog for all 30 spec-defined extension points.
|
||||
"""Extension point catalog for all 31 spec-defined extension points.
|
||||
|
||||
Provides :func:`register_all_extension_points` which registers every
|
||||
extension point defined in ``docs/specification.md`` into the
|
||||
@@ -23,6 +23,7 @@ from cleveragents.infrastructure.plugins.extension_protocols import (
|
||||
ConfigSourceExtension,
|
||||
ConfigValidatorExtension,
|
||||
ContextPipelineComponentExtension,
|
||||
ContextScopeChainExtension,
|
||||
ContextStorageBackendExtension,
|
||||
ContextStrategyExtension,
|
||||
EventFilterExtension,
|
||||
@@ -89,9 +90,9 @@ def _category_from_name(name: str) -> str:
|
||||
return name.split(".")[0]
|
||||
|
||||
|
||||
# The authoritative list of all 30 spec-defined extension points.
|
||||
# The authoritative list of all 31 spec-defined extension points.
|
||||
_EXTENSION_POINT_DEFS: tuple[_ExtensionPointDef, ...] = (
|
||||
# --- Context (12) ---
|
||||
# --- Context (13) ---
|
||||
_ExtensionPointDef(
|
||||
"context.strategy",
|
||||
ContextStrategyExtension,
|
||||
@@ -152,6 +153,11 @@ _EXTENSION_POINT_DEFS: tuple[_ExtensionPointDef, ...] = (
|
||||
ContextStorageBackendExtension,
|
||||
"Pluggable storage backend for context data",
|
||||
),
|
||||
_ExtensionPointDef(
|
||||
"context.scope_chain",
|
||||
ContextScopeChainExtension,
|
||||
"Pluggable scope chain resolution strategy for component overrides",
|
||||
),
|
||||
# --- Output (3) ---
|
||||
_ExtensionPointDef(
|
||||
"output.renderer",
|
||||
@@ -254,11 +260,11 @@ _EXTENSION_POINT_DEFS: tuple[_ExtensionPointDef, ...] = (
|
||||
)
|
||||
|
||||
# Public constant: number of spec-defined extension points.
|
||||
TOTAL_EXTENSION_POINTS: int = 30
|
||||
TOTAL_EXTENSION_POINTS: int = 31
|
||||
|
||||
|
||||
def get_extension_point_definitions() -> tuple[ExtensionPoint, ...]:
|
||||
"""Return all 30 spec-defined extension points as ExtensionPoint models.
|
||||
"""Return all 31 spec-defined extension points as ExtensionPoint models.
|
||||
|
||||
Each entry includes the point's name, Protocol type, description,
|
||||
and a ``registry_key`` equal to its category prefix.
|
||||
@@ -288,7 +294,7 @@ def get_extension_points_by_category() -> dict[str, list[ExtensionPoint]]:
|
||||
|
||||
|
||||
def register_all_extension_points(manager: ExtensionPointRegistrar) -> int:
|
||||
"""Register all 30 spec-defined extension points on *manager*.
|
||||
"""Register all 31 spec-defined extension points on *manager*.
|
||||
|
||||
Args:
|
||||
manager: Object with a ``register_extension_point`` method
|
||||
|
||||
@@ -13,10 +13,10 @@ ISSUES CLOSED: #939
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
from typing import Any, Protocol, Tuple, runtime_checkable
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context extension protocols (12 extension points)
|
||||
# Context extension protocols (13 extension points)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -62,6 +62,27 @@ class ContextStorageBackendExtension(Protocol):
|
||||
def retrieve(self, key: str) -> Any: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ContextScopeChainExtension(Protocol):
|
||||
"""Protocol for pluggable scope chain resolution strategies.
|
||||
|
||||
Scope chains determine the order and algorithm used to resolve
|
||||
component implementations across hierarchical scopes (plan > project > global).
|
||||
Pluggable implementations allow custom ordering, additional scope levels,
|
||||
or completely custom resolution algorithms.
|
||||
"""
|
||||
|
||||
@property
|
||||
def scope_chain_name(self) -> str: ...
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
component_type: type[Any],
|
||||
scopes: Sequence[Mapping[str, Any]],
|
||||
fallback: Any = None,
|
||||
) -> Tuple[Any, str]: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output extension protocols (3 extension points)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -320,6 +341,7 @@ ALL_EXTENSION_PROTOCOLS: dict[str, type[Any]] = {
|
||||
"context.pipeline_component.preamble_generator": ContextPipelineComponentExtension,
|
||||
"context.pipeline_component.skeleton_compressor": ContextPipelineComponentExtension,
|
||||
"context.storage_backend": ContextStorageBackendExtension,
|
||||
"context.scope_chain": ContextScopeChainExtension,
|
||||
"output.renderer": OutputRendererExtension,
|
||||
"output.materializer": OutputMaterializerExtension,
|
||||
"output.format": OutputFormatExtension,
|
||||
|
||||
@@ -241,10 +241,9 @@ class PluginLoader:
|
||||
def validate_protocol(klass: type[Any], protocol: type[Any]) -> bool:
|
||||
"""Check whether *klass* satisfies a ``@runtime_checkable`` Protocol.
|
||||
|
||||
Creates a temporary instance of *klass* (using a no-arg
|
||||
constructor) and checks it against the protocol via
|
||||
``isinstance``. If instantiation fails, falls back to a
|
||||
structural check using ``issubclass``.
|
||||
Uses structural type checking via ``issubclass`` to validate the
|
||||
protocol without instantiating the class. This prevents arbitrary
|
||||
code execution from untrusted plugin constructors during validation.
|
||||
|
||||
Args:
|
||||
klass: The class to validate.
|
||||
@@ -256,23 +255,84 @@ class PluginLoader:
|
||||
Raises:
|
||||
ProtocolMismatchError: If *klass* does not satisfy the protocol.
|
||||
"""
|
||||
# Try instance check first (most reliable for runtime_checkable)
|
||||
# Try structural check first (safe, no instantiation).
|
||||
# This prevents arbitrary code execution from plugin constructors.
|
||||
issubclass_raised_type_error = False
|
||||
try:
|
||||
instance = klass()
|
||||
if isinstance(instance, protocol):
|
||||
if issubclass(klass, protocol):
|
||||
return True
|
||||
except Exception:
|
||||
# If instantiation fails, try subclass check
|
||||
try:
|
||||
if issubclass(klass, protocol):
|
||||
return True
|
||||
except TypeError:
|
||||
pass
|
||||
except TypeError:
|
||||
# issubclass raises TypeError if protocol is not a valid
|
||||
# @runtime_checkable Protocol (e.g. has a custom metaclass that
|
||||
# overrides __subclasscheck__). Fall through to structural check.
|
||||
issubclass_raised_type_error = True
|
||||
|
||||
# Fallback: perform a conservative structural check against the
|
||||
# Protocol definition without instantiating the class. We inspect
|
||||
# the protocol's declared members (callables and annotated names)
|
||||
# and ensure the candidate class exposes them as class-level
|
||||
# attributes or descriptors. This approximates structural
|
||||
# conformance while avoiding constructor execution.
|
||||
required: set[str] = set()
|
||||
|
||||
# Collect names from protocol __dict__ (methods, properties, etc.)
|
||||
prot_dict = getattr(protocol, "__dict__", {})
|
||||
for name, _value in prot_dict.items():
|
||||
if name.startswith("__"):
|
||||
continue
|
||||
# Methods and descriptors will appear in the dict; annotations
|
||||
# are handled below. We treat any non-data-magic name as a
|
||||
# required member.
|
||||
required.add(name)
|
||||
|
||||
# Also include annotated names (those declared with type hints)
|
||||
ann = getattr(protocol, "__annotations__", {}) or {}
|
||||
for name in ann:
|
||||
if name.startswith("__"):
|
||||
continue
|
||||
required.add(name)
|
||||
|
||||
# If issubclass raised TypeError and the protocol declares no
|
||||
# inspectable members, we cannot validate conformance — treat this
|
||||
# as a mismatch rather than silently returning True for an
|
||||
# unverifiable protocol.
|
||||
if issubclass_raised_type_error and not required:
|
||||
msg = (
|
||||
f"Class '{getattr(klass, '__name__', str(klass))}' cannot be "
|
||||
f"validated against protocol "
|
||||
f"'{getattr(protocol, '__name__', str(protocol))}': "
|
||||
f"issubclass raised TypeError and the protocol declares no "
|
||||
f"inspectable members. Ensure the protocol is a valid "
|
||||
f"@runtime_checkable Protocol."
|
||||
)
|
||||
raise ProtocolMismatchError(msg)
|
||||
|
||||
# Validate that the candidate class exposes each required name.
|
||||
missing: list[str] = []
|
||||
for name in sorted(required):
|
||||
# getattr on the class returns descriptors (functions, property,
|
||||
# staticmethod, etc.) without instantiation. hasattr is safe.
|
||||
if not hasattr(klass, name):
|
||||
missing.append(name)
|
||||
continue
|
||||
# If the protocol declared a callable (method), ensure the
|
||||
# attribute on the class is callable or a descriptor.
|
||||
prot_val = prot_dict.get(name)
|
||||
if callable(prot_val):
|
||||
cand = getattr(klass, name)
|
||||
# Properties are descriptors and typically not callable; we
|
||||
# accept them as present. For methods, require callable.
|
||||
if not (callable(cand) or hasattr(cand, "fget")):
|
||||
missing.append(name)
|
||||
|
||||
if not missing:
|
||||
return True
|
||||
|
||||
msg = (
|
||||
f"Class '{klass.__name__}' does not satisfy protocol "
|
||||
f"'{protocol.__name__}'. Ensure the class implements all "
|
||||
f"required methods and attributes."
|
||||
f"Class '{getattr(klass, '__name__', str(klass))}' does not "
|
||||
f"satisfy protocol '{getattr(protocol, '__name__', str(protocol))}'. "
|
||||
f"Missing attributes: {', '.join(missing)}. Ensure the class "
|
||||
f"implements all required methods and descriptors."
|
||||
)
|
||||
raise ProtocolMismatchError(msg)
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ from cleveragents.resource.handlers._base import (
|
||||
EMPTY_CONTENT_HASH,
|
||||
BaseResourceHandler,
|
||||
)
|
||||
from cleveragents.resource.handlers.discovery import discover_devcontainers
|
||||
from cleveragents.resource.handlers.protocol import (
|
||||
CheckpointResult,
|
||||
Content,
|
||||
@@ -233,10 +234,13 @@ class FsDirectoryHandler(BaseResourceHandler):
|
||||
)
|
||||
|
||||
def discover_children(self, *, resource: Resource) -> list[Resource]:
|
||||
"""Discover subdirectories as child resources.
|
||||
"""Discover subdirectories and devcontainer instances as child resources.
|
||||
|
||||
Each immediate subdirectory becomes a child ``fs-directory``
|
||||
resource.
|
||||
resource. Additionally, any ``.devcontainer/`` configurations
|
||||
found at the resource location are registered as
|
||||
``devcontainer-instance`` child resources with
|
||||
``provisioning_state: discovered``.
|
||||
|
||||
Args:
|
||||
resource: The parent fs-directory resource.
|
||||
@@ -261,6 +265,28 @@ class FsDirectoryHandler(BaseResourceHandler):
|
||||
)
|
||||
children.append(child)
|
||||
|
||||
# Wire devcontainer auto-discovery (issue #4740)
|
||||
dc_results = discover_devcontainers(location, "fs-directory")
|
||||
for dc_result in dc_results:
|
||||
config_name = dc_result.config_name or "default"
|
||||
dc_child = Resource(
|
||||
resource_id=self._derive_child_id(
|
||||
resource.resource_id, f"devcontainer-{config_name}"
|
||||
),
|
||||
name=f"devcontainer-{config_name}",
|
||||
resource_type_name="devcontainer-instance",
|
||||
classification=resource.classification,
|
||||
description=f"Devcontainer at {dc_result.config_path}",
|
||||
location=location,
|
||||
parents=[resource.resource_id],
|
||||
properties={
|
||||
"devcontainer_json_path": str(dc_result.config_path),
|
||||
"config_name": config_name,
|
||||
"provisioning_state": "discovered",
|
||||
},
|
||||
)
|
||||
children.append(dc_child)
|
||||
|
||||
return children
|
||||
|
||||
# -- Checkpoint and rollback (issue #836) ------------------------------
|
||||
|
||||
@@ -6,17 +6,18 @@ strategy (with fallback to ``copy_on_write``).
|
||||
|
||||
Content CRUD operations (issue #827):
|
||||
|
||||
- ``read`` — ``git show HEAD:<path>``
|
||||
- ``write`` — atomic file write inside the checkout
|
||||
- ``delete`` — ``os.remove`` + ``git rm --cached``
|
||||
- ``list_children`` — ``git ls-tree -r --name-only HEAD``
|
||||
- ``diff`` — ``git diff --no-index``
|
||||
- ``discover_children`` — ``git ls-tree --name-only HEAD``
|
||||
- ``read`` -- ``git show HEAD:<path>``
|
||||
- ``write`` -- atomic file write inside the checkout
|
||||
- ``delete`` -- ``os.remove`` + ``git rm --cached``
|
||||
- ``list_children`` -- ``git ls-tree -r --name-only HEAD``
|
||||
- ``diff`` -- ``git diff --no-index``
|
||||
- ``discover_children`` -- ``git ls-tree --name-only HEAD`` + devcontainer discovery
|
||||
|
||||
Based on:
|
||||
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
|
||||
- Built-in type definition in resource_registry_service.py L62-98
|
||||
- Issue #827 — ResourceHandler CRUD and discovery methods
|
||||
- Issue #827 -- ResourceHandler CRUD and discovery methods
|
||||
- Issue #4740 -- Wire discover_devcontainers() into git-checkout handler
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -36,6 +37,7 @@ from cleveragents.resource.handlers._base import (
|
||||
EMPTY_CONTENT_HASH,
|
||||
BaseResourceHandler,
|
||||
)
|
||||
from cleveragents.resource.handlers.discovery import discover_devcontainers
|
||||
from cleveragents.resource.handlers.protocol import (
|
||||
CheckpointResult,
|
||||
Content,
|
||||
@@ -303,17 +305,20 @@ class GitCheckoutHandler(BaseResourceHandler):
|
||||
)
|
||||
|
||||
def discover_children(self, *, resource: Resource) -> list[Resource]:
|
||||
"""Discover child resources via ``git ls-tree``.
|
||||
"""Discover child resources via ``git ls-tree`` and devcontainer scan.
|
||||
|
||||
Each top-level directory in the repo becomes a child resource
|
||||
of type ``fs-directory``.
|
||||
of type ``fs-directory``. Additionally, any ``.devcontainer/``
|
||||
configurations found at the resource location are registered as
|
||||
``devcontainer-instance`` child resources with
|
||||
``provisioning_state: discovered``.
|
||||
|
||||
Args:
|
||||
resource: The parent git-checkout resource.
|
||||
|
||||
Returns:
|
||||
List of child :class:`Resource` objects for top-level
|
||||
directories.
|
||||
directories and discovered devcontainer instances.
|
||||
"""
|
||||
location = self._require_location(resource)
|
||||
|
||||
@@ -346,6 +351,28 @@ class GitCheckoutHandler(BaseResourceHandler):
|
||||
)
|
||||
children.append(child)
|
||||
|
||||
# Wire devcontainer auto-discovery (issue #4740)
|
||||
dc_results = discover_devcontainers(location, "git-checkout")
|
||||
for dc_result in dc_results:
|
||||
config_name = dc_result.config_name or "default"
|
||||
dc_child = Resource(
|
||||
resource_id=self._derive_child_id(
|
||||
resource.resource_id, f"devcontainer-{config_name}"
|
||||
),
|
||||
name=f"devcontainer-{config_name}",
|
||||
resource_type_name="devcontainer-instance",
|
||||
classification=resource.classification,
|
||||
description=f"Devcontainer at {dc_result.config_path}",
|
||||
location=location,
|
||||
parents=[resource.resource_id],
|
||||
properties={
|
||||
"devcontainer_json_path": str(dc_result.config_path),
|
||||
"config_name": config_name,
|
||||
"provisioning_state": "discovered",
|
||||
},
|
||||
)
|
||||
children.append(dc_child)
|
||||
|
||||
return children
|
||||
|
||||
# -- Checkpoint and rollback (issue #836) ------------------------------
|
||||
|
||||
@@ -146,7 +146,7 @@ if _TEXTUAL_AVAILABLE:
|
||||
def action_help(self) -> None:
|
||||
prompt = self.query_one("#prompt", PromptInput)
|
||||
help_panel = self.query_one("#help-panel", HelpPanelOverlay)
|
||||
context_name = resolve_help_context(prompt.text)
|
||||
context_name = resolve_help_context(prompt.value)
|
||||
help_panel.toggle(context_name)
|
||||
|
||||
def action_cycle_preset(self) -> None:
|
||||
|
||||
@@ -39,9 +39,23 @@ Screen {
|
||||
}
|
||||
|
||||
#prompt {
|
||||
layout: horizontal;
|
||||
height: auto;
|
||||
border: round $primary;
|
||||
margin: 1 0 0 0;
|
||||
align-horizontal: left;
|
||||
}
|
||||
|
||||
#prompt > .prompt-symbol {
|
||||
padding: 0 1;
|
||||
content-align: center middle;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
#prompt > Input {
|
||||
border: none;
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#persona-bar {
|
||||
|
||||
@@ -19,6 +19,7 @@ class InputMode(StrEnum):
|
||||
NORMAL = "normal"
|
||||
COMMAND = "command"
|
||||
SHELL = "shell"
|
||||
MULTILINE = "multiline"
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
@@ -53,6 +54,8 @@ class InputModeRouter:
|
||||
return InputMode.COMMAND
|
||||
if stripped.startswith(("!", "$")):
|
||||
return InputMode.SHELL
|
||||
if "\n" in text or "```" in text:
|
||||
return InputMode.MULTILINE
|
||||
return InputMode.NORMAL
|
||||
|
||||
def process(self, text: str) -> ModeResult:
|
||||
|
||||
@@ -1,27 +1,111 @@
|
||||
"""Prompt widget and submitted message event."""
|
||||
"""Prompt widget with mode-aware symbol handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast
|
||||
|
||||
from cleveragents.tui.input.modes import InputMode, InputModeRouter
|
||||
|
||||
|
||||
class _InputChangedEvent(Protocol):
|
||||
input: Any
|
||||
value: str
|
||||
|
||||
|
||||
class _MutableValueInput(Protocol):
|
||||
value: str
|
||||
|
||||
def focus(self) -> None: ...
|
||||
|
||||
|
||||
class _StaticWidget(Protocol):
|
||||
def __init__(self, text: str = "", *args: object, **kwargs: object) -> None: ...
|
||||
|
||||
def update(self, text: str) -> None: ...
|
||||
|
||||
|
||||
class _HorizontalWidget(Protocol):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None: ...
|
||||
|
||||
def focus(self) -> None: ...
|
||||
|
||||
|
||||
class _InputWidget(_MutableValueInput, Protocol):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None: ...
|
||||
|
||||
|
||||
def _load_input_base() -> type[Any]:
|
||||
try:
|
||||
return importlib.import_module("textual.widgets").TextArea
|
||||
except Exception: # pragma: no cover
|
||||
return importlib.import_module("textual.widgets").Input
|
||||
except Exception: # pragma: no cover - optional dependency
|
||||
|
||||
class _FallbackInput:
|
||||
text = ""
|
||||
value = ""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.text = ""
|
||||
self.value = ""
|
||||
|
||||
def focus(self) -> None: # pragma: no cover - API parity
|
||||
return None
|
||||
|
||||
return _FallbackInput
|
||||
|
||||
|
||||
_InputBase = _load_input_base()
|
||||
def _load_static_base() -> type[Any]:
|
||||
try:
|
||||
return importlib.import_module("textual.widgets").Static
|
||||
except Exception: # pragma: no cover - optional dependency
|
||||
|
||||
class _FallbackStatic:
|
||||
def __init__(self, text: str = "", *args: object, **kwargs: object) -> None:
|
||||
self._text = text
|
||||
|
||||
def update(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
return _FallbackStatic
|
||||
|
||||
|
||||
def _load_horizontal_base() -> type[Any]:
|
||||
try:
|
||||
return importlib.import_module("textual.containers").Horizontal
|
||||
except Exception: # pragma: no cover - optional dependency
|
||||
|
||||
class _FallbackHorizontal:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
del args, kwargs
|
||||
self._children: list[Any] = []
|
||||
|
||||
def compose(self) -> Iterable[Any]:
|
||||
yield from self._children
|
||||
|
||||
def mount(self, widget: Any) -> None:
|
||||
self._children.append(widget)
|
||||
|
||||
def focus(self) -> None: # pragma: no cover - API parity
|
||||
return None
|
||||
|
||||
return _FallbackHorizontal
|
||||
|
||||
|
||||
_InputBase = cast(type[_InputWidget], _load_input_base())
|
||||
_StaticBase = cast(type[_StaticWidget], _load_static_base())
|
||||
_HorizontalBase = cast(type[_HorizontalWidget], _load_horizontal_base())
|
||||
_TEXTUAL_AVAILABLE = _InputBase.__module__.startswith("textual.")
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
_ComposeResult = Iterable[Any]
|
||||
else:
|
||||
_ComposeResult = Iterable[Any]
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
@@ -31,10 +115,129 @@ class PromptSubmitted:
|
||||
text: str
|
||||
|
||||
|
||||
class PromptInput(_InputBase):
|
||||
"""TextArea widget wrapper with helper methods."""
|
||||
_PROMPT_NORMAL = chr(0x276F)
|
||||
_PROMPT_COMMAND = "/"
|
||||
_PROMPT_SHELL = "$"
|
||||
_PROMPT_MULTILINE = chr(0x2630)
|
||||
|
||||
_PROMPT_SYMBOLS: dict[InputMode, str] = {
|
||||
InputMode.NORMAL: _PROMPT_NORMAL,
|
||||
InputMode.COMMAND: _PROMPT_COMMAND,
|
||||
InputMode.SHELL: _PROMPT_SHELL,
|
||||
InputMode.MULTILINE: _PROMPT_MULTILINE,
|
||||
}
|
||||
|
||||
|
||||
class _PromptSymbolMixin:
|
||||
"""Mixin providing mode-aware prompt symbol updates.
|
||||
|
||||
Subclasses must define a ``value`` property (str) and implement
|
||||
``_apply_symbol(symbol: str) -> None``.
|
||||
|
||||
.. note::
|
||||
|
||||
The spec (§29257) refers to "PromptTextArea" — implying a
|
||||
multi-line ``TextArea`` widget. However, ``TextArea.__init__()``
|
||||
dropped the ``placeholder`` keyword in textual >=1.0, so this
|
||||
implementation uses ``textual.widgets.Input`` (single-line)
|
||||
instead. The ``MULTILINE`` mode is detected by content
|
||||
(``\\n`` or triple-backtick) and the symbol updates correctly,
|
||||
but the underlying ``Input`` widget cannot display multiple
|
||||
lines. A future migration to ``TextArea`` (once placeholder
|
||||
support is restored or replaced) would enable true multi-line
|
||||
editing per spec §30209-30218.
|
||||
"""
|
||||
|
||||
_current_symbol: str
|
||||
|
||||
def _apply_symbol(self, symbol: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def _update_symbol(self, raw_text: str) -> None:
|
||||
symbol = _PROMPT_SYMBOLS[InputModeRouter.detect_mode(raw_text)]
|
||||
self._current_symbol = symbol
|
||||
self._apply_symbol(symbol)
|
||||
|
||||
@property
|
||||
def prompt_symbol(self) -> str:
|
||||
return self._current_symbol
|
||||
|
||||
def consume_text(self) -> PromptSubmitted:
|
||||
text = self.text
|
||||
self.text = ""
|
||||
text = self.value
|
||||
self.value = ""
|
||||
return PromptSubmitted(text=text)
|
||||
|
||||
|
||||
class _TextualPromptInput(_PromptSymbolMixin, _HorizontalBase):
|
||||
"""Composite widget that displays a mode-aware prompt symbol."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
placeholder: str = "",
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
horizontal = cast(Any, super())
|
||||
horizontal.__init__(name=name, id=id, classes=classes)
|
||||
self._symbol_widget = _StaticBase("", classes="prompt-symbol")
|
||||
input_id = f"{id}--input" if id else None
|
||||
self._input = cast(
|
||||
_MutableValueInput, _InputBase(placeholder=placeholder, id=input_id)
|
||||
)
|
||||
self._current_symbol = _PROMPT_SYMBOLS[InputMode.NORMAL]
|
||||
self._update_symbol(self._input.value)
|
||||
|
||||
def compose(self) -> _ComposeResult:
|
||||
yield self._symbol_widget
|
||||
yield self._input
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
return self._input.value
|
||||
|
||||
@value.setter
|
||||
def value(self, new_value: str) -> None:
|
||||
self._input.value = new_value
|
||||
self._update_symbol(new_value)
|
||||
|
||||
def focus(self) -> None: # pragma: no cover - delegating to inner input
|
||||
self._input.focus()
|
||||
|
||||
def on_input_changed(self, event: _InputChangedEvent) -> None:
|
||||
if event.input is self._input:
|
||||
self._update_symbol(event.value)
|
||||
|
||||
def _apply_symbol(self, symbol: str) -> None:
|
||||
self._symbol_widget.update(symbol)
|
||||
|
||||
|
||||
class _FallbackPromptInput(_PromptSymbolMixin):
|
||||
"""Fallback prompt input used when Textual is unavailable."""
|
||||
|
||||
def __init__(self, placeholder: str = "", **_: object) -> None:
|
||||
self.placeholder = placeholder
|
||||
self._input = cast(_MutableValueInput, _InputBase())
|
||||
self._current_symbol = _PROMPT_SYMBOLS[InputMode.NORMAL]
|
||||
self._update_symbol(self._input.value)
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
return getattr(self._input, "value", "")
|
||||
|
||||
@value.setter
|
||||
def value(self, new_value: str) -> None:
|
||||
self._input.value = new_value
|
||||
self._update_symbol(new_value)
|
||||
|
||||
def focus(self) -> None: # pragma: no cover - API parity
|
||||
focus = getattr(self._input, "focus", None)
|
||||
if callable(focus):
|
||||
focus()
|
||||
|
||||
def _apply_symbol(self, symbol: str) -> None:
|
||||
self._current_symbol = symbol
|
||||
|
||||
|
||||
PromptInput = _TextualPromptInput if _TEXTUAL_AVAILABLE else _FallbackPromptInput
|
||||
|
||||
@@ -17,13 +17,12 @@ import yaml
|
||||
|
||||
from cleveragents.actor.registry import ActorRegistry
|
||||
from cleveragents.actor.schema import ActorConfigSchema, ActorType, is_v3_yaml
|
||||
from cleveragents.config.settings import ProviderDefaults, Settings
|
||||
from cleveragents.config.settings import ProviderDefaults
|
||||
from cleveragents.core.exceptions import NotFoundError
|
||||
from cleveragents.domain.models.core.actor import Actor
|
||||
from cleveragents.providers.registry import (
|
||||
ProviderCapabilities,
|
||||
ProviderInfo,
|
||||
ProviderRegistry,
|
||||
ProviderType,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Unit tests for pluggable scope chain resolution (PR #10658).
|
||||
|
||||
Tests that ``ComponentResolver`` correctly delegates to a pluggable
|
||||
``ContextScopeChainExtension`` when one is set via ``set_scope_chain()``,
|
||||
and falls back to the built-in plan > project > global chain otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from cleveragents.application.services.component_resolver import (
|
||||
ComponentNotFoundError,
|
||||
ComponentRegistrationError,
|
||||
ComponentResolver,
|
||||
ExtensionPointEntry,
|
||||
ResolutionResult,
|
||||
ScopeLevel,
|
||||
)
|
||||
from cleveragents.infrastructure.plugins.extension_protocols import (
|
||||
ContextScopeChainExtension,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test scope chain implementations (Protocol-based stubs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ReverseOrderScopeChain:
|
||||
"""Scope chain that resolves global > project > plan (reverse order)."""
|
||||
|
||||
@property
|
||||
def scope_chain_name(self) -> str:
|
||||
return "reverse_order"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
component_type: type[Any],
|
||||
scopes: Sequence[Mapping[str, Any]],
|
||||
fallback: Any = None,
|
||||
) -> tuple[Any, str]:
|
||||
# Check global first (last in typical chains)
|
||||
for scope_entry in scopes:
|
||||
if scope_entry.get("scope") == "global":
|
||||
if fallback is not None:
|
||||
return fallback(component_type)
|
||||
raise ComponentNotFoundError(f"No implementation for {component_type.__name__}")
|
||||
|
||||
|
||||
class _GlobalFirstScopeChain:
|
||||
"""Scope chain that always picks global first (short-circuit)."""
|
||||
|
||||
@property
|
||||
def scope_chain_name(self) -> str:
|
||||
return "global_first"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
component_type: type[Any],
|
||||
scopes: Sequence[Mapping[str, Any]],
|
||||
fallback: Any = None,
|
||||
) -> tuple[Any, str]:
|
||||
# Always return global if available
|
||||
for scope_entry in scopes:
|
||||
if scope_entry.get("scope") == "global":
|
||||
try:
|
||||
impl, scope = fallback(component_type)
|
||||
return impl, scope
|
||||
except ComponentNotFoundError:
|
||||
pass
|
||||
raise ComponentNotFoundError(f"No implementation found.")
|
||||
|
||||
|
||||
class _CustomLayerScopeChain:
|
||||
"""Scope chain with an extra 'feature' layer between plan and project."""
|
||||
|
||||
@property
|
||||
def scope_chain_name(self) -> str:
|
||||
return "custom_layer"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
component_type: type[Any],
|
||||
scopes: Sequence[Mapping[str, Any]],
|
||||
fallback: Any = None,
|
||||
) -> tuple[Any, str]:
|
||||
# Simulate feature-layer check (always returns global in this stub)
|
||||
return fallback(component_type)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — built-in chain still works when no pluggable resolver is set
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuiltInScopeChainDefaults:
|
||||
"""Verify the default (built-in) path still works without a pluggable chain."""
|
||||
|
||||
def test_global_only(self) -> None:
|
||||
resolver = ComponentResolver()
|
||||
|
||||
class MyProtocol: ... # type: ignore[valid-type,misc]
|
||||
|
||||
impl = object()
|
||||
resolver.register_global(MyProtocol, impl)
|
||||
|
||||
result = resolver.resolve(MyProtocol)
|
||||
assert result.component is impl
|
||||
assert result.scope == ScopeLevel.GLOBAL
|
||||
|
||||
def test_project_overrides_global(self) -> None:
|
||||
resolver = ComponentResolver()
|
||||
|
||||
class MyProtocol: ... # type: ignore[valid-type,misc]
|
||||
|
||||
global_impl = object()
|
||||
project_impl = object()
|
||||
resolver.register_global(MyProtocol, global_impl)
|
||||
resolver.register_project("p1", MyProtocol, project_impl)
|
||||
|
||||
result = resolver.resolve(MyProtocol, project_id="p1")
|
||||
assert result.component is project_impl
|
||||
assert result.scope == ScopeLevel.PROJECT
|
||||
|
||||
def test_plan_overrides_project(self) -> None:
|
||||
resolver = ComponentResolver()
|
||||
|
||||
class MyProtocol: ... # type: ignore[valid-type,misc]
|
||||
|
||||
global_impl = object()
|
||||
project_impl = object()
|
||||
plan_impl = object()
|
||||
resolver.register_global(MyProtocol, global_impl)
|
||||
resolver.register_project("p1", MyProtocol, project_impl)
|
||||
resolver.register_plan("pl1", MyProtocol, plan_impl)
|
||||
|
||||
result = resolver.resolve(MyProtocol, plan_id="pl1", project_id="p1")
|
||||
assert result.component is plan_impl
|
||||
assert result.scope == ScopeLevel.PLAN
|
||||
|
||||
def test_not_found_raises(self) -> None:
|
||||
resolver = ComponentResolver()
|
||||
|
||||
class Unregistered: ... # type: ignore[valid-type,misc]
|
||||
|
||||
with pytest.raises(ComponentNotFoundError):
|
||||
resolver.resolve(Unregistered)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — pluggable scope chain integration (PR #10658)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPluggableScopeChainIntegration:
|
||||
"""Verify that set_scope_chain() correctly overrides the built-in chain."""
|
||||
|
||||
def test_set_scope_chain_replaces_default(self) -> None:
|
||||
resolver = ComponentResolver()
|
||||
|
||||
class MyProtocol: ... # type: ignore[valid-type,misc]
|
||||
|
||||
impl = object()
|
||||
resolver.register_global(MyProtocol, impl)
|
||||
|
||||
# No pluggable chain — uses default → GLOBAL
|
||||
result = resolver.resolve(MyProtocol)
|
||||
assert result.scope == ScopeLevel.GLOBAL
|
||||
|
||||
def test_set_scope_chain_custom_resolver_used(self) -> None:
|
||||
resolver = ComponentResolver()
|
||||
|
||||
class MyProtocol: ... # type: ignore[valid-type,misc]
|
||||
|
||||
impl = object()
|
||||
resolver.register_global(MyProtocol, impl)
|
||||
|
||||
# Set a custom chain — it receives the fallback to global.
|
||||
chain: ContextScopeChainExtension = _GlobalFirstScopeChain()
|
||||
resolver.set_scope_chain(chain)
|
||||
|
||||
result = resolver.resolve(MyProtocol)
|
||||
assert result.scope == ScopeLevel.GLOBAL # custom chain delegates to default
|
||||
assert result.component is impl
|
||||
|
||||
def test_set_scope_chain_none_restores_default(self) -> None:
|
||||
resolver = ComponentResolver()
|
||||
|
||||
class MyProtocol: ... # type: ignore[valid-type,misc]
|
||||
|
||||
global_impl = object()
|
||||
project_impl = object()
|
||||
resolver.register_global(MyProtocol, global_impl)
|
||||
resolver.register_project("p1", MyProtocol, project_impl)
|
||||
|
||||
# First use pluggable chain
|
||||
resolver.set_scope_chain(_CustomLayerScopeChain())
|
||||
result = resolver.resolve(MyProtocol, project_id="p1")
|
||||
assert result.scope == ScopeLevel.GLOBAL # custom chain delegates to default
|
||||
|
||||
# Clear the pluggable chain — reverts to built-in plan > project > global
|
||||
resolver.set_scope_chain(None)
|
||||
result = resolver.resolve(MyProtocol, project_id="p1")
|
||||
assert result.scope == ScopeLevel.PROJECT
|
||||
assert result.component is project_impl
|
||||
|
||||
def test_set_scope_chain_receives_scope_metadata(self) -> None:
|
||||
"""Verify the custom chain receives scope metadata dict list."""
|
||||
received_scopes: list | None = None
|
||||
|
||||
class TracingScopeChain(ContextScopeChainExtension): # type: ignore[misc]
|
||||
@property
|
||||
def scope_chain_name(self) -> str:
|
||||
return "tracing"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
component_type: type[Any],
|
||||
scopes: Sequence[Mapping[str, Any]],
|
||||
fallback: Any = None,
|
||||
) -> tuple[Any, str]:
|
||||
nonlocal received_scopes
|
||||
received_scopes = list(scopes)
|
||||
return fallback(component_type)
|
||||
|
||||
resolver = ComponentResolver()
|
||||
|
||||
class MyProtocol: ... # type: ignore[valid-type,misc]
|
||||
|
||||
resolver.register_global(MyProtocol, object())
|
||||
resolver.set_scope_chain(TracingScopeChain())
|
||||
|
||||
resolver.resolve(MyProtocol, plan_id="pl1", project_id="p1")
|
||||
|
||||
assert received_scopes is not None
|
||||
scope_names = [s["scope"] for s in received_scopes]
|
||||
assert "plan" in scope_names
|
||||
assert "project" in scope_names
|
||||
assert "global" in scope_names
|
||||
|
||||
|
||||
class TestPluggableScopeChainExtensionProtocol:
|
||||
"""Verify the ContextScopeChainExtension protocol definitions."""
|
||||
|
||||
def test_extension_point_registered_in_catalog(self) -> None:
|
||||
from cleveragents.infrastructure.plugins.extension_catalog import (
|
||||
get_extension_point_definitions,
|
||||
TOTAL_EXTENSION_POINTS,
|
||||
)
|
||||
|
||||
assert TOTAL_EXTENSION_POINTS == 31
|
||||
names = [ep.name for ep in get_extension_point_definitions()]
|
||||
assert "context.scope_chain" in names
|
||||
|
||||
def test_protocol_has_required_attributes(self) -> None:
|
||||
"""The Protocol should define scope_chain_name and resolve()."""
|
||||
import inspect
|
||||
|
||||
# Check scope_chain_name property exists
|
||||
assert hasattr(_GlobalFirstScopeChain, "scope_chain_name")
|
||||
|
||||
# Check resolve method signature matches Protocol contract
|
||||
sig = inspect.signature(ContextScopeChainExtension.resolve)
|
||||
params = list(sig.parameters.keys())
|
||||
assert "component_type" in params
|
||||
assert "scopes" in params
|
||||
assert "fallback" in params
|
||||
|
||||
|
||||
class TestPluggableScopeChainEdgeCases:
|
||||
"""Edge cases and integration tests for pluggable scope chains."""
|
||||
|
||||
def test_multiple_set_scope_chain_calls(self) -> None:
|
||||
resolver = ComponentResolver()
|
||||
|
||||
class MyProtocol: ... # type: ignore[valid-type,misc]
|
||||
|
||||
impl1 = object()
|
||||
impl2 = object()
|
||||
resolver.register_global(MyProtocol, impl1)
|
||||
|
||||
chain_a = _GlobalFirstScopeChain()
|
||||
chain_b = _ReverseOrderScopeChain()
|
||||
|
||||
resolver.set_scope_chain(chain_a)
|
||||
result1 = resolver.resolve(MyProtocol)
|
||||
assert result1.component is impl1
|
||||
|
||||
resolver.set_scope_chain(chain_b)
|
||||
result2 = resolver.resolve(MyProtocol)
|
||||
# chain_b also delegates to fallback (global) in this test config
|
||||
assert result2.component is impl1
|
||||
|
||||
def test_pluggable_chain_with_no_global_registration(self) -> None:
|
||||
resolver = ComponentResolver()
|
||||
|
||||
class MyProtocol: ... # type: ignore[valid-type,misc]
|
||||
|
||||
resolver.set_scope_chain(_GlobalFirstScopeChain())
|
||||
|
||||
with pytest.raises(ComponentNotFoundError):
|
||||
resolver.resolve(MyProtocol)
|
||||
|
||||
def test_extension_point_introspection(self) -> None:
|
||||
resolver = ComponentResolver()
|
||||
|
||||
class MyProtocol: ... # type: ignore[valid-type,misc]
|
||||
|
||||
resolver.register_extension_point(
|
||||
MyProtocol, description="Test protocol", category="test"
|
||||
)
|
||||
points = resolver.list_extension_points()
|
||||
assert len(points) == 1
|
||||
pt = points[0]
|
||||
assert pt.component_type is MyProtocol
|
||||
assert pt.category == "test"
|
||||
Reference in New Issue
Block a user