Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 223d808789 | |||
| 0a63149101 | |||
| 30a058282c |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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,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
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user