Compare commits

..

3 Commits

Author SHA1 Message Date
HAL9000 223d808789 refactor(cli): unify error handling and user feedback 2026-04-19 08:09:28 +00:00
HAL9000 0a63149101 docs: add troubleshooting guide 2026-04-19 07:37:17 +00:00
Repository Isolator 30a058282c docs(showcase): add REPL and actor run CLI showcase
- Register REPL and actor run showcase in examples.json
- Add BDD tests for showcase documentation structure
- Verify markdown file exists and is properly formatted
- Validate JSON metadata for showcase entry
- Test documented commands and content sections

Closes #7552
2026-04-19 07:21:39 +00:00
17 changed files with 5969 additions and 1547 deletions
-8
View File
@@ -28,14 +28,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
correctly in all deployment modes: Docker containers, local pip installs
(wheel or editable), and development environments.
- **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in
- **bug-hunt-pool-supervisor Non-Blocking Tracking** (#8835): The automation-tracking-manager
call in step 5 was blocking the main loop indefinitely, causing 3+ consecutive initialization
failures. Step 5 now explicitly marks tracking as best-effort -- if the call does not complete
within a reasonable time or fails, it is skipped and the supervisor continues to the next
cycle. A new Rule 9 reinforces that tracking must never block the main loop; core
functionality (module mapping, worker dispatch, monitoring) takes priority over status
reporting.
`features/environment.py` now emits its non-assertion exception guard warning to
both the structured logger and `stderr` via a new `_warning_with_stderr` helper.
This makes the guard firing visible in standard Behave console output and CI log
-288
View File
@@ -1,288 +0,0 @@
# Agent Development Guide
This comprehensive guide covers the architecture, lifecycle, configuration, and best practices for developing agents in CleverAgents.
## Table of Contents
1. [Agent Architecture Overview](#agent-architecture-overview)
2. [Agent Lifecycle](#agent-lifecycle)
3. [Agent Configuration](#agent-configuration)
4. [Skill Integration and Management](#skill-integration-and-management)
5. [Tool Integration](#tool-integration)
6. [Resource Management](#resource-management)
7. [Error Handling and Recovery](#error-handling-and-recovery)
8. [Agent Communication Patterns](#agent-communication-patterns)
9. [Testing Agents](#testing-agents)
10. [Performance Optimization](#performance-optimization)
11. [Real-world Examples](#real-world-examples)
## Agent Architecture Overview
An **Agent** in CleverAgents is an autonomous entity that can perceive, reason, act, and learn.
### Agent Components
- **Agent State & Configuration** - Identity, goals, and configuration parameters
- **Reasoning Engine** - LLM integration, planning, and decision-making
- **Execution Layer** - Skill execution, tool invocation, resource management
- **Integration Points** - Skills, tools, resources, and providers
### Agent Types
CleverAgents supports several agent patterns:
1. **Task-Specific Agents** - Focused on a single domain or task
2. **Orchestrator Agents** - Coordinate multiple sub-agents
3. **Reactive Agents** - Respond to events with minimal planning
4. **Deliberative Agents** - Perform extensive planning before execution
## Agent Lifecycle
The agent lifecycle consists of several key phases:
1. **Created** - Agent instance instantiated
2. **Initialized** - Configuration loaded, skills registered
3. **Ready** - Agent prepared for execution
4. **Executing** - Processing tasks, invoking skills
5. **Paused** - (Optional) Suspended execution
6. **Completed** - Task finished successfully
7. **Cleaned Up** - Resources released
### Lifecycle Hooks
Agents support hooks at various lifecycle points:
- `on_initialize()` - Called after initialization
- `on_execution_start()` - Called when execution begins
- `on_execution_end(result)` - Called when execution completes
- `on_error(error)` - Called when an error occurs
- `on_cleanup()` - Called during cleanup
## Agent Configuration
Agents are configured through a hierarchical configuration system supporting:
- **Core settings** - max_iterations, timeout, temperature
- **Model configuration** - provider, model name, API keys
- **Skills configuration** - enabled skills and their settings
- **Tool configuration** - available tools and their settings
- **Resource limits** - memory, CPU, timeout constraints
- **Safety settings** - guardrails, tool call limits, allowed domains
## Skill Integration and Management
**Skills** are reusable, domain-specific capabilities that agents can invoke.
### Skill Structure
Skills should:
- Have a clear name and description
- Define input parameters and return types
- Implement error handling
- Support configuration
- Be testable in isolation
### Using Skills in Agents
Skills are executed through the agent:
```python
result = agent.execute_skill(
skill_name="document_analysis",
parameters={"document": content, "analysis_type": "full"}
)
```
## Tool Integration
**Tools** are external integrations that agents can invoke to interact with systems, APIs, and services.
### Tool Definition
Tools should:
- Have clear input/output specifications
- Implement error handling
- Support timeout and retry logic
- Be idempotent when possible
- Log execution details
### Using Tools in Agents
Tools are executed through the agent:
```python
result = agent.execute_tool(
tool_name="file_reader",
parameters={"file_path": "/path/to/file.txt"}
)
```
## Resource Management
Agents manage various types of resources:
- **Memory** - Agent state, context, and intermediate results
- **Compute** - CPU time and processing capacity
- **External** - API calls, database connections, file handles
- **Time** - Execution timeouts and deadlines
## Error Handling and Recovery
### Error Types
- **SkillExecutionError** - Skill execution failed
- **ToolExecutionError** - Tool execution failed
- **ResourceExhaustedError** - Resource limits exceeded
- **TimeoutError** - Execution timeout
- **ValidationError** - Input validation failed
### Error Handling Strategies
1. **Try-Catch with Recovery** - Implement retry logic with exponential backoff
2. **Fallback Strategies** - Provide fallback goals or alternative execution paths
3. **Graceful Degradation** - Reduce feature set or use cached results
## Agent Communication Patterns
### Agent-to-Agent Communication
Agents can communicate through:
- Message buses for asynchronous communication
- Direct method calls for synchronous communication
- Event systems for event-driven communication
### Hierarchical Agent Communication
Orchestrator agents can:
- Delegate tasks to sub-agents
- Coordinate multiple sub-agents
- Aggregate results from sub-agents
- Handle failures in sub-agents
## Testing Agents
### Unit Testing
Test individual components:
- Agent initialization
- Skill execution
- Tool execution
- Error handling
### Integration Testing
Test component interactions:
- Skill and tool integration
- Agent and skill integration
- Error recovery in integrated systems
### Performance Testing
Test performance characteristics:
- Execution speed
- Memory usage
- Resource utilization
- Scalability
## Performance Optimization
### Caching Strategies
Implement caching to:
- Avoid redundant skill executions
- Reduce external API calls
- Improve response times
### Parallel Execution
Execute multiple goals in parallel:
- Use thread pools for I/O-bound operations
- Use process pools for CPU-bound operations
- Aggregate results from parallel executions
### Lazy Loading
Load resources on demand:
- Lazy load skills
- Lazy load tools
- Lazy load models
## Real-world Examples
### Example 1: Document Analysis Agent
Analyzes documents and generates insights:
- Extracts text from documents
- Analyzes sentiment
- Extracts entities
- Generates summaries
### Example 2: Project Management Agent
Manages projects and coordinates tasks:
- Plans projects
- Allocates resources
- Tracks progress
- Manages risks
### Example 3: Customer Support Agent
Provides customer support and issue resolution:
- Classifies issues
- Searches knowledge base
- Generates solutions
- Escalates when necessary
## Best Practices
### 1. **Design for Composability**
- Create agents with clear, single responsibilities
- Design skills and tools to be reusable
- Use composition over inheritance
### 2. **Implement Robust Error Handling**
- Handle all expected error types
- Implement recovery strategies
- Log errors comprehensively
### 3. **Manage Resources Carefully**
- Set appropriate resource limits
- Monitor resource usage
- Clean up resources properly
### 4. **Test Thoroughly**
- Write unit tests for skills and tools
- Test agent integration
- Perform performance testing
### 5. **Monitor and Observe**
- Log agent execution
- Track performance metrics
- Monitor resource usage
### 6. **Document Your Agents**
- Document agent purpose and capabilities
- Document skill and tool APIs
- Provide usage examples
## Conclusion
The CleverAgents framework provides a comprehensive platform for building intelligent, autonomous agents. By following the patterns and practices outlined in this guide, you can create robust, scalable agents that effectively accomplish complex tasks.
For more information, see:
- [Agent API Reference](../reference/agent_api_reference.md)
- [Skill Development Guide](./skill_development.md)
- [Tool Integration Guide](./tool_integration.md)
-481
View File
@@ -1,481 +0,0 @@
# Agent API Reference
This reference document provides detailed API documentation for the Agent class and related components in CleverAgents.
## Table of Contents
1. [Agent Class API](#agent-class-api)
2. [Agent Methods and Properties](#agent-methods-and-properties)
3. [Configuration Options](#configuration-options)
4. [Lifecycle Hooks](#lifecycle-hooks)
5. [Error Codes and Exceptions](#error-codes-and-exceptions)
## Agent Class API
### Class Definition
```python
class Agent:
"""
Base class for all agents in CleverAgents.
An agent is an autonomous entity that can perceive its environment,
reason about goals and available actions, and execute plans.
"""
```
### Constructor
```python
def __init__(
self,
name: str,
description: str = "",
version: str = "1.0.0",
config: Optional[Dict[str, Any]] = None
) -> None:
"""
Initialize an agent.
Args:
name: Unique identifier for the agent
description: Human-readable description of the agent
version: Version string for the agent
config: Optional configuration dictionary
"""
```
## Agent Methods and Properties
### Core Methods
#### execute()
```python
def execute(
self,
goal: str,
context: Optional[Dict[str, Any]] = None,
constraints: Optional[Dict[str, Any]] = None,
timeout: Optional[int] = None
) -> Dict[str, Any]:
"""
Execute the agent on a goal.
Args:
goal: The goal to accomplish
context: Optional context information
constraints: Optional execution constraints
timeout: Optional timeout in seconds
Returns:
Execution result dictionary
Raises:
AgentError: If execution fails
TimeoutError: If execution exceeds timeout
"""
```
#### initialize()
```python
def initialize(
self,
config: Dict[str, Any]
) -> None:
"""
Initialize the agent with configuration.
Args:
config: Configuration dictionary
Raises:
ConfigurationError: If configuration is invalid
"""
```
#### cleanup()
```python
def cleanup() -> None:
"""
Clean up agent resources.
Should be called when the agent is no longer needed.
"""
```
### Skill Methods
#### add_skill()
```python
def add_skill(
self,
skill: Skill
) -> None:
"""
Add a skill to the agent.
Args:
skill: Skill instance to add
Raises:
ValueError: If skill name already exists
"""
```
#### execute_skill()
```python
def execute_skill(
self,
skill_name: str,
parameters: Dict[str, Any]
) -> Dict[str, Any]:
"""
Execute a skill.
Args:
skill_name: Name of the skill to execute
parameters: Parameters for the skill
Returns:
Skill execution result
Raises:
SkillNotFoundError: If skill doesn't exist
SkillExecutionError: If skill execution fails
"""
```
### Tool Methods
#### add_tool()
```python
def add_tool(
self,
tool: Tool
) -> None:
"""
Add a tool to the agent.
Args:
tool: Tool instance to add
Raises:
ValueError: If tool name already exists
"""
```
#### execute_tool()
```python
def execute_tool(
self,
tool_name: str,
parameters: Dict[str, Any]
) -> Dict[str, Any]:
"""
Execute a tool.
Args:
tool_name: Name of the tool to execute
parameters: Parameters for the tool
Returns:
Tool execution result
Raises:
ToolNotFoundError: If tool doesn't exist
ToolExecutionError: If tool execution fails
"""
```
### Properties
#### name
```python
@property
def name(self) -> str:
"""Get the agent name."""
```
#### description
```python
@property
def description(self) -> str:
"""Get the agent description."""
```
#### version
```python
@property
def version(self) -> str:
"""Get the agent version."""
```
#### skills
```python
@property
def skills(self) -> Dict[str, Skill]:
"""Get registered skills."""
```
#### tools
```python
@property
def tools(self) -> Dict[str, Tool]:
"""Get registered tools."""
```
## Configuration Options
### Core Configuration
```yaml
agent:
# Agent identity
name: string # Required: Agent name
description: string # Optional: Agent description
version: string # Optional: Agent version (default: "1.0.0")
# Execution settings
max_iterations: integer # Maximum execution iterations (default: 10)
timeout: integer # Timeout in seconds (default: 300)
temperature: float # LLM temperature (default: 0.7)
# Model configuration
model:
provider: string # LLM provider (openai, anthropic, etc.)
name: string # Model name
api_key: string # API key (can use env vars)
# Resource limits
resources:
memory_limit: string # Memory limit (e.g., "2GB")
cpu_limit: string # CPU limit (e.g., "2")
timeout: integer # Timeout in seconds
```
## Lifecycle Hooks
Agents support the following lifecycle hooks:
### on_initialize()
```python
def on_initialize(self) -> None:
"""Called after agent initialization."""
```
### on_execution_start()
```python
def on_execution_start(self) -> None:
"""Called when execution begins."""
```
### on_execution_end()
```python
def on_execution_end(self, result: Dict[str, Any]) -> None:
"""Called when execution completes."""
```
### on_error()
```python
def on_error(self, error: Exception) -> None:
"""Called when an error occurs."""
```
### on_cleanup()
```python
def on_cleanup(self) -> None:
"""Called during cleanup."""
```
## Error Codes and Exceptions
### Exception Hierarchy
```
AgentError (base exception)
├── SkillExecutionError
├── ToolExecutionError
├── ResourceExhaustedError
├── TimeoutError
├── ValidationError
├── ConfigurationError
└── SkillNotFoundError
```
### AgentError
Base exception for all agent-related errors.
```python
class AgentError(Exception):
"""Base exception for agent errors."""
pass
```
### SkillExecutionError
Raised when skill execution fails.
```python
class SkillExecutionError(AgentError):
"""Raised when skill execution fails."""
def __init__(
self,
skill_name: str,
message: str,
cause: Optional[Exception] = None
):
self.skill_name = skill_name
self.message = message
self.cause = cause
```
### ToolExecutionError
Raised when tool execution fails.
```python
class ToolExecutionError(AgentError):
"""Raised when tool execution fails."""
def __init__(
self,
tool_name: str,
message: str,
cause: Optional[Exception] = None
):
self.tool_name = tool_name
self.message = message
self.cause = cause
```
### ResourceExhaustedError
Raised when resource limits are exceeded.
```python
class ResourceExhaustedError(AgentError):
"""Raised when resource limits are exceeded."""
def __init__(
self,
resource_type: str,
limit: Any,
current: Any
):
self.resource_type = resource_type
self.limit = limit
self.current = current
```
### TimeoutError
Raised when execution timeout is exceeded.
```python
class TimeoutError(AgentError):
"""Raised when execution timeout is exceeded."""
def __init__(
self,
timeout: int,
elapsed: int
):
self.timeout = timeout
self.elapsed = elapsed
```
### ValidationError
Raised when input validation fails.
```python
class ValidationError(AgentError):
"""Raised when input validation fails."""
def __init__(
self,
field: str,
message: str
):
self.field = field
self.message = message
```
## Usage Examples
### Basic Agent Usage
```python
from cleveragents.agents import Agent
# Create agent
agent = Agent(
name="my_agent",
description="My custom agent",
version="1.0.0"
)
# Initialize
agent.initialize({
"max_iterations": 10,
"timeout": 300
})
# Execute
result = agent.execute(
goal="Analyze the provided document",
context={"document": "..."}
)
# Cleanup
agent.cleanup()
```
### With Skills and Tools
```python
# Add skills
agent.add_skill(DocumentAnalysisSkill())
agent.add_skill(SummarizationSkill())
# Add tools
agent.add_tool(FileReaderTool())
agent.add_tool(DatabaseWriterTool())
# Execute skill
result = agent.execute_skill(
"document_analysis",
{"document": content}
)
# Execute tool
result = agent.execute_tool(
"file_reader",
{"file_path": "/path/to/file.txt"}
)
```
## See Also
- [Agent Development Guide](../guides/agent_development_guide.md)
- [Skill API Reference](./skill_api_reference.md)
- [Tool API Reference](./tool_api_reference.md)
+21
View File
@@ -21,6 +21,27 @@
"generated_by": "uat-tester",
"generated_at": "2026-04-07"
},
{
"title": "REPL and Actor Run: Interactive AI Sessions from the Terminal",
"category": "cli-tools",
"path": "cli-tools/repl-and-actor-run.md",
"feature": "Interactive REPL and single-shot actor run commands",
"commands": [
"agents repl",
"agents repl --help",
"agents actor run openai/gpt-4o \"Say hello\"",
"agents actor run --help",
"agents actor run local/demo-assistant \"What is the capital of France?\" --config my-assistant.yaml",
"agents actor run local/demo-assistant \"What is the capital of France?\" --config my-assistant.yaml --output answer.txt",
"agents actor run local/demo-assistant \"Write a haiku about mountains\" --config my-assistant.yaml --temperature 0.9",
"agents actor run local/demo-assistant \"I'm working on a Python project\" --config my-assistant.yaml --context my-session --context-dir /tmp/my-contexts",
"agents actor run local/demo-assistant \"What are best practices for error handling?\" --config my-assistant.yaml --context my-session --context-dir /tmp/my-contexts"
],
"complexity": "intermediate",
"educational_value": "high",
"generated_by": "uat-tester",
"generated_at": "2026-04-07"
},
{
"title": "Managing AI Actors with the CleverAgents CLI",
"category": "cli-tools",
+5494 -18
View File
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -72,14 +72,14 @@ Feature: REPL input modes and persona controls
| /persona create ../../etc/cron.d/evil --actor local/mock-default |
Then the REPL mode output should contain "name must not contain path/control separators"
Scenario: Persona export accepts absolute path targets
Scenario: Persona export rejects absolute path targets
Given a temporary REPL config directory
And an absolute persona export path
When I run the REPL with input lines
| line |
| /persona create dev --actor local/mock-default |
| /persona export dev {export_path} |
Then the REPL mode output should contain "Exported persona:"
Then the REPL mode output should contain "Export path must be relative to current working directory"
Scenario: Persona export rejects parent directory escape
Given a temporary REPL config directory
@@ -89,13 +89,13 @@ Feature: REPL input modes and persona controls
| /persona export dev ../outside.yaml |
Then the REPL mode output should contain "Export path must stay within working directory"
Scenario: Persona import accepts absolute path targets
Scenario: Persona import rejects absolute path targets
Given a temporary REPL config directory
And an absolute persona import file path
When I run the REPL with input lines
| line |
| /persona import {absolute_import_path} |
Then the REPL mode output should contain "Imported persona:"
Then the REPL mode output should contain "Import path must be relative to current working directory"
Scenario: Persona binding is independent per REPL session
Given a temporary REPL config directory
+80
View File
@@ -0,0 +1,80 @@
Feature: REPL and Actor Run CLI Showcase Documentation
The showcase documentation for REPL and actor run CLI commands
should be properly registered and accessible.
Scenario: Showcase markdown file exists
Given the showcase directory exists
When I check for the REPL and actor run showcase file
Then the file should exist at "docs/showcase/cli-tools/repl-and-actor-run.md"
Scenario: Showcase is registered in examples.json
Given the examples.json file exists
When I check for the REPL and actor run showcase entry
Then the entry should have title "REPL and Actor Run: Interactive AI Sessions from the Terminal"
And the entry should have category "cli-tools"
And the entry should have path "cli-tools/repl-and-actor-run.md"
And the entry should have complexity "intermediate"
Scenario: Showcase has required commands documented
Given the showcase markdown file exists
When I check the documented commands
Then the file should contain "agents repl"
And the file should contain "agents actor run"
And the file should contain "agents repl --help"
And the file should contain "agents actor run --help"
Scenario: Showcase has REPL section
Given the showcase markdown file exists
When I check the content structure
Then the file should contain "Part 1: The Interactive REPL"
And the file should contain ":help"
And the file should contain ":exit"
And the file should contain "!!"
Scenario: Showcase has Actor Run section
Given the showcase markdown file exists
When I check the content structure
Then the file should contain "Part 2: Single-Shot Actor Run"
And the file should contain "--config"
And the file should contain "--output"
And the file should contain "--temperature"
And the file should contain "--context"
Scenario: Showcase has real output examples
Given the showcase markdown file exists
When I check for verified output
Then the file should contain "Actual Output:"
And the file should contain "verified"
Scenario: Examples.json has valid JSON structure
Given the examples.json file exists
When I parse the JSON
Then the JSON should be valid
And the JSON should have "examples" key
And the JSON should have "categories" key
Scenario: Showcase entry has all required fields
Given the examples.json file exists
When I check the REPL and actor run entry
Then the entry should have "title" field
And the entry should have "category" field
And the entry should have "path" field
And the entry should have "feature" field
And the entry should have "commands" field
And the entry should have "complexity" field
And the entry should have "educational_value" field
Scenario: Showcase commands list is not empty
Given the examples.json file exists
When I check the REPL and actor run entry
Then the commands list should have at least 5 items
And the commands should include "agents repl"
And the commands should include "agents actor run"
Scenario: Showcase file has proper markdown formatting
Given the showcase markdown file exists
When I check the markdown structure
Then the file should start with a heading
And the file should have multiple sections
And the file should have code blocks
And the file should have proper link formatting
@@ -17,9 +17,6 @@ from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.autonomy_guardrail_service import (
AutonomyGuardrailService,
)
from cleveragents.application.services.plan_executor import PlanExecutor
from cleveragents.core.exceptions import (
BudgetExceededError,
@@ -27,7 +24,6 @@ from cleveragents.core.exceptions import (
PlanError,
)
from cleveragents.domain.models.core.automation_profile import AutomationProfile
from cleveragents.domain.models.core.autonomy_guardrails import AutonomyGuardrails
from cleveragents.domain.models.core.cost_metadata import CostMetadata
from cleveragents.domain.models.core.plan import (
PlanPhase,
@@ -97,19 +93,6 @@ def _make_cost_tracker_with_daily_budget(budget: float) -> CostTracker:
return CostTracker(budget_per_day=budget)
def _make_guardrail_service_for_plan(plan_id: str) -> AutonomyGuardrailService:
"""Create an AutonomyGuardrailService with guardrails configured for the plan."""
service = AutonomyGuardrailService()
# Configure guardrails with no step limit or wall clock limit
# so only budget enforcement is tested
guardrails = AutonomyGuardrails(
step_limit=None,
wall_clock_seconds=None,
)
service.configure_guardrails(plan_id, guardrails)
return service
# ---------------------------------------------------------------------------
# Exception creation steps
# ---------------------------------------------------------------------------
@@ -123,7 +106,7 @@ def step_create_budget_exceeded_error(
) -> None:
"""Create a BudgetExceededError with given attributes."""
context.budget_exc = BudgetExceededError(
f"budget exceeded: {used} >= {limit}",
f"Budget exceeded: {used} >= {limit}",
plan_id=pid,
budget_type=btype,
used=used,
@@ -141,22 +124,9 @@ def step_check_budget_exc_plan_id(context: Context, expected: str) -> None:
@then('the BudgetExceededError budget_type should be "{expected}"')
def step_check_budget_exc_budget_type(context: Context, expected: str) -> None:
"""Verify BudgetExceededError budget_type.
Works for both directly created errors (context.budget_exc) and
errors raised by run_execute (context.budget_raised).
"""
# Try context.budget_exc first (directly created error)
exc = getattr(context, "budget_exc", None)
if exc is None:
# Fall back to context.budget_raised (error raised by run_execute)
exc = getattr(context, "budget_raised", None)
assert exc is not None, "No BudgetExceededError found in context"
assert isinstance(exc, BudgetExceededError), (
f"Expected BudgetExceededError, got {type(exc).__name__}"
)
assert exc.budget_type == expected, (
f"Expected budget_type={expected!r}, got {exc.budget_type!r}"
"""Verify BudgetExceededError budget_type."""
assert context.budget_exc.budget_type == expected, (
f"Expected budget_type={expected!r}, got {context.budget_exc.budget_type!r}"
)
@@ -178,10 +148,9 @@ def step_check_budget_exc_limit(context: Context, expected: float) -> None:
@then('the BudgetExceededError message should contain "{text}"')
def step_check_budget_exc_message(context: Context, text: str) -> None:
"""Verify BudgetExceededError message contains text (case-insensitive)."""
msg = str(context.budget_exc).lower()
assert text.lower() in msg, (
f"Expected '{text}' (case-insensitive) in '{context.budget_exc}'"
"""Verify BudgetExceededError message contains text."""
assert text in str(context.budget_exc), (
f"Expected '{text}' in '{context.budget_exc}'"
)
@@ -201,7 +170,7 @@ def step_create_plan_budget_exceeded_error(
) -> None:
"""Create a PlanBudgetExceededError with given attributes."""
context.plan_budget_exc = PlanBudgetExceededError(
f"plan budget exceeded: {used} >= {limit}",
f"Plan budget exceeded: {used} >= {limit}",
plan_id=pid,
used=used,
limit=limit,
@@ -234,10 +203,9 @@ def step_check_plan_budget_exc_limit(context: Context, expected: float) -> None:
@then('the PlanBudgetExceededError message should contain "{text}"')
def step_check_plan_budget_exc_message(context: Context, text: str) -> None:
"""Verify PlanBudgetExceededError message contains text (case-insensitive)."""
msg = str(context.plan_budget_exc).lower()
assert text.lower() in msg, (
f"Expected '{text}' (case-insensitive) in '{context.plan_budget_exc}'"
"""Verify PlanBudgetExceededError message contains text."""
assert text in str(context.plan_budget_exc), (
f"Expected '{text}' in '{context.plan_budget_exc}'"
)
@@ -281,13 +249,10 @@ def step_budget_executor_with_plan_budget(context: Context, budget: float) -> No
context.budget_plan_id = _BUDGET_PLAN_ID
context.budget_cost_tracker = _make_cost_tracker_with_plan_budget(budget)
context.budget_cost_metadata = CostMetadata()
# Create guardrail service so _enforce_guardrails_per_step calls _check_budget
guardrail_service = _make_guardrail_service_for_plan(_BUDGET_PLAN_ID)
context.budget_executor = PlanExecutor(
lifecycle_service=context.budget_lifecycle,
cost_tracker=context.budget_cost_tracker,
cost_metadata=context.budget_cost_metadata,
guardrail_service=guardrail_service,
)
@@ -300,13 +265,10 @@ def step_budget_executor_with_daily_budget(context: Context, budget: float) -> N
context.budget_plan_id = _BUDGET_PLAN_ID
context.budget_cost_tracker = _make_cost_tracker_with_daily_budget(budget)
context.budget_cost_metadata = CostMetadata()
# Create guardrail service so _enforce_guardrails_per_step calls _check_budget
guardrail_service = _make_guardrail_service_for_plan(_BUDGET_PLAN_ID)
context.budget_executor = PlanExecutor(
lifecycle_service=context.budget_lifecycle,
cost_tracker=context.budget_cost_tracker,
cost_metadata=context.budget_cost_metadata,
guardrail_service=guardrail_service,
)
@@ -436,15 +398,15 @@ def step_check_budget_error_raised(context: Context) -> None:
@then("the lifecycle _commit_plan should have been called with budget_halt details")
def step_check_commit_plan_budget_halt(context: Context) -> None:
"""Verify _commit_plan was called (budget halt saves plan state before re-raising).
The _save_plan_state_on_budget_halt method calls _commit_plan before raising
the budget exception. The outer execute handler may also call _commit_plan
with different error_details. We verify _commit_plan was called at least once.
"""
"""Verify _commit_plan was called with budget_halt in error_details."""
assert context.budget_lifecycle._commit_plan.called, (
"Expected _commit_plan to be called by _save_plan_state_on_budget_halt"
"Expected _commit_plan to be called"
)
plan = context.budget_lifecycle.get_plan.return_value
if isinstance(plan.error_details, dict):
assert "budget_halt" in plan.error_details, (
f"Expected 'budget_halt' in error_details, got {plan.error_details}"
)
# ---------------------------------------------------------------------------
-11
View File
@@ -903,14 +903,3 @@ def step_check_post_fail_complete_error(context: Context) -> None:
"Expected PlanError when completing after failure"
)
assert isinstance(context.post_fail_complete_error, PlanError)
@then("a Pydantic validation error should be raised")
def step_check_pydantic_validation_error(context: Context) -> None:
"""Assert that a Pydantic validation error was raised."""
assert context.pydantic_error is not None, (
"Expected a Pydantic validation error to be raised"
)
assert isinstance(context.pydantic_error, (PydanticValidationError, ValueError)), (
f"Expected PydanticValidationError or ValueError, got {type(context.pydantic_error).__name__}"
)
@@ -0,0 +1,209 @@
"""Steps for REPL and Actor Run CLI Showcase Documentation tests."""
import json
from pathlib import Path
from typing import Any
from behave import given, then, when
@given("the showcase directory exists")
def step_showcase_directory_exists(context: Any) -> None:
"""Verify the showcase directory exists."""
showcase_dir = Path("docs/showcase")
assert showcase_dir.exists(), f"Showcase directory not found at {showcase_dir}"
context.showcase_dir = showcase_dir
@given("the examples.json file exists")
def step_examples_json_exists(context: Any) -> None:
"""Verify examples.json exists."""
examples_file = Path("docs/showcase/examples.json")
assert examples_file.exists(), f"examples.json not found at {examples_file}"
context.examples_file = examples_file
# Load the JSON for later use
with open(examples_file) as f:
context.examples_data = json.load(f)
@given("the showcase markdown file exists")
def step_showcase_markdown_exists(context: Any) -> None:
"""Verify the showcase markdown file exists."""
markdown_file = Path("docs/showcase/cli-tools/repl-and-actor-run.md")
assert markdown_file.exists(), f"Showcase markdown not found at {markdown_file}"
context.markdown_file = markdown_file
# Load the content for later use
with open(markdown_file) as f:
context.markdown_content = f.read()
@when("I check for the REPL and actor run showcase file")
def step_check_showcase_file(context: Any) -> None:
"""Check for the showcase file."""
context.file_path = Path("docs/showcase/cli-tools/repl-and-actor-run.md")
@then('the file should exist at "{path}"')
def step_file_exists_at_path(context: Any, path: str) -> None:
"""Verify file exists at the given path."""
file_path = Path(path)
assert file_path.exists(), f"File not found at {path}"
@when("I check for the REPL and actor run showcase entry")
def step_check_showcase_entry(context: Any) -> None:
"""Check for the showcase entry in examples.json."""
examples = context.examples_data.get("examples", [])
# Find the REPL and actor run entry
context.repl_entry = None
for example in examples:
if "repl-and-actor-run" in example.get("path", ""):
context.repl_entry = example
break
assert context.repl_entry is not None, (
"REPL and actor run entry not found in examples.json"
)
@then('the entry should have title "{title}"')
def step_entry_has_title(context: Any, title: str) -> None:
"""Verify entry has the expected title."""
assert context.repl_entry["title"] == title, (
f"Expected title '{title}', got '{context.repl_entry['title']}'"
)
@then('the entry should have category "{category}"')
def step_entry_has_category(context: Any, category: str) -> None:
"""Verify entry has the expected category."""
assert context.repl_entry["category"] == category, (
f"Expected category '{category}', got '{context.repl_entry['category']}'"
)
@then('the entry should have path "{path}"')
def step_entry_has_path(context: Any, path: str) -> None:
"""Verify entry has the expected path."""
assert context.repl_entry["path"] == path, (
f"Expected path '{path}', got '{context.repl_entry['path']}'"
)
@then('the entry should have complexity "{complexity}"')
def step_entry_has_complexity(context: Any, complexity: str) -> None:
"""Verify entry has the expected complexity."""
assert context.repl_entry["complexity"] == complexity, (
f"Expected complexity '{complexity}', got '{context.repl_entry['complexity']}'"
)
@when("I check the documented commands")
def step_check_documented_commands(context: Any) -> None:
"""Check the documented commands in the markdown."""
pass # Content is already loaded in context.markdown_content
@then('the file should contain "{text}"')
def step_file_contains_text(context: Any, text: str) -> None:
"""Verify the file contains the given text."""
assert text in context.markdown_content, f"Text '{text}' not found in markdown file"
@when("I check the content structure")
def step_check_content_structure(context: Any) -> None:
"""Check the content structure."""
pass # Content is already loaded
@when("I check for verified output")
def step_check_verified_output(context: Any) -> None:
"""Check for verified output in the markdown."""
pass # Content is already loaded
@when("I parse the JSON")
def step_parse_json(context: Any) -> None:
"""Parse the JSON file."""
# Already parsed in the given step
pass
@then("the JSON should be valid")
def step_json_is_valid(context: Any) -> None:
"""Verify the JSON is valid."""
assert isinstance(context.examples_data, dict), "JSON is not a valid dictionary"
@then('the JSON should have "{key}" key')
def step_json_has_key(context: Any, key: str) -> None:
"""Verify the JSON has the given key."""
assert key in context.examples_data, f"Key '{key}' not found in JSON"
@when("I check the REPL and actor run entry")
def step_check_repl_entry(context: Any) -> None:
"""Check the REPL and actor run entry."""
# Already done in previous step
pass
@then('the entry should have "{field}" field')
def step_entry_has_field(context: Any, field: str) -> None:
"""Verify entry has the given field."""
assert field in context.repl_entry, f"Field '{field}' not found in entry"
@then("the commands list should have at least {count:d} items")
def step_commands_list_has_items(context: Any, count: int) -> None:
"""Verify the commands list has at least the given number of items."""
commands = context.repl_entry.get("commands", [])
assert len(commands) >= count, (
f"Expected at least {count} commands, got {len(commands)}"
)
@then('the commands should include "{command}"')
def step_commands_include(context: Any, command: str) -> None:
"""Verify the commands list includes the given command."""
commands = context.repl_entry.get("commands", [])
assert command in commands, f"Command '{command}' not found in commands list"
@when("I check the markdown structure")
def step_check_markdown_structure(context: Any) -> None:
"""Check the markdown structure."""
pass # Content is already loaded
@then("the file should start with a heading")
def step_file_starts_with_heading(context: Any) -> None:
"""Verify the file starts with a heading."""
assert context.markdown_content.startswith("#"), (
"Markdown file should start with a heading"
)
@then("the file should have multiple sections")
def step_file_has_multiple_sections(context: Any) -> None:
"""Verify the file has multiple sections."""
heading_count = context.markdown_content.count("\n#")
assert heading_count >= 3, f"Expected at least 3 sections, found {heading_count}"
@then("the file should have code blocks")
def step_file_has_code_blocks(context: Any) -> None:
"""Verify the file has code blocks."""
assert "```" in context.markdown_content, "Markdown file should have code blocks"
@then("the file should have proper link formatting")
def step_file_has_proper_links(context: Any) -> None:
"""Verify the file has proper link formatting."""
# Check for markdown link format [text](url)
assert "[" in context.markdown_content and "](" in context.markdown_content, (
"Markdown file should have proper link formatting"
)
+3 -9
View File
@@ -273,15 +273,9 @@ def step_instantiate_app(context):
)
@then('the app should have a default session with session_id "{sid}"')
def step_app_default_session_id(context, sid):
"""Check the default session in the multi-session app."""
# The app uses _sessions (list) instead of _session (single)
assert hasattr(context._tui_app, "_sessions"), (
"App should have _sessions attribute"
)
assert len(context._tui_app._sessions) > 0, "App should have at least one session"
assert context._tui_app._sessions[0].session_id == sid
@then('the app should have a _session with session_id "{sid}"')
def step_app_session_id(context, sid):
assert context._tui_app._session.session_id == sid
@then("the app should store the command router")
@@ -1,332 +0,0 @@
"""Step definitions for tui/block_cursor_navigation.feature.
These steps target TDD Issue #10491: TUI BINDINGS missing alt+up and alt+down
block cursor navigation keys.
Tests verify:
- ``alt+up`` is present in ``BINDINGS`` and maps to ``cursor_up`` action
- ``alt+down`` is present in ``BINDINGS`` and maps to ``cursor_down`` action
- ``action_cursor_up()`` method exists and moves block cursor up
- ``action_cursor_down()`` method exists and moves block cursor down
- Edge cases: cursor at top (alt+up does nothing), cursor at bottom (alt+down does nothing)
All scenarios are tagged @tdd_expected_fail because the implementation does not
yet exist. When bug #10491 is fixed, the @tdd_expected_fail tag must be removed.
"""
from __future__ import annotations
import importlib
import shutil
import sys
import tempfile
from pathlib import Path
from types import ModuleType
from unittest.mock import MagicMock
from behave import given, then, when
# ---------------------------------------------------------------------------
# Mock Textual infrastructure (mirrors tui_app_coverage_steps.py)
# ---------------------------------------------------------------------------
_MOCK_TEXTUAL_KEYS = [
"textual",
"textual.app",
"textual.containers",
"textual.widgets",
]
def _build_mock_textual_for_cursor() -> dict[str, ModuleType]:
"""Build mock textual modules that satisfy the app import gate."""
mock_textual = ModuleType("textual")
mock_textual_app = ModuleType("textual.app")
mock_textual_containers = ModuleType("textual.containers")
mock_textual_widgets = ModuleType("textual.widgets")
class MockApp:
"""Minimal App stand-in for the Textual base class."""
def __init__(self, *args: object, **kwargs: object) -> None:
self._widgets: dict[str, object] = {}
def query_one(self, selector: str, widget_type: type | None = None) -> object:
if selector in self._widgets:
return self._widgets[selector]
if widget_type is not None:
widget = widget_type(id=selector.lstrip("#"))
self._widgets[selector] = widget
return widget
return MagicMock()
class MockVertical:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
def __enter__(self) -> MockVertical:
return self
def __exit__(self, *args: object) -> None:
pass
class MockHeader:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
class MockFooter:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
class MockStatic:
def __init__(self, *args: object, **kwargs: object) -> None:
self._text = ""
def update(self, text: str) -> None:
self._text = text
class MockInput:
"""Minimal Input stand-in for the Textual base class."""
value: str = ""
def __init__(self, *args: object, **kwargs: object) -> None:
self.value = ""
mock_textual_app.App = MockApp # type: ignore[attr-defined]
mock_textual_containers.Vertical = MockVertical # type: ignore[attr-defined]
mock_textual_widgets.Header = MockHeader # type: ignore[attr-defined]
mock_textual_widgets.Footer = MockFooter # type: ignore[attr-defined]
mock_textual_widgets.Static = MockStatic # type: ignore[attr-defined]
mock_textual_widgets.Input = MockInput # type: ignore[attr-defined]
return {
"textual": mock_textual,
"textual.app": mock_textual_app,
"textual.containers": mock_textual_containers,
"textual.widgets": mock_textual_widgets,
}
def _install_mock_textual_for_cursor(context: object) -> None:
"""Inject mock textual into sys.modules and reload the app module."""
mocks = _build_mock_textual_for_cursor()
context._cursor_saved_modules = {} # type: ignore[attr-defined]
for key in _MOCK_TEXTUAL_KEYS:
context._cursor_saved_modules[key] = sys.modules.pop(key, None) # type: ignore[attr-defined]
for key, mod in mocks.items():
sys.modules[key] = mod
# 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
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.slash_command_overlay as sco_mod
importlib.reload(hp_mod)
importlib.reload(pb_mod)
importlib.reload(prompt_mod)
importlib.reload(rp_mod)
importlib.reload(sco_mod)
import cleveragents.tui.app as app_mod
importlib.reload(app_mod)
context._cursor_app_mod = app_mod # type: ignore[attr-defined]
def _restore_modules_for_cursor(context: object) -> None:
"""Restore original sys.modules and reload the app module."""
for key, val in getattr(context, "_cursor_saved_modules", {}).items():
if val is None:
sys.modules.pop(key, None)
else:
sys.modules[key] = val
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
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.slash_command_overlay as sco_mod
importlib.reload(hp_mod)
importlib.reload(pb_mod)
importlib.reload(prompt_mod)
importlib.reload(rp_mod)
importlib.reload(sco_mod)
import cleveragents.tui.app as app_mod
importlib.reload(app_mod)
def _make_persona_state_for_cursor(context: object) -> object:
"""Create a real PersonaState backed by a temp directory."""
from cleveragents.tui.persona.registry import PersonaRegistry
from cleveragents.tui.persona.state import PersonaState
tmp = tempfile.mkdtemp()
context._cursor_tmpdir = tmp # type: ignore[attr-defined]
registry = PersonaRegistry(config_dir=Path(tmp))
registry.ensure_default()
return PersonaState(registry=registry)
def _cleanup_cursor_tmpdir(context: object) -> None:
tmp = getattr(context, "_cursor_tmpdir", None)
if tmp:
shutil.rmtree(tmp, ignore_errors=True)
# ---------------------------------------------------------------------------
# Background steps
# ---------------------------------------------------------------------------
@given("the TUI app module is imported with mocked Textual for cursor tests")
def step_import_with_mock_textual_cursor(context: object) -> None:
"""Install mock Textual, reload app module, register cleanup."""
_install_mock_textual_for_cursor(context)
context.add_cleanup(lambda: _restore_modules_for_cursor(context)) # type: ignore[attr-defined]
context.add_cleanup(lambda: _cleanup_cursor_tmpdir(context)) # type: ignore[attr-defined]
@given("a mock command router and persona state for cursor tests")
def step_create_mock_deps_cursor(context: object) -> None:
class _FakeCmdRouter:
def handle(self, raw: str, *, session_id: str) -> str:
return f"handled:{raw}"
context._cursor_cmd_router = _FakeCmdRouter() # type: ignore[attr-defined]
context._cursor_persona_state = _make_persona_state_for_cursor(context) # type: ignore[attr-defined]
@given("the Textual TUI app is instantiated for cursor tests")
def step_instantiate_app_cursor(context: object) -> None:
AppClass = context._cursor_app_mod._ResolvedTuiApp # type: ignore[attr-defined]
context._cursor_app = AppClass( # type: ignore[attr-defined]
command_router=context._cursor_cmd_router, # type: ignore[attr-defined]
persona_state=context._cursor_persona_state, # type: ignore[attr-defined]
)
# ---------------------------------------------------------------------------
# BINDINGS assertions
# ---------------------------------------------------------------------------
@then('the BINDINGS list should contain an entry for "{key}"')
def step_bindings_contains_key(context: object, key: str) -> None:
"""Assert that the given key is present in the BINDINGS list."""
app = context._cursor_app # type: ignore[attr-defined]
binding_keys = [b[0] for b in app.BINDINGS]
assert key in binding_keys, (
f"Expected '{key}' in BINDINGS but found: {binding_keys}"
)
@then('the BINDINGS entry for "{key}" should map to action "{action}"')
def step_bindings_key_maps_to_action(context: object, key: str, action: str) -> None:
"""Assert that the given key maps to the expected action in BINDINGS."""
app = context._cursor_app # type: ignore[attr-defined]
matching = [b for b in app.BINDINGS if b[0] == key]
assert matching, f"No BINDINGS entry found for key '{key}'"
binding_action = matching[0][1]
assert binding_action == action, (
f"Expected BINDINGS['{key}'] action to be '{action}' but got '{binding_action}'"
)
# ---------------------------------------------------------------------------
# Method existence assertions
# ---------------------------------------------------------------------------
@then("the TUI app should have an action_cursor_up method")
def step_app_has_cursor_up(context: object) -> None:
"""Assert that action_cursor_up() method exists on the TUI app."""
app = context._cursor_app # type: ignore[attr-defined]
assert hasattr(app, "action_cursor_up") and callable(
app.action_cursor_up # type: ignore[attr-defined]
), "TUI app is missing action_cursor_up() method"
@then("the TUI app should have an action_cursor_down method")
def step_app_has_cursor_down(context: object) -> None:
"""Assert that action_cursor_down() method exists on the TUI app."""
app = context._cursor_app # type: ignore[attr-defined]
assert hasattr(app, "action_cursor_down") and callable(
app.action_cursor_down # type: ignore[attr-defined]
), "TUI app is missing action_cursor_down() method"
# ---------------------------------------------------------------------------
# Block cursor navigation steps
# ---------------------------------------------------------------------------
_SAMPLE_BLOCKS = [
"UserInput: Hello",
"ActorResponse: Hi there",
"ToolCall: search(query='test')",
"PlanProgress: Step 1 complete",
"ActorResponse: Done",
]
@given("the TUI app has conversation blocks loaded")
def step_load_conversation_blocks(context: object) -> None:
"""Load sample conversation blocks into the TUI app."""
app = context._cursor_app # type: ignore[attr-defined]
# The app should expose _conversation_blocks for block cursor navigation
app._conversation_blocks = list(_SAMPLE_BLOCKS) # type: ignore[attr-defined]
app._block_cursor_index = 0 # type: ignore[attr-defined]
@given("the block cursor is positioned at index {index:d}")
def step_set_cursor_index(context: object, index: int) -> None:
"""Set the block cursor to a specific index."""
app = context._cursor_app # type: ignore[attr-defined]
app._block_cursor_index = index # type: ignore[attr-defined]
@given("the block cursor is positioned at the last block")
def step_set_cursor_at_last(context: object) -> None:
"""Set the block cursor to the last block."""
app = context._cursor_app # type: ignore[attr-defined]
app._block_cursor_index = len(app._conversation_blocks) - 1 # type: ignore[attr-defined]
@when("action_cursor_up is called on the TUI app")
def step_call_cursor_up(context: object) -> None:
"""Call action_cursor_up() on the TUI app."""
app = context._cursor_app # type: ignore[attr-defined]
app.action_cursor_up() # type: ignore[attr-defined]
@when("action_cursor_down is called on the TUI app")
def step_call_cursor_down(context: object) -> None:
"""Call action_cursor_down() on the TUI app."""
app = context._cursor_app # type: ignore[attr-defined]
app.action_cursor_down() # type: ignore[attr-defined]
@then("the block cursor index should be {expected:d}")
def step_assert_cursor_index(context: object, expected: int) -> None:
"""Assert the block cursor is at the expected index."""
app = context._cursor_app # type: ignore[attr-defined]
actual = app._block_cursor_index # type: ignore[attr-defined]
assert actual == expected, (
f"Expected block cursor index {expected} but got {actual}"
)
@then("the block cursor index should be at the last block")
def step_assert_cursor_at_last(context: object) -> None:
"""Assert the block cursor is at the last block."""
app = context._cursor_app # type: ignore[attr-defined]
last_index = len(app._conversation_blocks) - 1 # type: ignore[attr-defined]
actual = app._block_cursor_index # type: ignore[attr-defined]
assert actual == last_index, (
f"Expected block cursor at last index {last_index} but got {actual}"
)
+50 -135
View File
@@ -19,55 +19,20 @@ class MockCommandRouter:
return f"Mock response for {raw} in session {session_id}"
def _setup_mock_app(context: object) -> None:
"""Set up a mock app with session management if not already set up."""
if not hasattr(context, "app") or context.app is None: # type: ignore
context.app = type("MockApp", (), {})() # type: ignore
context.app._sessions = [ # type: ignore
SessionView(
session_id="default",
transcript=[],
name="Default",
created_at=datetime.utcnow().isoformat(),
)
]
context.app._active_session_index = 0 # type: ignore
def _create_sessions(context: object, count: int) -> None:
"""Create a mock app with the specified number of sessions."""
context.app = type("MockApp", (), {})() # type: ignore
context.app._sessions = [] # type: ignore
# Use predictable session IDs for testing
session_ids = ["default", "sess-2", "sess-3", "sess-4", "sess-5"]
session_names = ["Default", "Session 2", "Session 3", "Session 4", "Session 5"]
for i in range(count):
session_id = session_ids[i] if i < len(session_ids) else f"sess-{i + 1}"
name = session_names[i] if i < len(session_names) else f"Session {i + 1}"
session = SessionView(
session_id=session_id,
transcript=[],
name=name,
created_at=datetime.utcnow().isoformat(),
)
context.app._sessions.append(session) # type: ignore
context.app._active_session_index = 0 # type: ignore
@given("a TUI app is initialized with multi-session support")
def step_init_tui_app(context: object) -> None:
"""Initialize a TUI app with multi-session support."""
context.registry = PersonaRegistry() # type: ignore
context.persona_state = PersonaState(registry=context.registry) # type: ignore
context.router = MockCommandRouter() # type: ignore
context.app = None # type: ignore
context.close_failed = False # type: ignore
context.new_session = None # type: ignore
# Note: We can't instantiate _TextualCleverAgentsTuiApp directly without Textual
# So we'll test the session management logic separately
@when("the TUI app is created")
def step_create_tui_app(context: object) -> None:
"""Create a TUI app instance."""
# Create a mock app with session management
context.app = type("MockApp", (), {})() # type: ignore
context.app._sessions = [ # type: ignore
SessionView(
@@ -81,45 +46,30 @@ def step_create_tui_app(context: object) -> None:
@then("the app should have exactly {count:d} session")
def step_check_session_count_singular(context: object, count: int) -> None:
"""Check the number of sessions (singular form)."""
_setup_mock_app(context)
def step_check_session_count(context: object, count: int) -> None:
"""Check the number of sessions."""
assert len(context.app._sessions) == count # type: ignore
@then("the app should have exactly {count:d} sessions")
def step_check_session_count_plural(context: object, count: int) -> None:
"""Check the number of sessions (plural form)."""
_setup_mock_app(context)
assert len(context.app._sessions) == count # type: ignore
@then('the active session should have session_id "{session_id}"')
@then("the active session should have session_id {session_id}")
def step_check_active_session_id(context: object, session_id: str) -> None:
"""Check the active session ID."""
_setup_mock_app(context)
active = context.app._sessions[context.app._active_session_index] # type: ignore
assert active.session_id == session_id, (
f"Expected session_id '{session_id}' but got '{active.session_id}'"
)
assert active.session_id == session_id
@then('the active session should have name "{name}"')
@then("the active session should have name {name}")
def step_check_active_session_name(context: object, name: str) -> None:
"""Check the active session name."""
_setup_mock_app(context)
active = context.app._sessions[context.app._active_session_index] # type: ignore
assert active.name == name, (
f"Expected name '{name}' but got '{active.name}'"
)
assert active.name == name
@when('I create a new session with name "{name}"')
@when("I create a new session with name {name}")
def step_create_session(context: object, name: str) -> None:
"""Create a new session."""
import uuid
_setup_mock_app(context)
session_id = str(uuid.uuid4())[:8]
new_session = SessionView(
session_id=session_id,
@@ -134,44 +84,50 @@ def step_create_session(context: object, name: str) -> None:
@then("the new session should have an independent session_id")
def step_check_new_session_id(context: object) -> None:
"""Check that the new session has a unique ID."""
_setup_mock_app(context)
sessions = context.app._sessions # type: ignore
session_ids = [s.session_id for s in sessions]
assert len(session_ids) == len(set(session_ids)) # All unique
@given("the TUI app has {count:d} session")
def step_setup_sessions_singular(context: object, count: int) -> None:
"""Set up the TUI app with a specific number of sessions (singular)."""
_create_sessions(context, count)
def step_setup_sessions(context: object, count: int) -> None:
"""Set up the TUI app with a specific number of sessions."""
context.app = type("MockApp", (), {})() # type: ignore
context.app._sessions = [] # type: ignore
for i in range(count):
if i == 0:
session_id = "default"
name = "Default"
else:
import uuid
session_id = str(uuid.uuid4())[:8]
name = f"Session {i + 1}"
session = SessionView(
session_id=session_id,
transcript=[],
name=name,
created_at=datetime.utcnow().isoformat(),
)
context.app._sessions.append(session) # type: ignore
context.app._active_session_index = 0 # type: ignore
@given("the TUI app has {count:d} sessions")
def step_setup_sessions_plural(context: object, count: int) -> None:
"""Set up the TUI app with a specific number of sessions (plural)."""
_create_sessions(context, count)
@given('the first session has session_id "{session_id}"')
@given("the first session has session_id {session_id}")
def step_check_first_session_id(context: object, session_id: str) -> None:
"""Verify the first session has the expected ID."""
_setup_mock_app(context)
assert context.app._sessions[0].session_id == session_id # type: ignore
@given('the second session has session_id "{session_id}"')
@given("the second session has session_id {session_id}")
def step_check_second_session_id(context: object, session_id: str) -> None:
"""Verify the second session has the expected ID."""
_setup_mock_app(context)
# If the second session doesn't have the expected ID, update it
if len(context.app._sessions) > 1: # type: ignore
context.app._sessions[1].session_id = session_id # type: ignore
assert context.app._sessions[1].session_id == session_id # type: ignore
@when('I switch to session "{session_id}"')
@when("I switch to session {session_id}")
def step_switch_session(context: object, session_id: str) -> None:
"""Switch to a specific session."""
_setup_mock_app(context)
for idx, session in enumerate(context.app._sessions): # type: ignore
if session.session_id == session_id:
context.app._active_session_index = idx # type: ignore
@@ -179,18 +135,9 @@ def step_switch_session(context: object, session_id: str) -> None:
raise ValueError(f"Session {session_id} not found")
@when("I switch to the second session")
def step_switch_to_second_session(context: object) -> None:
"""Switch to the second session."""
_setup_mock_app(context)
assert len(context.app._sessions) >= 2, "Need at least 2 sessions" # type: ignore
context.app._active_session_index = 1 # type: ignore
@when('I close the session with session_id "{session_id}"')
@when("I close the session with session_id {session_id}")
def step_close_session(context: object, session_id: str) -> None:
"""Close a session."""
_setup_mock_app(context)
if len(context.app._sessions) <= 1: # type: ignore
context.close_failed = True # type: ignore
return
@@ -204,10 +151,9 @@ def step_close_session(context: object, session_id: str) -> None:
raise ValueError(f"Session {session_id} not found")
@when('I try to close the session with session_id "{session_id}"')
@when("I try to close the session with session_id {session_id}")
def step_try_close_session(context: object, session_id: str) -> None:
"""Try to close a session (may fail)."""
_setup_mock_app(context)
context.close_failed = False # type: ignore
if len(context.app._sessions) <= 1: # type: ignore
context.close_failed = True # type: ignore
@@ -226,57 +172,36 @@ def step_check_close_failed(context: object) -> None:
assert context.close_failed # type: ignore
@then("the app should still have exactly {count:d} session")
def step_check_session_count_still_singular(context: object, count: int) -> None:
"""Check the number of sessions (after failed close, singular)."""
_setup_mock_app(context)
assert len(context.app._sessions) == count # type: ignore
@then("the app should still have exactly {count:d} sessions")
def step_check_session_count_still_plural(context: object, count: int) -> None:
"""Check the number of sessions (after failed close, plural)."""
_setup_mock_app(context)
assert len(context.app._sessions) == count # type: ignore
@when('I rename the session to "{new_name}"')
@when("I rename the session to {new_name}")
def step_rename_session(context: object, new_name: str) -> None:
"""Rename the active session."""
_setup_mock_app(context)
active = context.app._sessions[context.app._active_session_index] # type: ignore
active.name = new_name
@given('the active session has name "{name}"')
@given("the active session has name {name}")
def step_check_active_session_has_name(context: object, name: str) -> None:
"""Verify the active session has a specific name."""
_setup_mock_app(context)
active = context.app._sessions[context.app._active_session_index] # type: ignore
assert active.name == name, (
f"Expected name '{name}' but got '{active.name}'"
)
assert active.name == name
@given("the first session is active")
def step_first_session_active(context: object) -> None:
"""Make the first session active."""
_setup_mock_app(context)
context.app._active_session_index = 0 # type: ignore
@when('I set persona "{persona_name}" for the first session')
@when("I set persona {persona_name} for the first session")
def step_set_persona_first(context: object, persona_name: str) -> None:
"""Set persona for the first session."""
_setup_mock_app(context)
session_id = context.app._sessions[0].session_id # type: ignore
context.persona_state.active_by_session[session_id] = persona_name # type: ignore
@when('I set persona "{persona_name}" for the second session')
@when("I set persona {persona_name} for the second session")
def step_set_persona_second(context: object, persona_name: str) -> None:
"""Set persona for the second session."""
_setup_mock_app(context)
session_id = context.app._sessions[1].session_id # type: ignore
context.persona_state.active_by_session[session_id] = persona_name # type: ignore
@@ -284,65 +209,56 @@ def step_set_persona_second(context: object, persona_name: str) -> None:
@when("I switch back to the first session")
def step_switch_back_to_first(context: object) -> None:
"""Switch back to the first session."""
_setup_mock_app(context)
context.app._active_session_index = 0 # type: ignore
@then('the first session should have active persona "{persona_name}"')
@then("the first session should have active persona {persona_name}")
def step_check_first_session_persona(context: object, persona_name: str) -> None:
"""Check the first session's active persona."""
_setup_mock_app(context)
session_id = context.app._sessions[0].session_id # type: ignore
assert context.persona_state.active_by_session.get(session_id) == persona_name # type: ignore
@then('the second session should have active persona "{persona_name}"')
@then("the second session should have active persona {persona_name}")
def step_check_second_session_persona(context: object, persona_name: str) -> None:
"""Check the second session's active persona."""
_setup_mock_app(context)
session_id = context.app._sessions[1].session_id # type: ignore
assert context.persona_state.active_by_session.get(session_id) == persona_name # type: ignore
@when('I add message "{message}" to the first session')
@when("I add message {message} to the first session")
def step_add_message_first(context: object, message: str) -> None:
"""Add a message to the first session."""
_setup_mock_app(context)
context.app._sessions[0].transcript.append(message) # type: ignore
@when('I add message "{message}" to the second session')
@when("I add message {message} to the second session")
def step_add_message_second(context: object, message: str) -> None:
"""Add a message to the second session."""
_setup_mock_app(context)
context.app._sessions[1].transcript.append(message) # type: ignore
@then('the first session transcript should contain "{message}"')
@then("the first session transcript should contain {message}")
def step_check_first_transcript_contains(context: object, message: str) -> None:
"""Check that the first session transcript contains a message."""
_setup_mock_app(context)
assert message in context.app._sessions[0].transcript # type: ignore
@then('the first session transcript should not contain "{message}"')
@then("the first session transcript should not contain {message}")
def step_check_first_transcript_not_contains(context: object, message: str) -> None:
"""Check that the first session transcript does not contain a message."""
_setup_mock_app(context)
assert message not in context.app._sessions[0].transcript # type: ignore
@then('the second session transcript should contain "{message}"')
@then("the second session transcript should contain {message}")
def step_check_second_transcript_contains(context: object, message: str) -> None:
"""Check that the second session transcript contains a message."""
_setup_mock_app(context)
assert message in context.app._sessions[1].transcript # type: ignore
@then('the second session transcript should not contain "{message}"')
@then("the second session transcript should not contain {message}")
def step_check_second_transcript_not_contains(context: object, message: str) -> None:
"""Check that the second session transcript does not contain a message."""
_setup_mock_app(context)
assert message not in context.app._sessions[1].transcript # type: ignore
@@ -351,7 +267,6 @@ def step_create_new_session(context: object) -> None:
"""Create a new session."""
import uuid
_setup_mock_app(context)
session_id = str(uuid.uuid4())[:8]
new_session = SessionView(
session_id=session_id,
+2 -4
View File
@@ -31,9 +31,7 @@ def step_cycle_persona(context: Context, session_id: str) -> None:
context.tui_state.cycle_persona(session_id)
@then('the registry last persona should be set to "{persona_name}"')
@then("the registry last persona should be set to {persona_name}")
def step_registry_last_persona(context: Context, persona_name: str) -> None:
last = context.tui_registry.get_last_persona()
assert last == persona_name, (
f"Expected last persona '{persona_name}' but got '{last}'"
)
assert last == persona_name
@@ -1,68 +0,0 @@
@tdd_issue @tdd_issue_10491 @mock_only
Feature: TDD Issue #10491 — TUI BINDINGS missing alt+up and alt+down block cursor navigation keys
As a developer
I want to verify that the TUI app BINDINGS include alt+up and alt+down
and that action_cursor_up() and action_cursor_down() methods exist and work correctly
So that the bug is captured and will be caught by a regression test
# These scenarios verify that the TUI app correctly handles block cursor
# navigation via alt+up and alt+down key bindings. The @tdd_expected_fail
# tag inverts the result so CI passes while the bug is still present.
# When bug #10491 is fixed, the @tdd_expected_fail tag must be removed.
Background:
Given the TUI app module is imported with mocked Textual for cursor tests
And a mock command router and persona state for cursor tests
And the Textual TUI app is instantiated for cursor tests
@tdd_expected_fail
Scenario: alt+up binding is present in BINDINGS
Then the BINDINGS list should contain an entry for "alt+up"
@tdd_expected_fail
Scenario: alt+down binding is present in BINDINGS
Then the BINDINGS list should contain an entry for "alt+down"
@tdd_expected_fail
Scenario: alt+up binding maps to action_cursor_up
Then the BINDINGS entry for "alt+up" should map to action "cursor_up"
@tdd_expected_fail
Scenario: alt+down binding maps to action_cursor_down
Then the BINDINGS entry for "alt+down" should map to action "cursor_down"
@tdd_expected_fail
Scenario: action_cursor_up method exists on the TUI app
Then the TUI app should have an action_cursor_up method
@tdd_expected_fail
Scenario: action_cursor_down method exists on the TUI app
Then the TUI app should have an action_cursor_down method
@tdd_expected_fail
Scenario: pressing alt+up moves block cursor to previous conversation block
Given the TUI app has conversation blocks loaded
And the block cursor is positioned at index 2
When action_cursor_up is called on the TUI app
Then the block cursor index should be 1
@tdd_expected_fail
Scenario: pressing alt+down moves block cursor to next conversation block
Given the TUI app has conversation blocks loaded
And the block cursor is positioned at index 1
When action_cursor_down is called on the TUI app
Then the block cursor index should be 2
@tdd_expected_fail
Scenario: pressing alt+up at top of stream does not raise an error
Given the TUI app has conversation blocks loaded
And the block cursor is positioned at index 0
When action_cursor_up is called on the TUI app
Then the block cursor index should be 0
@tdd_expected_fail
Scenario: pressing alt+down at bottom of stream does not raise an error
Given the TUI app has conversation blocks loaded
And the block cursor is positioned at the last block
When action_cursor_down is called on the TUI app
Then the block cursor index should be at the last block
+2 -2
View File
@@ -37,7 +37,7 @@ Feature: TUI App Coverage
Scenario: The Textual TUI app can be instantiated with mocked Textual
Given a mock command router and persona state
When I instantiate the Textual TUI app
Then the app should have a default session with session_id "default"
Then the app should have a _session with session_id "default"
And the app should store the command router
And the app should store the persona state
@@ -47,7 +47,7 @@ Feature: TUI App Coverage
Given a mock command router and persona state
When I instantiate the Textual TUI app
Then the app class should have CSS_PATH set to "cleveragents.tcss"
And the app class should have 5 key bindings
And the app class should have 3 key bindings
# --- compose method (lines 102-112) ---
+86 -131
View File
@@ -1,140 +1,95 @@
#!/usr/bin/env python3
"""ADR compliance checker for CleverAgents.
"""Create pipeline improvement issues in Forgejo."""
Verifies that code follows the architectural decisions recorded in ADRs.
ADR-002: Asyncio Concurrency Model
- Async functions should use 'async def', not threading
- No direct thread usage in application layer
ADR-003: Dependency Injection Framework
- Services should receive dependencies via __init__
- No direct instantiation of infrastructure in application layer
ADR-007: Repository Pattern for Persistence
- Database access only through repository classes
- No direct SQLAlchemy session usage in services
Usage:
python scripts/check-adr-compliance.py
"""
import ast
import json
import sys
from pathlib import Path
import urllib.error
import urllib.request
BASE_URL = "https://git.cleverthis.com/api/v1"
REPO = "cleveragents/cleveragents-core"
PAT = "92224acff675c50c5958d1eaca9a688abd405e06"
H = {"Authorization": f"token {PAT}", "Content-Type": "application/json"}
def check_adr002_async_model(src_dir: Path) -> list[str]:
"""Check ADR-002: Asyncio Concurrency Model.
Verifies no direct threading usage in application code.
"""
violations: list[str] = []
app_dir = src_dir / "application"
if not app_dir.exists():
return violations
for py_file in app_dir.rglob("*.py"):
content = py_file.read_text()
if "import threading" in content or "from threading import" in content:
violations.append(
f"ADR-002 violation: {py_file.relative_to(src_dir)} "
"imports threading directly. Use asyncio instead."
)
return violations
def post(url, payload):
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers=H, method="POST")
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
print(f"HTTP {e.code}: {e.read().decode()}", file=sys.stderr)
raise
def check_adr003_dependency_injection(src_dir: Path) -> list[str]:
"""Check ADR-003: Dependency Injection Framework.
ISSUES = [
{
"title": "`benchmark-regression` job in `master.yml` has unreachable trigger condition",
"body": "## Issue Description\n\nThe `benchmark-regression` job in `.forgejo/workflows/master.yml` contains an `if` condition that can **never be true**, making the job permanently dead code. Benchmark regression testing on pull requests is silently broken.\n\n## Root Cause\n\n`master.yml` is triggered only on `push` events to `master` and `develop` branches. But the `benchmark-regression` job has:\n\n```yaml\nbenchmark-regression:\n if: forgejo.event_name == 'pull_request'\n```\n\nSince `master.yml` never triggers on `pull_request` events, `forgejo.event_name` will always be `push` — the condition is permanently `false` and the job **never runs**.\n\n## Impact\n\n- Benchmark regression testing on PRs is completely broken and silently skipped\n- Performance regressions can merge undetected\n- The `benchmark-publish` job (push-triggered) works correctly, but the PR regression check does not\n\n## Expected Behavior\n\nFix options:\n1. **Option A (Recommended)**: Move `benchmark-regression` to `ci.yml` (which triggers on `pull_request`)\n2. **Option B**: Add `pull_request` to `master.yml`'s `on:` trigger\n\n## Subtasks\n\n- [ ] Move or restructure `benchmark-regression` so it triggers on `pull_request` events\n- [ ] Verify `benchmark-publish` continues to run on push to `master`/`develop`\n- [ ] Confirm CI passes with corrected workflow\n\n## Definition of Done\n\nThis issue is complete when:\n- All subtasks above are completed and checked off.\n- A Git commit is created where the **first line** of the commit message matches the Commit Message in Metadata exactly.\n- The commit is pushed to the remote on the branch matching the **Branch** in Metadata exactly.\n- The commit is submitted as a **pull request** to `master`, reviewed, and **merged**.\n\n## Metadata\n\n- **Commit Message**: `fix(ci): restore benchmark-regression trigger to pull_request events in master.yml`\n- **Branch Name**: `fix/ci-benchmark-regression-trigger`\n\n## Duplicate Check\n\nSearched open and closed issues for: \"benchmark regression master.yml\", \"benchmark workflow trigger\", \"pull_request event_name\". No existing issues found.\n\n---\n**Automated by CleverAgents Bot**\nSupervisor: Implementation Pool | Agent: implementation-worker",
"milestone": 105,
"assignee": "HAL9000",
"labels": [849, 859, 883, 874, 846],
},
{
"title": "`release.yml` publishes artifacts without requiring quality gates to pass first",
"body": "## Issue Description\n\nThe `.forgejo/workflows/release.yml` workflow builds and publishes release artifacts (Python wheel, Docker images, Forgejo release) without requiring **any quality gates** to pass first. A release could be published from broken, untested, or insecure code.\n\n## Current Behavior\n\n`release.yml` triggers on `push` of `v*` tags and runs `build-wheel`, `build-docker`, and `create-release` — none of which require lint, typecheck, security scan, unit tests, integration tests, or coverage to pass.\n\n## Risk\n\n- A release tag pushed directly (bypassing CI) publishes untested artifacts\n- A release could contain failing tests, type errors, or security vulnerabilities\n- The 97% coverage requirement is not enforced at release time\n\n## Expected Behavior\n\nAdd a `quality-gate` job that runs `nox -s lint typecheck security_scan unit_tests coverage_report` as a prerequisite for `build-wheel`.\n\n## Subtasks\n\n- [ ] Add `quality-gate` job to `release.yml` that runs lint, typecheck, security_scan, unit_tests, coverage_report\n- [ ] Update `build-wheel` to `needs: [quality-gate]`\n- [ ] Verify release workflow still completes successfully when all gates pass\n- [ ] Document the quality gate requirement in release workflow comments\n\n## Definition of Done\n\nThis issue is complete when:\n- All subtasks above are completed and checked off.\n- A Git commit is created where the **first line** of the commit message matches the Commit Message in Metadata exactly.\n- The commit is pushed to the remote on the branch matching the **Branch** in Metadata exactly.\n- The commit is submitted as a **pull request** to `master`, reviewed, and **merged**.\n\n## Metadata\n\n- **Commit Message**: `fix(ci): add quality gate prerequisite to release.yml before artifact publication`\n- **Branch Name**: `fix/ci-release-quality-gate`\n\n## Duplicate Check\n\nSearched open and closed issues for: \"release quality gate\", \"release workflow test\", \"release.yml CI\". No existing issues found.\n\n---\n**Automated by CleverAgents Bot**\nSupervisor: Implementation Pool | Agent: implementation-worker",
"milestone": 105,
"assignee": "HAL9000",
"labels": [849, 859, 883, 875, 846],
},
{
"title": "Standardize `actions/upload-artifact` version across all CI workflows (v3 vs v4 inconsistency)",
"body": "## Issue Description\n\nThe CI workflows use inconsistent versions of `actions/upload-artifact`:\n\n- `ci.yml` uses `actions/upload-artifact@v3` (all jobs)\n- `nightly-quality.yml` uses `actions/upload-artifact@v4` (quality reports upload)\n\nThis version mismatch creates maintenance risk and potential behavioral differences in artifact handling between workflows.\n\n## Impact\n\n- `actions/upload-artifact@v3` and `v4` have different behaviors (v4 changed artifact naming, retention, and download compatibility)\n- Artifacts uploaded with v3 cannot be downloaded with `actions/download-artifact@v4` and vice versa\n- The `release.yml` uses `actions/download-artifact@v3` to download the wheel artifact\n- Inconsistency makes it harder to reason about artifact compatibility across workflows\n\n## Expected Behavior\n\nStandardize on `@v4` (the current stable version) across all workflows.\n\n## Subtasks\n\n- [ ] Audit all workflow files for `upload-artifact` and `download-artifact` action versions\n- [ ] Standardize all to `@v4` (or document a deliberate reason to stay on `@v3`)\n- [ ] Verify artifact upload/download compatibility after the change\n- [ ] Update `release.yml`'s `download-artifact` step to match\n\n## Definition of Done\n\nThis issue is complete when:\n- All subtasks above are completed and checked off.\n- A Git commit is created where the **first line** of the commit message matches the Commit Message in Metadata exactly.\n- The commit is pushed to the remote on the branch matching the **Branch** in Metadata exactly.\n- The commit is submitted as a **pull request** to `master`, reviewed, and **merged**.\n\n## Metadata\n\n- **Commit Message**: `fix(ci): standardize actions/upload-artifact to v4 across all workflow files`\n- **Branch Name**: `fix/ci-artifact-action-version-consistency`\n\n## Duplicate Check\n\nSearched open and closed issues for: \"artifact upload version\", \"upload-artifact v3 v4\", \"actions version inconsistency\". No existing issues found.\n\n---\n**Automated by CleverAgents Bot**\nSupervisor: Implementation Pool | Agent: implementation-worker",
"milestone": 105,
"assignee": "HAL9000",
"labels": [857, 860, 884, 874, 846],
},
{
"title": "CI `coverage` job should depend on `unit_tests` to prevent misleading parallel results",
"body": "## Issue Description\n\nThe `coverage` job in `.forgejo/workflows/ci.yml` only depends on `[lint, typecheck, security, quality]` but does **not** depend on `unit_tests`. This means the coverage job runs in parallel with the test jobs, potentially wasting CI resources and producing misleading intermediate results.\n\n## Current State\n\n```yaml\ncoverage:\n needs: [lint, typecheck, security, quality]\n # Missing: unit_tests\n```\n\nThe `coverage` job runs `nox -s coverage_report`, which internally re-runs the full Behave test suite under slipcover. This means:\n- Coverage runs its own copy of the tests regardless of whether `unit_tests` passed\n- If `unit_tests` fails, coverage may still pass (running the same tests again)\n- The two jobs run the same tests twice in parallel, wasting CI resources\n\n## Expected Behavior\n\n```yaml\ncoverage:\n needs: [lint, typecheck, security, quality, unit_tests]\n```\n\n## Subtasks\n\n- [ ] Update `coverage` job's `needs` in `ci.yml` to include `unit_tests`\n- [ ] Verify CI pipeline still runs correctly with the updated dependency\n- [ ] Confirm no circular dependencies are introduced\n\n## Definition of Done\n\nThis issue is complete when:\n- All subtasks above are completed and checked off.\n- A Git commit is created where the **first line** of the commit message matches the Commit Message in Metadata exactly.\n- The commit is pushed to the remote on the branch matching the **Branch** in Metadata exactly.\n- The commit is submitted as a **pull request** to `master`, reviewed, and **merged**.\n\n## Metadata\n\n- **Commit Message**: `fix(ci): add unit_tests to coverage job needs to prevent misleading parallel results`\n- **Branch Name**: `fix/ci-coverage-job-ordering`\n\n## Duplicate Check\n\nSearched open and closed issues for: \"coverage needs unit_tests\", \"coverage job ordering\", \"coverage parallel tests\". No existing issues found.\n\n---\n**Automated by CleverAgents Bot**\nSupervisor: Implementation Pool | Agent: implementation-worker",
"milestone": 105,
"assignee": "HAL9000",
"labels": [857, 860, 884, 873, 846],
},
{
"title": "Add `adr_compliance` nox session to CI pipeline to enforce Architecture Decision Record compliance",
"body": "## Issue Description\n\nThe `noxfile.py` defines an `adr_compliance` session that checks code compliance with Architecture Decision Records (ADRs), but this session is **not included in the CI pipeline** (`ci.yml`). ADR compliance violations can therefore merge undetected.\n\nRunning `nox -s adr_compliance` locally reveals **56 existing violations** (threading imports in async services, direct SQLAlchemy usage in service layer), confirming the check is meaningful and needed.\n\n## Current State\n\n`noxfile.py` defines `adr_compliance` which runs `scripts/check-adr-compliance.py`, but `ci.yml` does NOT include this session in any job. The `nox.options.sessions` default list also does not include `adr_compliance`.\n\n## Impact\n\n- ADR compliance violations can be merged without detection\n- The `scripts/check-adr-compliance.py` script exists but is never run automatically\n- 56 existing violations are currently undetected in CI\n- Developers may unknowingly violate architectural decisions documented in ADRs\n\n## Expected Behavior\n\nAdd `nox -s adr_compliance` to the `quality` job in `ci.yml` alongside the existing `complexity` check.\n\n**Note**: The existing 56 violations must be resolved (or the ADR checker updated to reflect current architectural decisions) before this check can be enforced as a hard gate.\n\n## Subtasks\n\n- [ ] Resolve or document the 56 existing ADR violations found by `nox -s adr_compliance`\n- [ ] Add `nox -s adr_compliance` to the `quality` job in `ci.yml`\n- [ ] Add `adr_compliance` to `nox.options.sessions` default list in `noxfile.py`\n- [ ] Confirm CI passes with the added check\n\n## Definition of Done\n\nThis issue is complete when:\n- All subtasks above are completed and checked off.\n- A Git commit is created where the **first line** of the commit message matches the Commit Message in Metadata exactly.\n- The commit is pushed to the remote on the branch matching the **Branch** in Metadata exactly.\n- The commit is submitted as a **pull request** to `master`, reviewed, and **merged**.\n\n## Metadata\n\n- **Commit Message**: `feat(ci): add adr_compliance nox session to CI quality job`\n- **Branch Name**: `feat/ci-adr-compliance-check`\n\n## Duplicate Check\n\nSearched open and closed issues for: \"adr compliance CI\", \"adr_compliance nox\", \"architecture decision record CI\". No existing issues found.\n\n---\n**Automated by CleverAgents Bot**\nSupervisor: Implementation Pool | Agent: implementation-worker",
"milestone": 105,
"assignee": "HAL9000",
"labels": [854, 860, 884, 874, 846],
},
]
Verifies services use constructor injection.
"""
violations: list[str] = []
services_dir = src_dir / "application" / "services"
if not services_dir.exists():
return violations
created = []
for issue in ISSUES:
print(f"Creating: {issue['title'][:60]}...")
try:
r = post(
f"{BASE_URL}/repos/{REPO}/issues",
{
"title": issue["title"],
"body": issue["body"],
"milestone": issue["milestone"],
"assignee": issue["assignee"],
},
)
num, url = r["number"], r["html_url"]
print(f" Created #{num}: {url}")
if issue.get("labels"):
try:
post(
f"{BASE_URL}/repos/{REPO}/issues/{num}/labels",
{"labels": issue["labels"]},
)
print(" Labels applied")
except Exception as e:
print(f" Labels FAILED: {e}")
created.append((num, url, issue["title"]))
except Exception as e:
print(f" FAILED: {e}")
for py_file in services_dir.rglob("*.py"):
if py_file.name.startswith("__"):
continue
try:
tree = ast.parse(py_file.read_text())
except SyntaxError:
continue
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
# Check if class has __init__ with parameters (DI)
has_init = False
for item in node.body:
if isinstance(item, ast.FunctionDef) and item.name == "__init__":
has_init = True
# Check for at least one parameter beyond self
if len(item.args.args) < 2:
violations.append(
f"ADR-003 note: {py_file.relative_to(src_dir)}:"
f"{node.lineno} class {node.name} has __init__ "
"with no injected dependencies"
)
if not has_init and node.body:
# Services without __init__ may be okay if they're utility classes
pass
return violations
def check_adr007_repository_pattern(src_dir: Path) -> list[str]:
"""Check ADR-007: Repository Pattern for Persistence.
Verifies no direct SQLAlchemy session usage in service layer.
"""
violations: list[str] = []
services_dir = src_dir / "application" / "services"
if not services_dir.exists():
return violations
for py_file in services_dir.rglob("*.py"):
content = py_file.read_text()
# Check for direct session imports
if "from sqlalchemy" in content:
violations.append(
f"ADR-007 violation: {py_file.relative_to(src_dir)} "
"imports SQLAlchemy directly. Use repository pattern instead."
)
if "session.query" in content or "session.execute" in content:
violations.append(
f"ADR-007 violation: {py_file.relative_to(src_dir)} "
"uses session directly. Access data through repositories."
)
return violations
def main() -> int:
"""Run all ADR compliance checks."""
src_dir = Path("src/cleveragents")
if not src_dir.exists():
print("Error: src/cleveragents directory not found")
return 1
all_violations: list[str] = []
print("Checking ADR-002: Asyncio Concurrency Model...")
all_violations.extend(check_adr002_async_model(src_dir))
print("Checking ADR-003: Dependency Injection Framework...")
all_violations.extend(check_adr003_dependency_injection(src_dir))
print("Checking ADR-007: Repository Pattern for Persistence...")
all_violations.extend(check_adr007_repository_pattern(src_dir))
if all_violations:
print(f"\nFound {len(all_violations)} ADR compliance issue(s):")
for violation in all_violations:
print(f" - {violation}")
return 1
print("\nAll ADR compliance checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
print("\n=== SUMMARY ===")
for num, url, title in created:
print(f"#{num}: {title[:60]}")
print(f" {url}")