Enhance Logging Functionality in RequestLogger

- Added a `noop` check in multiple logging methods (`LLMComplete`, `ToolStart`, `ToolComplete`, `HookStart`, `HookComplete`, and `HistoryLoad`) to prevent logging when the logger is in no-operation mode.
- Improved command handling in `root.go` by removing minimum argument requirements for commands and providing help output when no arguments are given.
- Introduced an `agent` command for better organization of agent-related functionalities in the CLI.
- Implemented automatic detection of the application root directory in `run.go` to streamline the application startup process.
- Cleaned up debug print statements in `config.go` to reduce clutter in the output.
This commit is contained in:
Max 2025-12-17 12:33:34 +08:00
parent c2a35bf667
commit d21f9c3769
17 changed files with 4444 additions and 14 deletions

View file

@ -378,6 +378,10 @@ func (l *RequestLogger) LLMStart(connector, model string, messageCount int) {
// LLMComplete logs the completion of an LLM call
func (l *RequestLogger) LLMComplete(tokens int, hasToolCalls bool) {
if l.noop {
return
}
elapsed := time.Since(l.startTime).Round(time.Millisecond)
status := "streaming"
if hasToolCalls {
@ -397,6 +401,10 @@ func (l *RequestLogger) LLMComplete(tokens int, hasToolCalls bool) {
// ToolStart logs the start of tool execution
func (l *RequestLogger) ToolStart(toolName string) {
if l.noop {
return
}
if config.IsDevelopment() {
fmt.Printf("%s 🔧 Tool: %s%s\n", colorYellow, toolName, colorReset)
} else {
@ -406,6 +414,10 @@ func (l *RequestLogger) ToolStart(toolName string) {
// ToolComplete logs the completion of tool execution
func (l *RequestLogger) ToolComplete(toolName string, success bool) {
if l.noop {
return
}
if config.IsDevelopment() {
if success {
fmt.Printf("%s ✓ %s completed%s\n", colorGreen, toolName, colorReset)
@ -423,6 +435,10 @@ func (l *RequestLogger) ToolComplete(toolName string, success bool) {
// HookStart logs the start of a hook execution
func (l *RequestLogger) HookStart(hookName string) {
if l.noop {
return
}
elapsed := time.Since(l.startTime).Round(time.Millisecond)
if config.IsDevelopment() {
@ -434,6 +450,10 @@ func (l *RequestLogger) HookStart(hookName string) {
// HookComplete logs the completion of a hook
func (l *RequestLogger) HookComplete(hookName string) {
if l.noop {
return
}
if config.IsDevelopment() {
fmt.Printf("%s ✓ %s done%s\n", colorGreen, hookName, colorReset)
} else {
@ -456,6 +476,10 @@ func (l *RequestLogger) Cleanup(resource string) {
// HistoryLoad logs history loading
func (l *RequestLogger) HistoryLoad(count, maxSize int) {
if l.noop {
return
}
if config.IsDevelopment() {
fmt.Printf("%s Loaded %d/%d history messages%s\n", colorGray, count, maxSize, colorReset)
} else {
@ -465,6 +489,10 @@ func (l *RequestLogger) HistoryLoad(count, maxSize int) {
// HistoryOverlap logs overlap detection
func (l *RequestLogger) HistoryOverlap(overlapCount int) {
if l.noop {
return
}
if overlapCount > 0 {
if config.IsDevelopment() {
fmt.Printf("%s Removed %d overlapping messages%s\n", colorYellow, overlapCount, colorReset)

657
agent/test/DESIGN.md Normal file
View file

@ -0,0 +1,657 @@
# Agent Test Package Design
## Overview
Agent Test Package provides a framework for testing AI agents with structured test cases.
It supports batch testing, report generation, stability analysis, and CI integration.
### Quick Start
```bash
# Quick test with a single message (auto-detect agent from current directory)
cd assistants/keyword
yao agent test -i "hello world"
# Or specify agent explicitly
yao agent test -i "hello world" -n keyword.agent
# Run tests from JSONL file (auto-detect agent from path)
yao agent test -i assistants/keyword/tests/inputs.jsonl
# Run with stability analysis (5 runs per test case)
yao agent test -i assistants/keyword/tests/inputs.jsonl --runs 5
# Generate HTML report
yao agent test -i assistants/keyword/tests/inputs.jsonl -r report.html -o report.html
```
## Usage
```bash
# Basic usage - auto-detect agent, output to same directory as input
# Output: tests/output-20241217100000.jsonl
yao agent test -i tests/inputs.jsonl
# Override connector
yao agent test -i tests/inputs.jsonl -c openai.gpt4
# Specify agent explicitly
yao agent test -i tests/inputs.jsonl -n my.agent
# Specify test environment (user and team)
yao agent test -i tests/inputs.jsonl -u test-user -t test-team
# Run multiple times for stability analysis
yao agent test -i tests/inputs.jsonl --runs 5
# Custom timeout per test case (default: 5m)
yao agent test -i tests/inputs.jsonl --timeout 10m
# Run tests in parallel (4 concurrent test cases)
yao agent test -i tests/inputs.jsonl --parallel 4
# Combine parallel and timeout for faster execution
yao agent test -i tests/inputs.jsonl --parallel 8 --timeout 2m
# Custom output file path
yao agent test -i tests/inputs.jsonl -o /path/to/results.jsonl
# Use custom reporter agent for personalized report (HTML)
yao agent test -i tests/inputs.jsonl -r report.html -o report.html
# Use custom reporter agent for personalized report (Markdown)
yao agent test -i tests/inputs.jsonl -r report.markdown -o report.md
# Full example with all options
yao agent test -i tests/inputs.jsonl \
-n keyword.agent \
-c deepseek.v3 \
-u test-user \
-t test-team \
--runs 3 \
--timeout 10m \
--parallel 4 \
-r report.html \
-o report.html
```
### Input Modes
The `-i` flag supports two input modes:
**1. JSONL File Mode** - Load test cases from a file:
```bash
yao agent test -i tests/inputs.jsonl
```
**2. Direct Message Mode** - Test with a single message:
```bash
# Auto-detect agent from current working directory
cd assistants/keyword
yao agent test -i "Extract keywords from this text"
# Or specify agent explicitly
yao agent test -i "Extract keywords from this text" -n keyword.agent
yao agent test -i "你好世界" -n keyword.agent -c deepseek.v3
```
When using direct message mode:
- Agent is resolved from current working directory (looks for `package.yao` upward)
- If not found, use `-n` flag to specify agent explicitly
- Output is printed to stdout (or saved to `-o` if specified)
- Useful for quick testing and debugging
### Default Output Path
When `-o` is not specified and using JSONL file mode, the output file is automatically generated in the same directory as the input file:
```
{input_directory}/output-{timestamp}.jsonl
```
Example:
- Input: `/app/assistants/keyword/tests/inputs.jsonl`
- Output: `/app/assistants/keyword/tests/output-20241217100000.jsonl`
The timestamp format is `YYYYMMDDHHMMSS` (e.g., `20241217100000` for 2024-12-17 10:00:00).
When using direct message mode without `-o`, output is printed to stdout.
## Command Line Options
| Flag | Long Flag | Description | Default | Example |
| ---- | ------------- | ------------------------------------- | -------------------------- | --------------------------------------- |
| `-i` | `--input` | Input: JSONL file path or message | - | `-i tests/inputs.jsonl` or `-i "hello"` |
| `-o` | `--output` | Path to output file (format by ext) | `output-{timestamp}.jsonl` | `-o report.html` |
| `-n` | `--name` | Explicit agent ID | auto-detect | `-n keyword.agent` |
| `-c` | `--connector` | Override connector | agent default | `-c openai.gpt4` |
| `-u` | `--user` | Test user ID (global override) | "test-user" | `-u admin` |
| `-t` | `--team` | Test team ID (global override) | "test-team" | `-t ops-team` |
| `-r` | `--reporter` | Custom reporter agent ID | - (use built-in) | `-r report.beautiful` |
| | `--runs` | Number of runs for stability analysis | 1 | `--runs 5` |
| | `--timeout` | Default timeout per test case | 5m | `--timeout 10m` |
| | `--parallel` | Number of parallel test cases | 1 | `--parallel 4` |
| `-v` | `--verbose` | Verbose output | false | `-v` |
| | `--fail-fast` | Stop on first failure | false | `--fail-fast` |
**Notes**:
- Without `-o` flag, output is saved to `{input_dir}/output-{timestamp}.jsonl`
- Output format is determined by `-o` file extension: `.jsonl`, `.json`, `.md`, `.html`
- Use `-r` to specify a custom reporter agent for personalized report generation
## Agent Resolution
The agent is resolved in the following order:
1. **Explicit specification** (`-n` flag): Use the specified agent ID
2. **Path-based detection**: Traverse up from `tests/inputs.jsonl` to find `package.yao`
### Path-based Detection Example
```
/app/assistants/workers/system/keyword/
├── package.yao <- Agent definition
├── prompts.yml
├── src/
│ └── index.ts
└── tests/
└── inputs.jsonl <- Test input file
```
Given input path `/app/assistants/workers/system/keyword/tests/inputs.jsonl`:
1. Check `/app/assistants/workers/system/keyword/tests/package.yao` - not found
2. Check `/app/assistants/workers/system/keyword/package.yao` - **found!**
3. Load agent from `/app/assistants/workers/system/keyword/`
## Test Environment
Agent calls require a `Context` with user and tenant information. The test framework creates a test context with configurable environment:
```go
// TestEnvironment configures the test execution context
type TestEnvironment struct {
UserID string // User ID for authorized info (-u flag)
TeamID string // Team ID for authorized info (-t flag)
Locale string // Locale (default: "en-us")
ClientType string // Client type (default: "test")
ClientIP string // Client IP (default: "127.0.0.1")
Referer string // Request referer (default: "test")
Accept string // Accept format (default: "standard")
}
```
Example context creation (similar to `agent_next_test.go`):
```go
func newTestContext(env *TestEnvironment, chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: env.UserID,
UserID: env.UserID,
TeamID: env.TeamID,
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = env.Locale
ctx.Client = context.Client{
Type: env.ClientType,
IP: env.ClientIP,
}
ctx.Referer = env.Referer
ctx.Accept = env.Accept
return ctx
}
```
## Stability Analysis (Multiple Runs)
When `--runs N` is specified (N > 1), the framework runs each test case N times and collects stability metrics:
### Stability Metrics
| Metric | Description |
| ------------------ | ------------------------------------------ |
| `pass_rate` | Percentage of runs that passed (0-100%) |
| `consistency` | How consistent the outputs are across runs |
| `avg_duration_ms` | Average execution time |
| `min_duration_ms` | Minimum execution time |
| `max_duration_ms` | Maximum execution time |
| `std_deviation_ms` | Standard deviation of execution time |
### Stability Report Structure
```json
{
"summary": {
"total_cases": 42,
"total_runs": 126,
"runs_per_case": 3,
"overall_pass_rate": 95.2,
"stable_cases": 38,
"unstable_cases": 4,
"duration_ms": 45678
},
"results": [
{
"id": "T001",
"runs": 3,
"passed": 3,
"failed": 0,
"pass_rate": 100.0,
"consistency": 1.0,
"stable": true,
"avg_duration_ms": 234,
"min_duration_ms": 210,
"max_duration_ms": 256,
"std_deviation_ms": 18.5,
"run_details": [
{"run": 1, "status": "passed", "duration_ms": 234, "output": {...}},
{"run": 2, "status": "passed", "duration_ms": 210, "output": {...}},
{"run": 3, "status": "passed", "duration_ms": 256, "output": {...}}
]
},
{
"id": "T002",
"runs": 3,
"passed": 2,
"failed": 1,
"pass_rate": 66.7,
"consistency": 0.67,
"stable": false,
"run_details": [...]
}
]
}
```
### Stability Classification
| Pass Rate | Classification |
| --------- | --------------- |
| 100% | Stable |
| 80-99% | Mostly Stable |
| 50-79% | Unstable |
| < 50% | Highly Unstable |
## Custom Reporter Agent
By default, the framework outputs JSONL format. You can specify a reporter agent (`-r` flag) for personalized report generation:
### Reporter Agent Interface
The reporter agent receives the test results and generates a custom report:
```json
// Input to reporter agent
{
"report": {
"summary": {...},
"results": [...],
"metadata": {...}
},
"format": "html", // or "markdown", "text"
"options": {
"verbose": true,
"include_outputs": true
}
}
```
### Built-in Reporter Agents
| Agent ID | Description |
| ----------------- | -------------------------------------- |
| `report.json` | JSON format (default, no agent needed) |
| `report.markdown` | Markdown format with tables |
| `report.html` | Interactive HTML report |
| `report.summary` | Brief text summary |
### Custom Reporter Example
Create a custom reporter agent at `assistants/reporters/my-reporter/`:
```yaml
# prompts.yml
- role: system
content: |
You are a test report generator. Generate a beautiful report from test results.
Output format: HTML with embedded CSS
Requirements:
- Show summary statistics prominently
- Use color coding (green=pass, red=fail)
- Include charts for stability metrics
- Make it printable
```
## Input Format (JSONL)
Each line in the input file is a JSON object with the following structure:
```jsonl
{"id": "T001", "input": "Simple text input"}
{"id": "T002", "input": {"role": "user", "content": "Message with role"}}
{"id": "T003", "input": {"role": "user", "content": [{"type": "text", "text": "ContentPart array"}]}}
{"id": "T004", "input": [{"role": "user", "content": "First message"}, {"role": "assistant", "content": "Response"}, {"role": "user", "content": "Follow-up"}]}
{"id": "T005", "input": "Text input", "expected": {"keywords": ["keyword1", "keyword2"]}}
{"id": "T006", "input": "Test with specific user", "user": "admin", "team": "ops-team"}
```
### Input Types
| Type | Description | Example |
| ----------- | ------------------------ | ----------------------------------------------------- |
| `string` | Simple text input | `"Hello world"` |
| `Message` | Single message with role | `{"role": "user", "content": "..."}` |
| `[]Message` | Conversation history | `[{"role": "user", ...}, {"role": "assistant", ...}]` |
### Fields
| Field | Type | Required | Description |
| ---------- | ------------------------------ | -------- | ---------------------------------------------------- |
| `id` | string | Yes | Unique test case identifier (e.g., "T001") |
| `input` | string \| Message \| []Message | Yes | Test input |
| `expected` | any | No | Expected output for validation |
| `user` | string | No | User ID for this test case (overridden by `-u` flag) |
| `team` | string | No | Team ID for this test case (overridden by `-t` flag) |
| `metadata` | map | No | Additional metadata for the test case |
| `skip` | bool | No | Skip this test case |
| `timeout` | string | No | Override timeout (e.g., "30s", "1m") |
### Environment Override Priority
The test environment (user/team) is determined by the following priority (highest first):
1. **Command line flags** (`-u`, `-t`): Global override for all test cases
2. **Test case fields** (`user`, `team`): Per-test case configuration
3. **Default values**: "test-user", "test-team"
Example:
```bash
# All tests run as "admin" user in "prod-team", regardless of test case settings
yao agent test -i tests/inputs.jsonl -u admin -t prod-team -o report.json
```
```jsonl
# T001 uses default user/team
{"id": "T001", "input": "Hello"}
# T002 uses specific user/team (unless overridden by -u/-t flags)
{"id": "T002", "input": "Admin action", "user": "admin", "team": "admin-team"}
# T003 uses specific user only, team uses default
{"id": "T003", "input": "User specific test", "user": "special-user"}
```
## Output Format
### Default: JSONL (without `-r` flag)
By default (without `-r` flag), the output is JSONL format - one JSON object per line, suitable for streaming and CI integration:
```jsonl
{"type": "start", "timestamp": "2024-12-17T10:00:00Z", "agent_id": "keyword", "total_cases": 42}
{"type": "result", "id": "T001", "status": "passed", "duration_ms": 234, "output": {"keywords": ["AI", "ML"]}}
{"type": "result", "id": "T002", "status": "passed", "duration_ms": 189, "output": {"keywords": ["cloud"]}}
{"type": "result", "id": "T003", "status": "failed", "duration_ms": 0, "error": "timeout after 30s"}
{"type": "summary", "total": 42, "passed": 40, "failed": 2, "duration_ms": 12345}
```
This format is:
- **Streamable**: Results are output as they complete
- **Parseable**: Each line is valid JSON, easy to process with `jq` or scripts
- **CI-friendly**: Exit code indicates pass/fail status
### Custom Report (with `-r` flag)
```json
{
"summary": {
"total": 42,
"passed": 40,
"failed": 2,
"skipped": 0,
"duration_ms": 12345,
"agent_id": "keyword",
"connector": "deepseek.v3",
"runs_per_case": 1,
"overall_pass_rate": 95.2
},
"environment": {
"user_id": "test-user",
"team_id": "test-team",
"locale": "en-us"
},
"results": [
{
"id": "T001",
"status": "passed",
"input": "...",
"output": { "keywords": ["AI", "machine learning"] },
"expected": null,
"duration_ms": 234,
"error": null
}
],
"metadata": {
"started_at": "2024-12-17T10:00:00Z",
"completed_at": "2024-12-17T10:00:12Z",
"version": "0.10.5"
}
}
```
### HTML Report
Beautiful, interactive HTML report with:
- Summary statistics (pass/fail/skip counts, duration)
- Stability charts (when runs > 1)
- Filterable test results table
- Expandable input/output details
- Error highlighting
- Export options
### Markdown Report
```markdown
# Agent Test Report
## Summary
| Metric | Value |
| --------- | ----------- |
| Agent | keyword |
| Connector | deepseek.v3 |
| Total | 42 |
| Passed | 40 |
| Failed | 2 |
| Pass Rate | 95.2% |
| Duration | 12.3s |
## Environment
| Setting | Value |
| ------- | --------- |
| User | test-user |
| Team | test-team |
| Locale | en-us |
## Results
### ✅ T001 - Passed (234ms)
...
```
## Architecture
```
agent/test/
├── DESIGN.md # This file
├── types.go # Core types and interfaces
├── interfaces.go # Runner and Reporter interfaces
├── runner.go # Test runner implementation
├── loader.go # Test case loader
├── resolver.go # Agent resolver
├── environment.go # Test environment setup
├── stability.go # Stability analysis
└── reporter/
├── json.go # JSON reporter
├── html.go # HTML reporter
├── markdown.go # Markdown reporter
└── agent.go # Agent-based custom reporter
```
## Core Components
### 1. TestCase
Represents a single test case loaded from JSONL.
### 2. TestResult
Represents the result of running a single test case.
### 3. TestReport
Represents the complete test report with summary and results.
### 4. Runner
Executes test cases against an agent:
- Loads test cases from JSONL
- Resolves agent from path or explicit ID
- Creates test context with environment
- Executes each test case (optionally multiple runs)
- Collects results and stability metrics
### 5. Reporter
Generates reports in various formats. The format is determined by the `-o` file extension:
| Extension | Format | Description |
| --------- | -------- | -------------------------- |
| `.jsonl` | JSONL | Streaming, line-by-line |
| `.json` | JSON | Full structured report |
| `.md` | Markdown | Human-readable with tables |
| `.html` | HTML | Interactive web report |
#### Custom Reporter Agent (`-r` flag)
When `-r <agent-id>` is specified, the framework calls the specified agent to generate the report:
1. Test execution completes, `TestReport` is generated
2. Framework calls the reporter agent with input:
```json
{
"report": {
/* TestReport object */
},
"format": "html",
"options": { "verbose": true }
}
```
3. Agent processes the report and returns formatted content
4. Framework writes the returned content to the output file
Example usage:
```bash
# Use custom reporter agent to generate a beautiful HTML report
yao agent test -i tests/inputs.jsonl -r report.beautiful -o report.html
# Use custom reporter agent to generate Slack-formatted summary
yao agent test -i tests/inputs.jsonl -r report.slack -o summary.txt
```
This allows for fully customizable report generation using AI agents
## Configuration
### Test Options
```go
type Options struct {
// Input/Output
InputFile string // Path to inputs.jsonl
OutputFile string // Path to output report
// Agent Selection
AgentID string // Explicit agent ID (optional)
Connector string // Override connector (optional)
// Test Environment
UserID string // Test user ID (-u flag)
TeamID string // Test team ID (-t flag)
Locale string // Locale (default: "en-us")
// Execution
Timeout time.Duration // Default timeout per test
Parallel int // Number of parallel tests (default: 1)
Runs int // Number of runs per test case (default: 1)
// Reporting
ReporterID string // Reporter agent ID for custom report
// Behavior
Verbose bool // Verbose output
FailFast bool // Stop on first failure
}
```
## Exit Codes
| Code | Description |
| ---- | ------------------- |
| 0 | All tests passed |
| 1 | Some tests failed |
| 2 | Configuration error |
| 3 | Runtime error |
## CI Integration
### GitHub Actions Example
```yaml
- name: Run Agent Tests
run: |
yao agent test -i assistants/keyword/tests/inputs.jsonl \
-u ci-user -t ci-team \
--runs 3 \
-o report.json
- name: Check Stability
run: |
# Fail if any test has pass rate below 80%
jq -e '.results | all(.pass_rate >= 80)' report.json
- name: Upload Test Report
uses: actions/upload-artifact@v3
with:
name: agent-test-report
path: report.json
```
### Exit Code Handling
The command exits with code 1 if any tests fail, making it easy to integrate with CI pipelines.
## Future Enhancements
1. **Snapshot Testing**: Compare outputs against saved snapshots
2. **Fuzzing**: Generate random inputs for robustness testing
3. **Coverage**: Track which agent code paths are exercised
4. **Benchmarking**: Performance metrics and regression detection
5. **Diff Reports**: Compare results between runs
6. **Flaky Test Detection**: Automatic identification of unstable tests
7. **Test Prioritization**: Run most important/failing tests first

63
agent/test/context.go Normal file
View file

@ -0,0 +1,63 @@
package test
import (
stdContext "context"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// NewTestContext creates a new context for testing
// This is similar to newAgentNextTestContext in agent_next_test.go
// but configurable via Environment
func NewTestContext(chatID, assistantID string, env *Environment) *context.Context {
// Build authorized info from environment
authorized := &types.AuthorizedInfo{
Subject: env.UserID,
UserID: env.UserID,
TenantID: env.TeamID,
}
// Create context with standard initialization
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.ID = chatID
ctx.AssistantID = assistantID
ctx.Locale = env.Locale
ctx.Client = context.Client{
Type: env.ClientType,
UserAgent: "yao-agent-test/1.0",
IP: env.ClientIP,
}
ctx.Referer = env.Referer
ctx.Accept = context.AcceptStandard
ctx.IDGenerator = message.NewIDGenerator()
ctx.Metadata = make(map[string]interface{})
// Initialize interrupt controller
ctx.Interrupt = context.NewInterruptController()
// Close the default logger created by context.New() and use noop logger
// to suppress LLM debug output during tests
if ctx.Logger != nil {
ctx.Logger.Close()
}
ctx.Logger = context.Noop()
return ctx
}
// NewTestContextFromOptions creates a test context from test options and test case
func NewTestContextFromOptions(chatID, assistantID string, opts *Options, tc *Case) *context.Context {
// Get environment from test case (with options override)
env := tc.GetEnvironment(opts)
return NewTestContext(chatID, assistantID, env)
}
// GenerateChatID generates a unique chat ID for testing
func GenerateChatID(testID string, runNumber int) string {
if runNumber > 1 {
return "test-" + testID + "-run" + string(rune('0'+runNumber))
}
return "test-" + testID
}

230
agent/test/input.go Normal file
View file

@ -0,0 +1,230 @@
package test
import (
"fmt"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/yao/agent/context"
)
// ParseInput converts various input formats to []context.Message
// Supported formats:
// - string: converted to single user message
// - map (Message): single message with role and content
// - []interface{} ([]Message): array of messages (conversation history)
func ParseInput(input interface{}) ([]context.Message, error) {
if input == nil {
return nil, fmt.Errorf("input is nil")
}
switch v := input.(type) {
case string:
// Simple string input -> single user message
return []context.Message{
{
Role: context.RoleUser,
Content: v,
},
}, nil
case map[string]interface{}:
// Single message object
msg, err := parseMessageMap(v)
if err != nil {
return nil, fmt.Errorf("failed to parse message: %w", err)
}
return []context.Message{*msg}, nil
case []interface{}:
// Array of messages (conversation history)
messages := make([]context.Message, 0, len(v))
for i, item := range v {
switch m := item.(type) {
case map[string]interface{}:
msg, err := parseMessageMap(m)
if err != nil {
return nil, fmt.Errorf("failed to parse message at index %d: %w", i, err)
}
messages = append(messages, *msg)
default:
return nil, fmt.Errorf("invalid message type at index %d: expected object, got %T", i, item)
}
}
return messages, nil
default:
return nil, fmt.Errorf("unsupported input type: %T", input)
}
}
// parseMessageMap converts a map to context.Message
func parseMessageMap(m map[string]interface{}) (*context.Message, error) {
msg := &context.Message{}
// Parse role (required)
if role, ok := m["role"].(string); ok {
msg.Role = context.MessageRole(role)
} else {
// Default to user role if not specified
msg.Role = context.RoleUser
}
// Parse content (required)
if content, ok := m["content"]; ok {
msg.Content = content
} else {
return nil, fmt.Errorf("message missing 'content' field")
}
// Parse optional name
if name, ok := m["name"].(string); ok {
msg.Name = &name
}
// Parse optional tool_call_id (for tool messages)
if toolCallID, ok := m["tool_call_id"].(string); ok {
msg.ToolCallID = &toolCallID
}
// Parse optional tool_calls (for assistant messages)
if toolCalls, ok := m["tool_calls"].([]interface{}); ok {
msg.ToolCalls = make([]context.ToolCall, 0, len(toolCalls))
for _, tc := range toolCalls {
if tcMap, ok := tc.(map[string]interface{}); ok {
toolCall, err := parseToolCall(tcMap)
if err != nil {
return nil, fmt.Errorf("failed to parse tool_call: %w", err)
}
msg.ToolCalls = append(msg.ToolCalls, *toolCall)
}
}
}
// Parse optional refusal (for assistant messages)
if refusal, ok := m["refusal"].(string); ok {
msg.Refusal = &refusal
}
return msg, nil
}
// parseToolCall converts a map to context.ToolCall
func parseToolCall(m map[string]interface{}) (*context.ToolCall, error) {
tc := &context.ToolCall{}
if id, ok := m["id"].(string); ok {
tc.ID = id
}
if typ, ok := m["type"].(string); ok {
tc.Type = context.ToolCallType(typ)
} else {
tc.Type = context.ToolTypeFunction
}
if fn, ok := m["function"].(map[string]interface{}); ok {
if name, ok := fn["name"].(string); ok {
tc.Function.Name = name
}
if args, ok := fn["arguments"].(string); ok {
tc.Function.Arguments = args
} else if args, ok := fn["arguments"].(map[string]interface{}); ok {
// Convert map to JSON string
argsBytes, err := jsoniter.Marshal(args)
if err != nil {
return nil, fmt.Errorf("failed to marshal arguments: %w", err)
}
tc.Function.Arguments = string(argsBytes)
}
}
return tc, nil
}
// ExtractTextContent extracts text content from various content formats
// Used for display in reports
func ExtractTextContent(content interface{}) string {
if content == nil {
return ""
}
switch v := content.(type) {
case string:
return v
case []interface{}:
// ContentPart array
var texts []string
for _, part := range v {
if partMap, ok := part.(map[string]interface{}); ok {
if partMap["type"] == "text" {
if text, ok := partMap["text"].(string); ok {
texts = append(texts, text)
}
}
}
}
if len(texts) > 0 {
result := texts[0]
for i := 1; i < len(texts); i++ {
result += "\n" + texts[i]
}
return result
}
return fmt.Sprintf("[%d content parts]", len(v))
case map[string]interface{}:
// Single ContentPart or Message
if v["type"] == "text" {
if text, ok := v["text"].(string); ok {
return text
}
}
if content, ok := v["content"]; ok {
return ExtractTextContent(content)
}
return fmt.Sprintf("%v", v)
default:
return fmt.Sprintf("%v", v)
}
}
// SummarizeInput creates a short summary of the input for display
func SummarizeInput(input interface{}, maxLen int) string {
text := ""
switch v := input.(type) {
case string:
text = v
case map[string]interface{}:
if content, ok := v["content"]; ok {
text = ExtractTextContent(content)
}
case []interface{}:
// Get the last user message for summary
for i := len(v) - 1; i >= 0; i-- {
if msg, ok := v[i].(map[string]interface{}); ok {
if msg["role"] == "user" {
if content, ok := msg["content"]; ok {
text = ExtractTextContent(content)
break
}
}
}
}
if text == "" && len(v) > 0 {
text = fmt.Sprintf("[%d messages]", len(v))
}
default:
text = fmt.Sprintf("%v", v)
}
if maxLen > 0 && len(text) > maxLen {
return text[:maxLen-3] + "..."
}
return text
}

125
agent/test/interfaces.go Normal file
View file

@ -0,0 +1,125 @@
package test
import (
"context"
"io"
)
// Runner is the interface for test execution
type Runner interface {
// Run executes all test cases and returns the report
Run(ctx context.Context) (*Report, error)
// RunCase executes a single test case
RunCase(ctx context.Context, tc *Case) (*Result, error)
// GetAgentInfo returns information about the agent being tested
GetAgentInfo() *AgentInfo
// SetProgressCallback sets a callback for progress updates
SetProgressCallback(callback ProgressCallback)
}
// ProgressCallback is called during test execution to report progress
// Parameters:
// - current: current test index (1-based)
// - total: total number of tests
// - result: result of the current test (nil if not yet completed)
type ProgressCallback func(current, total int, result *Result)
// Reporter is the interface for generating test reports
type Reporter interface {
// Generate generates a report from the test results
Generate(report *Report) error
// Write writes the report to the given writer
Write(report *Report, w io.Writer) error
}
// Loader is the interface for loading test cases
type Loader interface {
// Load loads test cases from the input source
Load() ([]*Case, error)
// LoadFile loads test cases from a JSONL file
LoadFile(path string) ([]*Case, error)
}
// Resolver is the interface for resolving agent information
type Resolver interface {
// Resolve resolves the agent from options
// Priority: explicit AgentID > path-based detection
Resolve(opts *Options) (*AgentInfo, error)
// ResolveFromPath resolves the agent by traversing up from the input file path
ResolveFromPath(inputPath string) (*AgentInfo, error)
}
// Validator is the interface for validating test outputs
type Validator interface {
// Validate compares actual output against expected output
// Returns nil if validation passes, error otherwise
Validate(actual, expected interface{}) error
// ValidateJSON validates JSON outputs with flexible comparison
ValidateJSON(actual, expected interface{}) error
}
// OutputAdapter adapts agent output to a comparable format
type OutputAdapter interface {
// Adapt transforms the raw agent output to a normalized format
Adapt(output interface{}) (interface{}, error)
}
// RunnerFactory creates Runner instances
type RunnerFactory interface {
// Create creates a new Runner with the given options
Create(opts *Options) (Runner, error)
}
// ReporterFactory creates Reporter instances
type ReporterFactory interface {
// Create creates a new Reporter for the given format
Create(format OutputFormat) (Reporter, error)
// CreateFromPath creates a Reporter based on output file extension
CreateFromPath(outputPath string) (Reporter, error)
}
// Hook allows customization of test execution
type Hook interface {
// BeforeAll is called before any tests run
BeforeAll(ctx context.Context, cases []*Case) error
// BeforeEach is called before each test case
BeforeEach(ctx context.Context, tc *Case) error
// AfterEach is called after each test case
AfterEach(ctx context.Context, tc *Case, result *Result) error
// AfterAll is called after all tests complete
AfterAll(ctx context.Context, report *Report) error
}
// DefaultHook provides a no-op implementation of Hook
type DefaultHook struct{}
// BeforeAll implements Hook
func (h *DefaultHook) BeforeAll(ctx context.Context, cases []*Case) error {
return nil
}
// BeforeEach implements Hook
func (h *DefaultHook) BeforeEach(ctx context.Context, tc *Case) error {
return nil
}
// AfterEach implements Hook
func (h *DefaultHook) AfterEach(ctx context.Context, tc *Case, result *Result) error {
return nil
}
// AfterAll implements Hook
func (h *DefaultHook) AfterAll(ctx context.Context, report *Report) error {
return nil
}

141
agent/test/loader.go Normal file
View file

@ -0,0 +1,141 @@
package test
import (
"bufio"
"fmt"
"os"
"strings"
"time"
jsoniter "github.com/json-iterator/go"
)
// JSONLLoader loads test cases from JSONL files
type JSONLLoader struct{}
// NewLoader creates a new JSONL loader
func NewLoader() Loader {
return &JSONLLoader{}
}
// Load loads test cases from the default input source
// This is a placeholder - actual implementation would use configured path
func (l *JSONLLoader) Load() ([]*Case, error) {
return nil, fmt.Errorf("Load() requires explicit path, use LoadFile() instead")
}
// LoadFile loads test cases from a JSONL file
func (l *JSONLLoader) LoadFile(path string) ([]*Case, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open file %s: %w", path, err)
}
defer file.Close()
var cases []*Case
scanner := bufio.NewScanner(file)
lineNum := 0
// Increase buffer size for long lines
const maxCapacity = 1024 * 1024 // 1MB
buf := make([]byte, maxCapacity)
scanner.Buffer(buf, maxCapacity)
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
// Skip empty lines
if line == "" {
continue
}
// Skip comments (lines starting with //)
if strings.HasPrefix(line, "//") {
continue
}
var tc Case
if err := jsoniter.UnmarshalFromString(line, &tc); err != nil {
return nil, fmt.Errorf("failed to parse line %d: %w", lineNum, err)
}
// Validate required fields
if tc.ID == "" {
return nil, fmt.Errorf("line %d: missing required field 'id'", lineNum)
}
if tc.Input == nil {
return nil, fmt.Errorf("line %d (id=%s): missing required field 'input'", lineNum, tc.ID)
}
cases = append(cases, &tc)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading file: %w", err)
}
if len(cases) == 0 {
return nil, fmt.Errorf("no test cases found in %s", path)
}
return cases, nil
}
// ValidateTestCases validates a slice of test cases
func ValidateTestCases(cases []*Case) error {
ids := make(map[string]bool)
for i, tc := range cases {
// Check for duplicate IDs
if ids[tc.ID] {
return fmt.Errorf("duplicate test case ID: %s", tc.ID)
}
ids[tc.ID] = true
// Validate input can be parsed
if _, err := tc.GetMessages(); err != nil {
return fmt.Errorf("test case %s (index %d): invalid input: %w", tc.ID, i, err)
}
// Validate timeout format if specified
if tc.Timeout != "" {
// GetTimeout returns a duration, parsing error would return default
// We validate by checking if the string is parseable
if _, err := time.ParseDuration(tc.Timeout); err != nil {
return fmt.Errorf("test case %s: invalid timeout format: %s", tc.ID, tc.Timeout)
}
}
}
return nil
}
// FilterTestCases filters test cases based on criteria
func FilterTestCases(cases []*Case, filter func(*Case) bool) []*Case {
var result []*Case
for _, tc := range cases {
if filter(tc) {
result = append(result, tc)
}
}
return result
}
// FilterSkipped returns test cases that are not skipped
func FilterSkipped(cases []*Case) []*Case {
return FilterTestCases(cases, func(tc *Case) bool {
return !tc.Skip
})
}
// FilterByIDs returns test cases matching the given IDs
func FilterByIDs(cases []*Case, ids []string) []*Case {
idSet := make(map[string]bool)
for _, id := range ids {
idSet[id] = true
}
return FilterTestCases(cases, func(tc *Case) bool {
return idSet[tc.ID]
})
}

316
agent/test/output.go Normal file
View file

@ -0,0 +1,316 @@
package test
import (
"fmt"
"strings"
"time"
"github.com/fatih/color"
jsoniter "github.com/json-iterator/go"
)
// OutputWriter handles colored console output for test execution
type OutputWriter struct {
verbose bool
}
// NewOutputWriter creates a new output writer
func NewOutputWriter(verbose bool) *OutputWriter {
return &OutputWriter{verbose: verbose}
}
// Header prints a header section
func (w *OutputWriter) Header(title string) {
fmt.Println()
color.New(color.FgCyan, color.Bold).Println("═══════════════════════════════════════════════════════════════")
color.New(color.FgCyan, color.Bold).Printf(" %s\n", title)
color.New(color.FgCyan, color.Bold).Println("═══════════════════════════════════════════════════════════════")
}
// SubHeader prints a sub-header
func (w *OutputWriter) SubHeader(title string) {
fmt.Println()
color.New(color.FgWhite, color.Bold).Println("───────────────────────────────────────────────────────────────")
color.New(color.FgWhite, color.Bold).Printf(" %s\n", title)
color.New(color.FgWhite, color.Bold).Println("───────────────────────────────────────────────────────────────")
}
// Info prints an info message
func (w *OutputWriter) Info(format string, args ...interface{}) {
color.New(color.FgBlue).Printf(" ")
fmt.Printf(format+"\n", args...)
}
// Success prints a success message
func (w *OutputWriter) Success(format string, args ...interface{}) {
color.New(color.FgGreen).Printf("✓ ")
fmt.Printf(format+"\n", args...)
}
// Error prints an error message
func (w *OutputWriter) Error(format string, args ...interface{}) {
color.New(color.FgRed).Printf("✗ ")
fmt.Printf(format+"\n", args...)
}
// Warning prints a warning message
func (w *OutputWriter) Warning(format string, args ...interface{}) {
color.New(color.FgYellow).Printf("⚠ ")
fmt.Printf(format+"\n", args...)
}
// Skip prints a skip message
func (w *OutputWriter) Skip(format string, args ...interface{}) {
color.New(color.FgYellow).Printf("○ ")
fmt.Printf(format+"\n", args...)
}
// Verbose prints a verbose message (only if verbose mode is enabled)
func (w *OutputWriter) Verbose(format string, args ...interface{}) {
if w.verbose {
color.New(color.FgHiBlack).Printf(" │ ")
fmt.Printf(format+"\n", args...)
}
}
// TestStart prints test case start
func (w *OutputWriter) TestStart(id string, input string, runNum int) {
inputPreview := truncateString(input, 50)
if runNum > 1 {
color.New(color.FgWhite).Printf("► [%s] Run %d: ", id, runNum)
} else {
color.New(color.FgWhite).Printf("► [%s] ", id)
}
color.New(color.FgHiBlack).Printf("%s", inputPreview)
fmt.Print(" ")
}
// TestResult prints test case result
func (w *OutputWriter) TestResult(status Status, duration time.Duration) {
switch status {
case StatusPassed:
color.New(color.FgGreen, color.Bold).Printf("PASSED")
case StatusFailed:
color.New(color.FgRed, color.Bold).Printf("FAILED")
case StatusSkipped:
color.New(color.FgYellow).Printf("SKIPPED")
case StatusError:
color.New(color.FgRed, color.Bold).Printf("ERROR")
case StatusTimeout:
color.New(color.FgRed).Printf("TIMEOUT")
}
color.New(color.FgHiBlack).Printf(" (%s)\n", formatDuration(duration))
}
// TestError prints test error details
func (w *OutputWriter) TestError(err string) {
color.New(color.FgRed).Printf(" └─ %s\n", err)
}
// TestOutput prints test output (verbose mode)
func (w *OutputWriter) TestOutput(output string) {
if w.verbose && output != "" {
outputPreview := truncateString(output, 100)
color.New(color.FgHiBlack).Printf(" └─ Output: %s\n", outputPreview)
}
}
// Progress prints progress information
func (w *OutputWriter) Progress(current, total int) {
percentage := float64(current) / float64(total) * 100
color.New(color.FgHiBlack).Printf("\r Progress: %d/%d (%.0f%%)", current, total, percentage)
}
// Summary prints the test summary
func (w *OutputWriter) Summary(summary *Summary, duration time.Duration) {
w.SubHeader("Summary")
// Agent info
color.New(color.FgWhite).Printf(" Agent: ")
color.New(color.FgCyan).Printf("%s\n", summary.AgentID)
if summary.Connector != "" {
color.New(color.FgWhite).Printf(" Connector: ")
color.New(color.FgCyan).Printf("%s\n", summary.Connector)
}
// Results
color.New(color.FgWhite).Printf(" Total: ")
fmt.Printf("%d\n", summary.Total)
color.New(color.FgWhite).Printf(" Passed: ")
if summary.Passed > 0 {
color.New(color.FgGreen).Printf("%d\n", summary.Passed)
} else {
fmt.Printf("%d\n", summary.Passed)
}
color.New(color.FgWhite).Printf(" Failed: ")
if summary.Failed > 0 {
color.New(color.FgRed).Printf("%d\n", summary.Failed)
} else {
fmt.Printf("%d\n", summary.Failed)
}
if summary.Skipped > 0 {
color.New(color.FgWhite).Printf(" Skipped: ")
color.New(color.FgYellow).Printf("%d\n", summary.Skipped)
}
if summary.Errors > 0 {
color.New(color.FgWhite).Printf(" Errors: ")
color.New(color.FgRed).Printf("%d\n", summary.Errors)
}
if summary.Timeouts > 0 {
color.New(color.FgWhite).Printf(" Timeouts: ")
color.New(color.FgRed).Printf("%d\n", summary.Timeouts)
}
// Pass rate
passRate := float64(0)
if summary.Total > 0 {
passRate = float64(summary.Passed) / float64(summary.Total) * 100
}
color.New(color.FgWhite).Printf(" Pass Rate: ")
if passRate == 100 {
color.New(color.FgGreen, color.Bold).Printf("%.1f%%\n", passRate)
} else if passRate >= 80 {
color.New(color.FgYellow).Printf("%.1f%%\n", passRate)
} else {
color.New(color.FgRed).Printf("%.1f%%\n", passRate)
}
// Duration
color.New(color.FgWhite).Printf(" Duration: ")
fmt.Printf("%s\n", formatDuration(duration))
// Stability info (if runs > 1)
if summary.RunsPerCase > 1 {
fmt.Println()
color.New(color.FgWhite, color.Bold).Println(" Stability Analysis:")
color.New(color.FgWhite).Printf(" Runs/Case: %d\n", summary.RunsPerCase)
color.New(color.FgWhite).Printf(" Total Runs: %d\n", summary.TotalRuns)
color.New(color.FgWhite).Printf(" Stable Cases: ")
if summary.StableCases == summary.Total {
color.New(color.FgGreen).Printf("%d\n", summary.StableCases)
} else {
color.New(color.FgYellow).Printf("%d\n", summary.StableCases)
}
color.New(color.FgWhite).Printf(" Unstable: ")
if summary.UnstableCases > 0 {
color.New(color.FgRed).Printf("%d\n", summary.UnstableCases)
} else {
fmt.Printf("%d\n", summary.UnstableCases)
}
}
}
// OutputFile prints the output file path
func (w *OutputWriter) OutputFile(path string) {
fmt.Println()
color.New(color.FgWhite).Printf(" Output: ")
color.New(color.FgCyan).Printf("%s\n", path)
}
// FinalResult prints the final result banner
func (w *OutputWriter) FinalResult(passed bool) {
fmt.Println()
if passed {
color.New(color.FgGreen, color.Bold).Println("═══════════════════════════════════════════════════════════════")
color.New(color.FgGreen, color.Bold).Println(" ✨ ALL TESTS PASSED ✨")
color.New(color.FgGreen, color.Bold).Println("═══════════════════════════════════════════════════════════════")
} else {
color.New(color.FgRed, color.Bold).Println("═══════════════════════════════════════════════════════════════")
color.New(color.FgRed, color.Bold).Println(" ❌ TESTS FAILED")
color.New(color.FgRed, color.Bold).Println("═══════════════════════════════════════════════════════════════")
}
fmt.Println()
}
// DirectOutput prints the agent output directly (for development mode)
func (w *OutputWriter) DirectOutput(output interface{}) {
if output == nil {
return
}
// Try to format as JSON if it's a complex type
switch v := output.(type) {
case string:
fmt.Println(v)
case map[string]interface{}, []interface{}:
// Pretty print JSON
jsonBytes, err := jsoniter.MarshalIndent(v, "", " ")
if err != nil {
fmt.Printf("%v\n", output)
} else {
fmt.Println(string(jsonBytes))
}
default:
// Try to marshal as JSON
jsonBytes, err := jsoniter.MarshalIndent(output, "", " ")
if err != nil {
fmt.Printf("%v\n", output)
} else {
fmt.Println(string(jsonBytes))
}
}
}
// StabilityResult prints stability analysis result for a test case
func (w *OutputWriter) StabilityResult(sr *StabilityResult) {
color.New(color.FgWhite).Printf(" [%s] ", sr.ID)
// Pass rate
if sr.PassRate == 100 {
color.New(color.FgGreen).Printf("%.0f%%", sr.PassRate)
} else if sr.PassRate >= 80 {
color.New(color.FgYellow).Printf("%.0f%%", sr.PassRate)
} else {
color.New(color.FgRed).Printf("%.0f%%", sr.PassRate)
}
// Classification
color.New(color.FgHiBlack).Printf(" (%d/%d) ", sr.Passed, sr.Runs)
switch sr.StabilityClass {
case StabilityStable:
color.New(color.FgGreen).Printf("Stable")
case StabilityMostlyStable:
color.New(color.FgYellow).Printf("Mostly Stable")
case StabilityUnstable:
color.New(color.FgRed).Printf("Unstable")
case StabilityHighlyUnstable:
color.New(color.FgRed, color.Bold).Printf("Highly Unstable")
}
// Timing
color.New(color.FgHiBlack).Printf(" avg:%.0fms\n", sr.AvgDurationMs)
}
// Helper functions
func truncateString(s string, maxLen int) string {
// Remove newlines and extra spaces
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", "")
s = strings.Join(strings.Fields(s), " ")
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
func formatDuration(d time.Duration) string {
if d < time.Millisecond {
return fmt.Sprintf("%dµs", d.Microseconds())
}
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
}
return fmt.Sprintf("%.1fm", d.Minutes())
}

713
agent/test/reporter.go Normal file
View file

@ -0,0 +1,713 @@
package test
import (
"bufio"
"fmt"
"html/template"
"io"
"strings"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/yao/agent/caller"
"github.com/yaoapp/yao/agent/context"
)
// JSONLReporter generates JSONL format reports (default)
type JSONLReporter struct{}
// NewJSONLReporter creates a new JSONL reporter
func NewJSONLReporter() *JSONLReporter {
return &JSONLReporter{}
}
// Generate generates a JSONL report (writes to stdout or file)
func (r *JSONLReporter) Generate(report *Report) error {
return nil // JSONL is written during test execution
}
// Write writes the report in JSONL format
func (r *JSONLReporter) Write(report *Report, w io.Writer) error {
writer := bufio.NewWriter(w)
defer writer.Flush()
// Start event
startEvent := map[string]interface{}{
"type": "start",
"timestamp": report.Metadata.StartedAt.Format(time.RFC3339),
"agent_id": report.Summary.AgentID,
"total_cases": report.Summary.Total,
}
if err := writeJSONLineToWriter(writer, startEvent); err != nil {
return err
}
// Result events
if report.Results != nil {
for _, result := range report.Results {
resultEvent := map[string]interface{}{
"type": "result",
"id": result.ID,
"status": result.Status,
"duration_ms": result.DurationMs,
}
if result.Output != nil {
resultEvent["output"] = result.Output
}
if result.Error != "" {
resultEvent["error"] = result.Error
}
if err := writeJSONLineToWriter(writer, resultEvent); err != nil {
return err
}
}
}
// Stability results
if report.StabilityResults != nil {
for _, sr := range report.StabilityResults {
stabilityEvent := map[string]interface{}{
"type": "stability",
"id": sr.ID,
"runs": sr.Runs,
"passed": sr.Passed,
"failed": sr.Failed,
"pass_rate": sr.PassRate,
"stable": sr.Stable,
"stability_class": sr.StabilityClass,
"avg_duration_ms": sr.AvgDurationMs,
}
if err := writeJSONLineToWriter(writer, stabilityEvent); err != nil {
return err
}
}
}
// Summary event
summaryEvent := map[string]interface{}{
"type": "summary",
"total": report.Summary.Total,
"passed": report.Summary.Passed,
"failed": report.Summary.Failed,
"skipped": report.Summary.Skipped,
"errors": report.Summary.Errors,
"timeouts": report.Summary.Timeouts,
"duration_ms": report.Summary.DurationMs,
}
if report.Summary.RunsPerCase > 1 {
summaryEvent["runs_per_case"] = report.Summary.RunsPerCase
summaryEvent["total_runs"] = report.Summary.TotalRuns
summaryEvent["overall_pass_rate"] = report.Summary.OverallPassRate
summaryEvent["stable_cases"] = report.Summary.StableCases
summaryEvent["unstable_cases"] = report.Summary.UnstableCases
}
return writeJSONLineToWriter(writer, summaryEvent)
}
// writeJSONLineToWriter writes a JSON line to the writer
func writeJSONLineToWriter(writer *bufio.Writer, data interface{}) error {
line, err := jsoniter.Marshal(data)
if err != nil {
return err
}
_, err = writer.Write(line)
if err != nil {
return err
}
_, err = writer.WriteString("\n")
return err
}
// JSONReporter generates full JSON format reports
type JSONReporter struct{}
// NewJSONReporter creates a new JSON reporter
func NewJSONReporter() *JSONReporter {
return &JSONReporter{}
}
// Generate generates a JSON report
func (r *JSONReporter) Generate(report *Report) error {
return nil
}
// Write writes the report in JSON format
func (r *JSONReporter) Write(report *Report, w io.Writer) error {
encoder := jsoniter.NewEncoder(w)
encoder.SetIndent("", " ")
return encoder.Encode(report)
}
// MarkdownReporter generates Markdown format reports
type MarkdownReporter struct{}
// NewMarkdownReporter creates a new Markdown reporter
func NewMarkdownReporter() *MarkdownReporter {
return &MarkdownReporter{}
}
// Generate generates a Markdown report
func (r *MarkdownReporter) Generate(report *Report) error {
return nil
}
// Write writes the report in Markdown format
func (r *MarkdownReporter) Write(report *Report, w io.Writer) error {
var sb strings.Builder
// Header
sb.WriteString("# Agent Test Report\n\n")
// Summary
sb.WriteString("## Summary\n\n")
sb.WriteString("| Metric | Value |\n")
sb.WriteString("| ------ | ----- |\n")
sb.WriteString(fmt.Sprintf("| Agent | %s |\n", report.Summary.AgentID))
if report.Summary.Connector != "" {
sb.WriteString(fmt.Sprintf("| Connector | %s |\n", report.Summary.Connector))
}
sb.WriteString(fmt.Sprintf("| Total | %d |\n", report.Summary.Total))
sb.WriteString(fmt.Sprintf("| Passed | %d |\n", report.Summary.Passed))
sb.WriteString(fmt.Sprintf("| Failed | %d |\n", report.Summary.Failed))
if report.Summary.Skipped > 0 {
sb.WriteString(fmt.Sprintf("| Skipped | %d |\n", report.Summary.Skipped))
}
if report.Summary.Errors > 0 {
sb.WriteString(fmt.Sprintf("| Errors | %d |\n", report.Summary.Errors))
}
if report.Summary.Timeouts > 0 {
sb.WriteString(fmt.Sprintf("| Timeouts | %d |\n", report.Summary.Timeouts))
}
passRate := float64(0)
if report.Summary.Total > 0 {
passRate = float64(report.Summary.Passed) / float64(report.Summary.Total) * 100
}
sb.WriteString(fmt.Sprintf("| Pass Rate | %.1f%% |\n", passRate))
sb.WriteString(fmt.Sprintf("| Duration | %dms |\n", report.Summary.DurationMs))
sb.WriteString("\n")
// Environment
if report.Environment != nil {
sb.WriteString("## Environment\n\n")
sb.WriteString("| Setting | Value |\n")
sb.WriteString("| ------- | ----- |\n")
sb.WriteString(fmt.Sprintf("| User | %s |\n", report.Environment.UserID))
sb.WriteString(fmt.Sprintf("| Team | %s |\n", report.Environment.TeamID))
sb.WriteString(fmt.Sprintf("| Locale | %s |\n", report.Environment.Locale))
sb.WriteString("\n")
}
// Results
sb.WriteString("## Results\n\n")
if report.Results != nil {
for _, result := range report.Results {
statusIcon := "✅"
switch result.Status {
case StatusFailed:
statusIcon = "❌"
case StatusError:
statusIcon = "💥"
case StatusTimeout:
statusIcon = "⏱️"
case StatusSkipped:
statusIcon = "⏭️"
}
sb.WriteString(fmt.Sprintf("### %s %s - %s (%dms)\n\n", statusIcon, result.ID, result.Status, result.DurationMs))
if result.Error != "" {
sb.WriteString(fmt.Sprintf("**Error:** %s\n\n", result.Error))
}
}
}
// Stability results
if report.StabilityResults != nil {
sb.WriteString("## Stability Analysis\n\n")
sb.WriteString("| ID | Pass Rate | Runs | Status | Avg Duration |\n")
sb.WriteString("| -- | --------- | ---- | ------ | ------------ |\n")
for _, sr := range report.StabilityResults {
status := string(sr.StabilityClass)
sb.WriteString(fmt.Sprintf("| %s | %.0f%% | %d/%d | %s | %.0fms |\n",
sr.ID, sr.PassRate, sr.Passed, sr.Runs, status, sr.AvgDurationMs))
}
sb.WriteString("\n")
}
// Metadata
sb.WriteString("## Metadata\n\n")
sb.WriteString(fmt.Sprintf("- **Started:** %s\n", report.Metadata.StartedAt.Format(time.RFC3339)))
sb.WriteString(fmt.Sprintf("- **Completed:** %s\n", report.Metadata.CompletedAt.Format(time.RFC3339)))
if report.Metadata.InputFile != "" {
sb.WriteString(fmt.Sprintf("- **Input File:** %s\n", report.Metadata.InputFile))
}
if report.Metadata.OutputFile != "" {
sb.WriteString(fmt.Sprintf("- **Output File:** %s\n", report.Metadata.OutputFile))
}
_, err := w.Write([]byte(sb.String()))
return err
}
// HTMLReporter generates HTML format reports
type HTMLReporter struct{}
// NewHTMLReporter creates a new HTML reporter
func NewHTMLReporter() *HTMLReporter {
return &HTMLReporter{}
}
// Generate generates an HTML report
func (r *HTMLReporter) Generate(report *Report) error {
return nil
}
// Write writes the report in HTML format
func (r *HTMLReporter) Write(report *Report, w io.Writer) error {
tmpl, err := template.New("report").Parse(htmlTemplate)
if err != nil {
return fmt.Errorf("failed to parse HTML template: %w", err)
}
// Calculate pass rate
passRate := float64(0)
if report.Summary.Total > 0 {
passRate = float64(report.Summary.Passed) / float64(report.Summary.Total) * 100
}
data := map[string]interface{}{
"Report": report,
"PassRate": passRate,
}
return tmpl.Execute(w, data)
}
// HTML template for reports
const htmlTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent Test Report - {{.Report.Summary.AgentID}}</title>
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--accent-green: #3fb950;
--accent-red: #f85149;
--accent-yellow: #d29922;
--accent-blue: #58a6ff;
--border-color: #30363d;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
padding: 2rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
h2 {
font-size: 1.25rem;
margin: 2rem 0 1rem;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 2rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
text-align: center;
}
.summary-card .value {
font-size: 2rem;
font-weight: 600;
}
.summary-card .label {
color: var(--text-secondary);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.summary-card.passed .value { color: var(--accent-green); }
.summary-card.failed .value { color: var(--accent-red); }
.summary-card.rate .value { color: var(--accent-blue); }
.results-table {
width: 100%;
border-collapse: collapse;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
}
.results-table th,
.results-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.results-table th {
background: var(--bg-tertiary);
font-weight: 600;
color: var(--text-primary);
}
.results-table tr:last-child td {
border-bottom: none;
}
.status {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.status.passed { background: rgba(63, 185, 80, 0.2); color: var(--accent-green); }
.status.failed { background: rgba(248, 81, 73, 0.2); color: var(--accent-red); }
.status.error { background: rgba(248, 81, 73, 0.2); color: var(--accent-red); }
.status.timeout { background: rgba(210, 153, 34, 0.2); color: var(--accent-yellow); }
.status.skipped { background: rgba(139, 148, 158, 0.2); color: var(--text-secondary); }
.error-msg {
color: var(--accent-red);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.metadata {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.metadata dt {
font-weight: 600;
color: var(--text-primary);
display: inline;
}
.metadata dd {
display: inline;
margin: 0 1rem 0 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Agent Test Report</h1>
<p class="subtitle">{{.Report.Summary.AgentID}} {{if .Report.Summary.Connector}} {{.Report.Summary.Connector}}{{end}}</p>
<div class="summary-grid">
<div class="summary-card">
<div class="value">{{.Report.Summary.Total}}</div>
<div class="label">Total Tests</div>
</div>
<div class="summary-card passed">
<div class="value">{{.Report.Summary.Passed}}</div>
<div class="label">Passed</div>
</div>
<div class="summary-card failed">
<div class="value">{{.Report.Summary.Failed}}</div>
<div class="label">Failed</div>
</div>
<div class="summary-card rate">
<div class="value">{{printf "%.1f" .PassRate}}%</div>
<div class="label">Pass Rate</div>
</div>
<div class="summary-card">
<div class="value">{{.Report.Summary.DurationMs}}ms</div>
<div class="label">Duration</div>
</div>
</div>
<h2>Test Results</h2>
<table class="results-table">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Duration</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{{range .Report.Results}}
<tr>
<td>{{.ID}}</td>
<td><span class="status {{.Status}}">{{.Status}}</span></td>
<td>{{.DurationMs}}ms</td>
<td>
{{if .Error}}<div class="error-msg">{{.Error}}</div>{{end}}
</td>
</tr>
{{end}}
{{range .Report.StabilityResults}}
<tr>
<td>{{.ID}}</td>
<td><span class="status {{if .Stable}}passed{{else}}failed{{end}}">{{.StabilityClass}}</span></td>
<td>{{printf "%.0f" .AvgDurationMs}}ms avg</td>
<td>{{.Passed}}/{{.Runs}} passed ({{printf "%.0f" .PassRate}}%)</td>
</tr>
{{end}}
</tbody>
</table>
<h2>Metadata</h2>
<dl class="metadata">
<dt>Started:</dt><dd>{{.Report.Metadata.StartedAt}}</dd>
<dt>Completed:</dt><dd>{{.Report.Metadata.CompletedAt}}</dd>
{{if .Report.Metadata.InputFile}}<dt>Input:</dt><dd>{{.Report.Metadata.InputFile}}</dd>{{end}}
{{if .Report.Metadata.OutputFile}}<dt>Output:</dt><dd>{{.Report.Metadata.OutputFile}}</dd>{{end}}
</dl>
</div>
</body>
</html>`
// AgentReporter uses a custom agent to generate reports
type AgentReporter struct {
agentID string
format string
verbose bool
ctx *context.Context // Test context for agent call
}
// NewAgentReporter creates a new agent-based reporter
func NewAgentReporter(agentID, format string, verbose bool) *AgentReporter {
return &AgentReporter{
agentID: agentID,
format: format,
verbose: verbose,
}
}
// SetContext sets the context for agent calls
func (r *AgentReporter) SetContext(ctx *context.Context) {
r.ctx = ctx
}
// Generate generates a report using the agent
func (r *AgentReporter) Generate(report *Report) error {
return nil
}
// Write writes the report using the agent
func (r *AgentReporter) Write(report *Report, w io.Writer) error {
// Check if AgentGetterFunc is initialized
if caller.AgentGetterFunc == nil {
return fmt.Errorf("AgentGetterFunc not initialized, cannot call reporter agent")
}
// Get the reporter agent
agent, err := caller.AgentGetterFunc(r.agentID)
if err != nil {
return fmt.Errorf("failed to get reporter agent %s: %w", r.agentID, err)
}
// Build input for the reporter agent
input := &ReporterInput{
Report: report,
Format: r.format,
Options: &ReporterOptions{
Verbose: r.verbose,
IncludeOutputs: r.verbose,
IncludeInputs: r.verbose,
},
}
// Convert input to JSON for the agent
inputJSON, err := jsoniter.Marshal(input)
if err != nil {
return fmt.Errorf("failed to marshal reporter input: %w", err)
}
// Create message for the agent
messages := []context.Message{
{
Role: context.RoleUser,
Content: string(inputJSON),
},
}
// Create context if not provided
ctx := r.ctx
if ctx == nil {
// Create a minimal context for the reporter agent call
ctx = NewTestContext("reporter", r.agentID, NewEnvironment("", ""))
defer ctx.Release()
}
// Call the agent with skip options (no history, no output)
options := &context.Options{
Skip: &context.Skip{
History: true,
Output: true,
},
}
result, err := agent.Stream(ctx, messages, options)
if err != nil {
return fmt.Errorf("reporter agent call failed: %w", err)
}
// Extract content from result
content, err := r.extractContent(result)
if err != nil {
return fmt.Errorf("failed to extract report content: %w", err)
}
// Write the content to output
_, err = w.Write([]byte(content))
if err != nil {
return fmt.Errorf("failed to write report: %w", err)
}
return nil
}
// extractContent extracts the report content from the agent's response
func (r *AgentReporter) extractContent(result interface{}) (string, error) {
if result == nil {
return "", fmt.Errorf("agent returned nil result")
}
// Try to convert to map first (context.Response)
switch v := result.(type) {
case string:
return v, nil
case *context.Response:
// Extract from completion content
if v.Completion != nil && v.Completion.Content != nil {
return r.contentToString(v.Completion.Content)
}
// Try next field
if v.Next != nil {
return r.contentToString(v.Next)
}
return "", fmt.Errorf("no content in response")
case map[string]interface{}:
// Check for completion.content
if completion, ok := v["completion"].(map[string]interface{}); ok {
if content, ok := completion["content"]; ok {
return r.contentToString(content)
}
}
// Check for next
if next, ok := v["next"]; ok {
return r.contentToString(next)
}
// Check for content directly
if content, ok := v["content"]; ok {
return r.contentToString(content)
}
// Marshal the whole thing
jsonBytes, _ := jsoniter.Marshal(v)
return string(jsonBytes), nil
default:
// Try to marshal as JSON
jsonBytes, err := jsoniter.Marshal(result)
if err != nil {
return fmt.Sprintf("%v", result), nil
}
return string(jsonBytes), nil
}
}
// contentToString converts various content types to string
func (r *AgentReporter) contentToString(content interface{}) (string, error) {
switch v := content.(type) {
case string:
return v, nil
case []byte:
return string(v), nil
default:
jsonBytes, err := jsoniter.Marshal(content)
if err != nil {
return fmt.Sprintf("%v", content), nil
}
return string(jsonBytes), nil
}
}
// GetReporter returns a reporter based on output format
func GetReporter(format OutputFormat) Reporter {
switch format {
case FormatJSON:
return NewJSONReporter()
case FormatHTML:
return NewHTMLReporter()
case FormatMarkdown:
return NewMarkdownReporter()
default:
return NewJSONLReporter()
}
}
// GetReporterFromPath returns a reporter based on file extension
func GetReporterFromPath(outputPath string) Reporter {
format := GetOutputFormat(outputPath)
return GetReporter(format)
}
// GetReporterWithAgent returns an agent-based reporter if agentID is specified,
// otherwise returns a built-in reporter based on output format
func GetReporterWithAgent(agentID, outputPath string, verbose bool) Reporter {
if agentID != "" {
format := GetOutputFormat(outputPath)
return NewAgentReporter(agentID, string(format), verbose)
}
return GetReporterFromPath(outputPath)
}

316
agent/test/resolver.go Normal file
View file

@ -0,0 +1,316 @@
package test
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
jsoniter "github.com/json-iterator/go"
)
// PathResolver resolves agent information from file paths
type PathResolver struct{}
// NewResolver creates a new path resolver
func NewResolver() Resolver {
return &PathResolver{}
}
// Resolve resolves the agent from options
// Priority: explicit AgentID > path-based detection (from input file or cwd)
func (r *PathResolver) Resolve(opts *Options) (*AgentInfo, error) {
// If explicit agent ID is provided, use it
if opts.AgentID != "" {
return r.ResolveByID(opts.AgentID)
}
// For file mode, resolve from input file path
if opts.InputMode == InputModeFile {
if opts.Input == "" {
return nil, fmt.Errorf("no agent ID or input file specified")
}
return r.ResolveFromPath(opts.Input)
}
// For message mode, try to resolve from current working directory
return r.ResolveFromCwd()
}
// ResolveFromCwd resolves the agent from the current working directory
// It looks for package.yao in the current directory or parent directories
func (r *PathResolver) ResolveFromCwd() (*AgentInfo, error) {
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get current working directory: %w", err)
}
info, err := r.ResolveFromPath(cwd)
if err != nil {
return nil, fmt.Errorf("no agent found in current directory. Use -n to specify agent explicitly")
}
return info, nil
}
// ResolveFromPath resolves the agent by traversing up from the input file path
// It looks for package.yao in parent directories
func (r *PathResolver) ResolveFromPath(inputPath string) (*AgentInfo, error) {
// Get absolute path
absPath, err := filepath.Abs(inputPath)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path: %w", err)
}
// Start from the directory containing the input file
dir := filepath.Dir(absPath)
// Traverse up to find package.yao
for {
packagePath := filepath.Join(dir, "package.yao")
if _, err := os.Stat(packagePath); err == nil {
// Found package.yao
return r.loadAgentFromPath(dir, packagePath)
}
// Move to parent directory
parent := filepath.Dir(dir)
if parent == dir {
// Reached root, no package.yao found
break
}
dir = parent
}
return nil, fmt.Errorf("no package.yao found in path hierarchy of %s", inputPath)
}
// ResolveByID resolves an agent by its ID
// This would integrate with the assistant loading system
func (r *PathResolver) ResolveByID(agentID string) (*AgentInfo, error) {
// This is a placeholder - actual implementation would use assistant.Get()
// For now, return basic info
return &AgentInfo{
ID: agentID,
Name: agentID,
}, nil
}
// loadAgentFromPath loads agent information from a package.yao file
func (r *PathResolver) loadAgentFromPath(agentDir, packagePath string) (*AgentInfo, error) {
// Read package.yao
data, err := os.ReadFile(packagePath)
if err != nil {
return nil, fmt.Errorf("failed to read package.yao: %w", err)
}
// Parse package.yao
var pkg PackageYao
if err := jsoniter.Unmarshal(data, &pkg); err != nil {
return nil, fmt.Errorf("failed to parse package.yao: %w", err)
}
// Derive agent ID from directory path
agentID := deriveAgentID(agentDir)
return &AgentInfo{
ID: agentID,
Name: pkg.Name,
Description: pkg.Description,
Path: agentDir,
Connector: pkg.Connector,
Type: pkg.Type,
}, nil
}
// PackageYao represents the structure of package.yao
type PackageYao struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Connector string `json:"connector,omitempty"`
Type string `json:"type,omitempty"`
Uses map[string]interface{} `json:"uses,omitempty"`
Options map[string]interface{} `json:"options,omitempty"`
}
// deriveAgentID derives an agent ID from the directory path
// e.g., /app/assistants/workers/system/keyword -> workers.system.keyword
func deriveAgentID(dir string) string {
// Find "assistants" in path and use everything after it
parts := strings.Split(filepath.ToSlash(dir), "/")
// Look for "assistants" marker
startIdx := -1
for i, part := range parts {
if part == "assistants" {
startIdx = i + 1
break
}
}
if startIdx == -1 || startIdx >= len(parts) {
// No "assistants" found, use the last directory name
return filepath.Base(dir)
}
// Join remaining parts with dots
return strings.Join(parts[startIdx:], ".")
}
// GetOutputFormat determines the output format from file extension
func GetOutputFormat(outputPath string) OutputFormat {
ext := strings.ToLower(filepath.Ext(outputPath))
switch ext {
case ".json":
return FormatJSON
case ".html", ".htm":
return FormatHTML
case ".md", ".markdown":
return FormatMarkdown
default:
return FormatJSON // Default to JSON
}
}
// ValidateOptions validates test options
func ValidateOptions(opts *Options) error {
if opts.Input == "" {
return fmt.Errorf("input is required (-i flag)")
}
// For file mode, check input file exists
if opts.InputMode == InputModeFile {
if _, err := os.Stat(opts.Input); os.IsNotExist(err) {
return fmt.Errorf("input file not found: %s", opts.Input)
}
}
// Note: For message mode, agent can be resolved from cwd, so no validation here
// The resolver will return an error if agent cannot be found
// Validate timeout
if opts.Timeout < 0 {
return fmt.Errorf("timeout cannot be negative")
}
// Validate parallel
if opts.Parallel < 0 {
return fmt.Errorf("parallel cannot be negative")
}
return nil
}
// DefaultOptions returns options with default values
func DefaultOptions() *Options {
return &Options{
Timeout: 5 * time.Minute, // 5 minutes default timeout
Parallel: 1,
Runs: 1,
Verbose: false,
FailFast: false,
}
}
// DetectInputMode detects the input mode from the input string
// Returns InputModeFile if input looks like a file path, InputModeMessage otherwise
func DetectInputMode(input string) InputMode {
// If input ends with .jsonl or .json, treat as file
if strings.HasSuffix(input, ".jsonl") || strings.HasSuffix(input, ".json") {
return InputModeFile
}
// If input contains path separator, check if file exists
if strings.Contains(input, string(filepath.Separator)) || strings.Contains(input, "/") {
if _, err := os.Stat(input); err == nil {
return InputModeFile
}
}
// Otherwise treat as direct message
return InputModeMessage
}
// MergeOptions merges user options with defaults
func MergeOptions(opts *Options, defaults *Options) *Options {
result := *defaults
if opts.Input != "" {
result.Input = opts.Input
result.InputMode = DetectInputMode(opts.Input)
}
if opts.OutputFile != "" {
result.OutputFile = opts.OutputFile
}
if opts.AgentID != "" {
result.AgentID = opts.AgentID
}
if opts.Connector != "" {
result.Connector = opts.Connector
}
if opts.UserID != "" {
result.UserID = opts.UserID
}
if opts.TeamID != "" {
result.TeamID = opts.TeamID
}
if opts.Locale != "" {
result.Locale = opts.Locale
}
if opts.Timeout > 0 {
result.Timeout = opts.Timeout
}
if opts.Parallel > 0 {
result.Parallel = opts.Parallel
}
if opts.Runs > 0 {
result.Runs = opts.Runs
}
if opts.ReporterID != "" {
result.ReporterID = opts.ReporterID
}
if opts.Verbose {
result.Verbose = opts.Verbose
}
if opts.FailFast {
result.FailFast = opts.FailFast
}
return &result
}
// GenerateDefaultOutputPath generates the default output path based on input file
// Format: {input_directory}/output-{timestamp}.jsonl
// Timestamp format: YYYYMMDDHHMMSS
func GenerateDefaultOutputPath(inputPath string) string {
dir := filepath.Dir(inputPath)
timestamp := time.Now().Format("20060102150405")
filename := fmt.Sprintf("output-%s.jsonl", timestamp)
return filepath.Join(dir, filename)
}
// ResolveOutputPath resolves the output path based on input mode
// - File mode: generate default path in same directory as input
// - Message mode: return empty string (output to stdout)
// If outputPath is explicitly specified, always use it
func ResolveOutputPath(opts *Options) string {
if opts.OutputFile != "" {
return opts.OutputFile
}
// For file mode, generate default output path
if opts.InputMode == InputModeFile {
return GenerateDefaultOutputPath(opts.Input)
}
// For message mode, output to stdout (empty string)
return ""
}
// CreateTestCaseFromMessage creates a single test case from a direct message
func CreateTestCaseFromMessage(message string) *Case {
return &Case{
ID: "T001",
Input: message,
}
}

505
agent/test/runner.go Normal file
View file

@ -0,0 +1,505 @@
package test
import (
"bufio"
stdContext "context"
"fmt"
"os"
"sync"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
)
// Executor executes test cases against an agent
type Executor struct {
opts *Options
output *OutputWriter
resolver Resolver
loader Loader
}
// NewRunner creates a new test runner
func NewRunner(opts *Options) *Executor {
return &Executor{
opts: opts,
output: NewOutputWriter(opts.Verbose),
resolver: NewResolver(),
loader: NewLoader(),
}
}
// Run executes all test cases and returns a report
func (r *Executor) Run() (*Report, error) {
// For direct message mode, use simplified output (development mode)
if r.opts.InputMode == InputModeMessage {
return r.RunDirect()
}
return r.RunTests()
}
// RunDirect executes a single direct message and outputs the result directly
// This is optimized for development/debugging scenarios
func (r *Executor) RunDirect() (*Report, error) {
// Resolve agent
agentInfo, err := r.resolver.Resolve(r.opts)
if err != nil {
return nil, fmt.Errorf("failed to resolve agent: %w", err)
}
// Get assistant
ast, err := assistant.Get(agentInfo.ID)
if err != nil {
return nil, fmt.Errorf("failed to get assistant: %w", err)
}
// Create test case from message
tc := CreateTestCaseFromMessage(r.opts.Input)
// Create context
chatID := GenerateChatID(tc.ID, 1)
ctx := NewTestContextFromOptions(chatID, agentInfo.ID, r.opts, tc)
defer ctx.Release()
// Set connector override if specified
opts := &context.Options{}
if r.opts.Connector != "" {
opts.Connector = r.opts.Connector
}
// Create timeout context
timeout := tc.GetTimeout(r.opts.Timeout)
timeoutCtx, cancel := stdContext.WithTimeout(ctx.Context, timeout)
defer cancel()
ctx.Context = timeoutCtx
// Parse input to messages
messages, err := tc.GetMessages()
if err != nil {
return nil, fmt.Errorf("failed to parse input: %w", err)
}
// Run the agent
response, err := ast.Stream(ctx, messages, opts)
// Check for timeout
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("timeout after %s", timeout)
}
// Check for error
if err != nil {
return nil, err
}
// Extract and print output directly
output := extractOutput(response)
r.output.DirectOutput(output)
// Return minimal report (for exit code handling)
return &Report{
Summary: &Summary{
Total: 1,
Passed: 1,
AgentID: agentInfo.ID,
},
}, nil
}
// RunTests executes test cases from file and generates a report
func (r *Executor) RunTests() (*Report, error) {
startTime := time.Now()
// Print header
r.output.Header("Agent Test")
// Resolve agent
agentInfo, err := r.resolver.Resolve(r.opts)
if err != nil {
return nil, fmt.Errorf("failed to resolve agent: %w", err)
}
r.output.Info("Agent: %s", agentInfo.ID)
if r.opts.Connector != "" {
r.output.Info("Connector: %s (override)", r.opts.Connector)
} else if agentInfo.Connector != "" {
r.output.Info("Connector: %s", agentInfo.Connector)
}
// Load test cases
var testCases []*Case
// File mode - load from JSONL
testCases, err = r.loader.LoadFile(r.opts.Input)
if err != nil {
return nil, fmt.Errorf("failed to load test cases: %w", err)
}
r.output.Info("Input: %s (%d test cases)", r.opts.Input, len(testCases))
// Filter skipped tests
activeTests := FilterSkipped(testCases)
skippedCount := len(testCases) - len(activeTests)
if skippedCount > 0 {
r.output.Warning("Skipped: %d test cases", skippedCount)
}
// Print test info
if r.opts.Runs > 1 {
r.output.Info("Runs: %d per test case (stability analysis)", r.opts.Runs)
}
r.output.Info("Timeout: %s", r.opts.Timeout)
if r.opts.Parallel > 1 {
r.output.Info("Parallel: %d", r.opts.Parallel)
}
// Get assistant
ast, err := assistant.Get(agentInfo.ID)
if err != nil {
return nil, fmt.Errorf("failed to get assistant: %w", err)
}
// Create report
report := &Report{
Summary: &Summary{
Total: len(testCases),
AgentID: agentInfo.ID,
AgentPath: agentInfo.Path,
Connector: r.opts.Connector,
RunsPerCase: r.opts.Runs,
},
Environment: NewEnvironment(r.opts.UserID, r.opts.TeamID),
Metadata: &ReportMetadata{
StartedAt: startTime,
InputFile: r.opts.Input,
Options: r.opts,
},
}
// Run tests
r.output.SubHeader("Running Tests")
if r.opts.Runs > 1 {
// Stability testing mode
report.StabilityResults = r.runStabilityTests(ast, activeTests, agentInfo.ID)
r.calculateStabilitySummary(report)
} else {
// Single run mode
report.Results = r.runSingleTests(ast, activeTests, agentInfo.ID)
r.calculateSingleSummary(report)
}
// Add skipped count
report.Summary.Skipped = skippedCount
// Complete report
report.Summary.DurationMs = time.Since(startTime).Milliseconds()
report.Metadata.CompletedAt = time.Now()
// Print summary
r.output.Summary(report.Summary, time.Since(startTime))
// Write output
if r.opts.OutputFile != "" {
err = r.writeOutput(report)
if err != nil {
r.output.Error("Failed to write output: %s", err.Error())
} else {
r.output.OutputFile(r.opts.OutputFile)
}
}
// Print final result
r.output.FinalResult(!report.HasFailures())
return report, nil
}
// runSingleTests runs each test case once
func (r *Executor) runSingleTests(ast *assistant.Assistant, testCases []*Case, agentID string) []*Result {
results := make([]*Result, 0, len(testCases))
if r.opts.Parallel > 1 {
// Parallel execution
results = r.runParallel(ast, testCases, agentID)
} else {
// Sequential execution
for i, tc := range testCases {
result := r.runSingleTest(ast, tc, agentID, 1)
results = append(results, result)
// Check fail-fast
if r.opts.FailFast && result.Status != StatusPassed && result.Status != StatusSkipped {
r.output.Warning("Stopping due to --fail-fast (failed at test %d/%d)", i+1, len(testCases))
break
}
}
}
return results
}
// runParallel runs tests in parallel
func (r *Executor) runParallel(ast *assistant.Assistant, testCases []*Case, agentID string) []*Result {
results := make([]*Result, len(testCases))
var wg sync.WaitGroup
sem := make(chan struct{}, r.opts.Parallel)
for i, tc := range testCases {
wg.Add(1)
go func(idx int, testCase *Case) {
defer wg.Done()
sem <- struct{}{} // Acquire
defer func() { <-sem }() // Release
results[idx] = r.runSingleTest(ast, testCase, agentID, 1)
}(i, tc)
}
wg.Wait()
return results
}
// runSingleTest runs a single test case
func (r *Executor) runSingleTest(ast *assistant.Assistant, tc *Case, agentID string, runNum int) *Result {
// Get input summary for display
inputSummary := SummarizeInput(tc.Input, 50)
r.output.TestStart(tc.ID, inputSummary, runNum)
startTime := time.Now()
// Create result
result := &Result{
ID: tc.ID,
Input: tc.Input,
Expected: tc.Expected,
}
// Parse input to messages
messages, err := tc.GetMessages()
if err != nil {
result.Status = StatusError
result.Error = fmt.Sprintf("failed to parse input: %s", err.Error())
result.DurationMs = time.Since(startTime).Milliseconds()
r.output.TestResult(result.Status, time.Since(startTime))
r.output.TestError(result.Error)
return result
}
// Create context
chatID := GenerateChatID(tc.ID, runNum)
ctx := NewTestContextFromOptions(chatID, agentID, r.opts, tc)
defer ctx.Release()
// Set connector override if specified
opts := &context.Options{}
if r.opts.Connector != "" {
opts.Connector = r.opts.Connector
}
// Create timeout context
timeout := tc.GetTimeout(r.opts.Timeout)
timeoutCtx, cancel := stdContext.WithTimeout(ctx.Context, timeout)
defer cancel()
ctx.Context = timeoutCtx
// Run the test
response, err := ast.Stream(ctx, messages, opts)
duration := time.Since(startTime)
result.DurationMs = duration.Milliseconds()
// Check for timeout
if timeoutCtx.Err() != nil {
result.Status = StatusTimeout
result.Error = fmt.Sprintf("timeout after %s", timeout)
r.output.TestResult(result.Status, duration)
r.output.TestError(result.Error)
return result
}
// Check for error
if err != nil {
result.Status = StatusError
result.Error = err.Error()
r.output.TestResult(result.Status, duration)
r.output.TestError(result.Error)
return result
}
// Extract output
result.Output = extractOutput(response)
// Validate result if expected is set
if tc.Expected != nil {
if validateOutput(result.Output, tc.Expected) {
result.Status = StatusPassed
} else {
result.Status = StatusFailed
result.Error = "output does not match expected"
}
} else {
// No expected value - pass if no error
result.Status = StatusPassed
}
r.output.TestResult(result.Status, duration)
if result.Status == StatusFailed {
r.output.TestError(result.Error)
}
r.output.TestOutput(fmt.Sprintf("%v", result.Output))
return result
}
// runStabilityTests runs each test case multiple times for stability analysis
func (r *Executor) runStabilityTests(ast *assistant.Assistant, testCases []*Case, agentID string) []*StabilityResult {
results := make([]*StabilityResult, 0, len(testCases))
for _, tc := range testCases {
sr := &StabilityResult{
ID: tc.ID,
Input: tc.Input,
Expected: tc.Expected,
RunDetails: make([]*RunDetail, 0, r.opts.Runs),
}
// Run multiple times
for run := 1; run <= r.opts.Runs; run++ {
result := r.runSingleTest(ast, tc, agentID, run)
rd := &RunDetail{
Run: run,
Status: result.Status,
DurationMs: result.DurationMs,
Output: result.Output,
Error: result.Error,
}
sr.RunDetails = append(sr.RunDetails, rd)
}
// Calculate stability metrics
sr.CalculateStability()
// Print stability result
r.output.StabilityResult(sr)
results = append(results, sr)
// Check fail-fast
if r.opts.FailFast && !sr.Stable {
r.output.Warning("Stopping due to --fail-fast (unstable test: %s)", tc.ID)
break
}
}
return results
}
// calculateSingleSummary calculates summary for single run mode
func (r *Executor) calculateSingleSummary(report *Report) {
for _, result := range report.Results {
switch result.Status {
case StatusPassed:
report.Summary.Passed++
case StatusFailed:
report.Summary.Failed++
case StatusError:
report.Summary.Errors++
case StatusTimeout:
report.Summary.Timeouts++
}
}
}
// calculateStabilitySummary calculates summary for stability mode
func (r *Executor) calculateStabilitySummary(report *Report) {
report.Summary.TotalRuns = len(report.StabilityResults) * r.opts.Runs
var totalPassRate float64
for _, sr := range report.StabilityResults {
if sr.Stable {
report.Summary.StableCases++
report.Summary.Passed++
} else {
report.Summary.UnstableCases++
report.Summary.Failed++
}
totalPassRate += sr.PassRate
}
if len(report.StabilityResults) > 0 {
report.Summary.OverallPassRate = totalPassRate / float64(len(report.StabilityResults))
}
}
// writeOutput writes the test report to the output file
func (r *Executor) writeOutput(report *Report) error {
file, err := os.Create(r.opts.OutputFile)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer file.Close()
// Get reporter based on -r flag or file extension
reporter := GetReporterWithAgent(r.opts.ReporterID, r.opts.OutputFile, r.opts.Verbose)
// If using agent reporter, set context
if agentReporter, ok := reporter.(*AgentReporter); ok {
// Create a context for the reporter agent call
ctx := NewTestContext("reporter", r.opts.ReporterID, report.Environment)
defer ctx.Release()
agentReporter.SetContext(ctx)
}
// Write report using the reporter
return reporter.Write(report, file)
}
// writeJSONLine writes a JSON line to the writer
func writeJSONLine(writer *bufio.Writer, data interface{}) error {
line, err := jsoniter.Marshal(data)
if err != nil {
return err
}
_, err = writer.Write(line)
if err != nil {
return err
}
_, err = writer.WriteString("\n")
return err
}
// extractOutput extracts the output from the agent response
func extractOutput(response interface{}) interface{} {
if response == nil {
return nil
}
// Try to get completion content from context.Response
if resp, ok := response.(*context.Response); ok {
if resp.Completion != nil {
return resp.Completion.Content
}
if resp.Next != nil {
return resp.Next
}
}
return response
}
// validateOutput validates the actual output against expected
func validateOutput(actual, expected interface{}) bool {
// Simple JSON comparison
actualJSON, err1 := jsoniter.Marshal(actual)
expectedJSON, err2 := jsoniter.Marshal(expected)
if err1 != nil || err2 != nil {
return false
}
return string(actualJSON) == string(expectedJSON)
}

575
agent/test/types.go Normal file
View file

@ -0,0 +1,575 @@
package test
import (
"math"
"time"
"github.com/yaoapp/yao/agent/context"
)
// Status represents the status of a test case execution
type Status string
const (
// StatusPassed indicates the test passed
StatusPassed Status = "passed"
// StatusFailed indicates the test failed
StatusFailed Status = "failed"
// StatusSkipped indicates the test was skipped
StatusSkipped Status = "skipped"
// StatusError indicates a runtime error occurred
StatusError Status = "error"
// StatusTimeout indicates the test timed out
StatusTimeout Status = "timeout"
)
// OutputFormat represents the output format for test reports
type OutputFormat string
const (
// FormatJSON outputs JSON format (for CI integration)
FormatJSON OutputFormat = "json"
// FormatHTML outputs HTML format (for human review)
FormatHTML OutputFormat = "html"
// FormatMarkdown outputs Markdown format (for documentation)
FormatMarkdown OutputFormat = "markdown"
)
// StabilityClass represents the stability classification of a test case
type StabilityClass string
const (
// StabilityStable indicates 100% pass rate
StabilityStable StabilityClass = "stable"
// StabilityMostlyStable indicates 80-99% pass rate
StabilityMostlyStable StabilityClass = "mostly_stable"
// StabilityUnstable indicates 50-79% pass rate
StabilityUnstable StabilityClass = "unstable"
// StabilityHighlyUnstable indicates < 50% pass rate
StabilityHighlyUnstable StabilityClass = "highly_unstable"
)
// InputMode represents the input mode for test cases
type InputMode string
const (
// InputModeFile indicates input from a JSONL file
InputModeFile InputMode = "file"
// InputModeMessage indicates input from a direct message string
InputModeMessage InputMode = "message"
)
// Options represents the configuration options for running tests
type Options struct {
// Input/Output
// ===============================
// Input is the input source: either a file path or a direct message
Input string `json:"input"`
// InputMode is the input mode (auto-detected from Input)
InputMode InputMode `json:"input_mode"`
// OutputFile is the path to write the test report
// Format is determined by file extension (.json, .html, .md)
OutputFile string `json:"output_file"`
// Agent Selection
// ===============================
// AgentID is the explicit agent ID to test (optional)
// If not set, agent is resolved from InputFile path
AgentID string `json:"agent_id,omitempty"`
// Connector overrides the agent's default connector (optional)
Connector string `json:"connector,omitempty"`
// Test Environment
// ===============================
// UserID is the test user ID (-u flag)
UserID string `json:"user_id,omitempty"`
// TeamID is the test team ID (-t flag)
TeamID string `json:"team_id,omitempty"`
// Locale is the locale for the test context (default: "en-us")
Locale string `json:"locale,omitempty"`
// Execution
// ===============================
// Timeout is the default timeout for each test case
// Can be overridden per test case
Timeout time.Duration `json:"timeout,omitempty"`
// Parallel is the number of tests to run in parallel
// Default is 1 (sequential execution)
Parallel int `json:"parallel,omitempty"`
// Runs is the number of times to run each test case
// Default is 1. When > 1, stability metrics are collected
Runs int `json:"runs,omitempty"`
// Reporting
// ===============================
// ReporterID is the reporter agent ID for custom report generation
// If not set, default JSONL format is used
ReporterID string `json:"reporter_id,omitempty"`
// Behavior
// ===============================
// Verbose enables verbose output during test execution
Verbose bool `json:"verbose,omitempty"`
// FailFast stops execution on first failure
FailFast bool `json:"fail_fast,omitempty"`
}
// Environment configures the test execution context
type Environment struct {
// UserID is the user ID for authorized info (-u flag)
UserID string `json:"user_id"`
// TeamID is the team ID for authorized info (-t flag)
TeamID string `json:"team_id"`
// Locale is the locale (default: "en-us")
Locale string `json:"locale"`
// ClientType is the client type (default: "test")
ClientType string `json:"client_type"`
// ClientIP is the client IP (default: "127.0.0.1")
ClientIP string `json:"client_ip"`
// Referer is the request referer (default: "test")
Referer string `json:"referer"`
// Accept is the accept format (default: "standard")
Accept string `json:"accept"`
}
// NewEnvironment creates a new test environment with defaults
func NewEnvironment(userID, teamID string) *Environment {
env := &Environment{
UserID: userID,
TeamID: teamID,
Locale: "en-us",
ClientType: "test",
ClientIP: "127.0.0.1",
Referer: "test",
Accept: "standard",
}
// Apply defaults if not set
if env.UserID == "" {
env.UserID = "test-user"
}
if env.TeamID == "" {
env.TeamID = "test-team"
}
return env
}
// Case represents a single test case loaded from JSONL
type Case struct {
// ID is the unique identifier for this test case (e.g., "T001")
ID string `json:"id"`
// Input is the test input, can be:
// - string: simple text input
// - map (Message): single message with role and content
// - []map ([]Message): conversation history
Input interface{} `json:"input"`
// Expected is the expected output for validation (optional)
// If set, the actual output will be compared against this
Expected interface{} `json:"expected,omitempty"`
// Environment (per-test case, can be overridden by command line flags)
// ===============================
// UserID is the user ID for this test case (overridden by -u flag)
UserID string `json:"user,omitempty"`
// TeamID is the team ID for this test case (overridden by -t flag)
TeamID string `json:"team,omitempty"`
// Metadata contains additional metadata for the test case
Metadata map[string]interface{} `json:"metadata,omitempty"`
// Skip indicates whether to skip this test case
Skip bool `json:"skip,omitempty"`
// Timeout overrides the default timeout for this test case
// Format: "30s", "1m", "2m30s"
Timeout string `json:"timeout,omitempty"`
}
// GetEnvironment returns the effective test environment for this test case
// Priority: command line flags > test case fields > defaults
func (tc *Case) GetEnvironment(opts *Options) *Environment {
env := NewEnvironment("", "")
// Apply test case specific values
if tc.UserID != "" {
env.UserID = tc.UserID
}
if tc.TeamID != "" {
env.TeamID = tc.TeamID
}
// Apply command line overrides (highest priority)
if opts != nil {
if opts.UserID != "" {
env.UserID = opts.UserID
}
if opts.TeamID != "" {
env.TeamID = opts.TeamID
}
if opts.Locale != "" {
env.Locale = opts.Locale
}
}
return env
}
// GetMessages converts the Input to a slice of context.Message
// This handles all input formats: string, Message, []Message
func (tc *Case) GetMessages() ([]context.Message, error) {
return ParseInput(tc.Input)
}
// GetTimeout returns the timeout duration for this test case
// Returns the override timeout if set, otherwise returns the default
func (tc *Case) GetTimeout(defaultTimeout time.Duration) time.Duration {
if tc.Timeout == "" {
return defaultTimeout
}
d, err := time.ParseDuration(tc.Timeout)
if err != nil {
return defaultTimeout
}
return d
}
// Result represents the result of running a single test case
type Result struct {
// ID is the test case identifier
ID string `json:"id"`
// Status is the test execution status
Status Status `json:"status"`
// Input is the original test input (for reference in reports)
Input interface{} `json:"input"`
// Output is the actual output from the agent
Output interface{} `json:"output,omitempty"`
// Expected is the expected output (if specified in test case)
Expected interface{} `json:"expected,omitempty"`
// DurationMs is the execution duration in milliseconds
DurationMs int64 `json:"duration_ms"`
// Error contains the error message if status is failed/error/timeout
Error string `json:"error,omitempty"`
// Metadata contains additional result metadata
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// RunDetail represents the result of a single run in stability testing
type RunDetail struct {
// Run is the run number (1-based)
Run int `json:"run"`
// Status is the execution status for this run
Status Status `json:"status"`
// DurationMs is the execution duration in milliseconds
DurationMs int64 `json:"duration_ms"`
// Output is the output from this run
Output interface{} `json:"output,omitempty"`
// Error contains the error message if this run failed
Error string `json:"error,omitempty"`
}
// StabilityResult represents the stability analysis result for a test case
type StabilityResult struct {
// ID is the test case identifier
ID string `json:"id"`
// Input is the original test input
Input interface{} `json:"input"`
// Expected is the expected output (if specified)
Expected interface{} `json:"expected,omitempty"`
// Runs is the total number of runs
Runs int `json:"runs"`
// Passed is the number of runs that passed
Passed int `json:"passed"`
// Failed is the number of runs that failed
Failed int `json:"failed"`
// PassRate is the pass rate percentage (0-100)
PassRate float64 `json:"pass_rate"`
// Consistency is a measure of output consistency (0-1)
// 1.0 means all outputs are identical, lower values indicate variation
Consistency float64 `json:"consistency"`
// Stable indicates whether the test is considered stable
Stable bool `json:"stable"`
// StabilityClass is the stability classification
StabilityClass StabilityClass `json:"stability_class"`
// Timing statistics
AvgDurationMs float64 `json:"avg_duration_ms"`
MinDurationMs int64 `json:"min_duration_ms"`
MaxDurationMs int64 `json:"max_duration_ms"`
StdDeviationMs float64 `json:"std_deviation_ms"`
// RunDetails contains details for each run
RunDetails []*RunDetail `json:"run_details"`
}
// CalculateStability calculates stability metrics from run details
func (sr *StabilityResult) CalculateStability() {
if len(sr.RunDetails) == 0 {
return
}
sr.Runs = len(sr.RunDetails)
sr.Passed = 0
sr.Failed = 0
var totalDuration int64
sr.MinDurationMs = math.MaxInt64
sr.MaxDurationMs = 0
for _, rd := range sr.RunDetails {
if rd.Status == StatusPassed {
sr.Passed++
} else {
sr.Failed++
}
totalDuration += rd.DurationMs
if rd.DurationMs < sr.MinDurationMs {
sr.MinDurationMs = rd.DurationMs
}
if rd.DurationMs > sr.MaxDurationMs {
sr.MaxDurationMs = rd.DurationMs
}
}
// Calculate pass rate
sr.PassRate = float64(sr.Passed) / float64(sr.Runs) * 100
// Calculate average duration
sr.AvgDurationMs = float64(totalDuration) / float64(sr.Runs)
// Calculate standard deviation
var sumSquares float64
for _, rd := range sr.RunDetails {
diff := float64(rd.DurationMs) - sr.AvgDurationMs
sumSquares += diff * diff
}
sr.StdDeviationMs = math.Sqrt(sumSquares / float64(sr.Runs))
// Determine stability classification
sr.StabilityClass = ClassifyStability(sr.PassRate)
sr.Stable = sr.PassRate == 100
// Calculate consistency (simplified: based on pass rate)
sr.Consistency = sr.PassRate / 100
}
// ClassifyStability returns the stability classification based on pass rate
func ClassifyStability(passRate float64) StabilityClass {
switch {
case passRate == 100:
return StabilityStable
case passRate >= 80:
return StabilityMostlyStable
case passRate >= 50:
return StabilityUnstable
default:
return StabilityHighlyUnstable
}
}
// Summary contains aggregated statistics for the test run
type Summary struct {
// Total number of test cases
Total int `json:"total"`
// Passed number of test cases that passed
Passed int `json:"passed"`
// Failed number of test cases that failed
Failed int `json:"failed"`
// Skipped number of test cases that were skipped
Skipped int `json:"skipped"`
// Errors number of test cases with runtime errors
Errors int `json:"errors"`
// Timeouts number of test cases that timed out
Timeouts int `json:"timeouts"`
// DurationMs is the total execution duration in milliseconds
DurationMs int64 `json:"duration_ms"`
// AgentID is the ID of the agent being tested
AgentID string `json:"agent_id"`
// AgentPath is the file path of the agent (for path-based resolution)
AgentPath string `json:"agent_path,omitempty"`
// Connector is the connector used for the test
Connector string `json:"connector"`
// Stability metrics (when Runs > 1)
// ===============================
// RunsPerCase is the number of runs per test case
RunsPerCase int `json:"runs_per_case,omitempty"`
// TotalRuns is the total number of runs (Total * RunsPerCase)
TotalRuns int `json:"total_runs,omitempty"`
// OverallPassRate is the overall pass rate percentage
OverallPassRate float64 `json:"overall_pass_rate,omitempty"`
// StableCases is the number of cases with 100% pass rate
StableCases int `json:"stable_cases,omitempty"`
// UnstableCases is the number of cases with < 100% pass rate
UnstableCases int `json:"unstable_cases,omitempty"`
}
// Report represents the complete test report
type Report struct {
// Summary contains aggregated statistics
Summary *Summary `json:"summary"`
// Environment contains the test environment configuration
Environment *Environment `json:"environment,omitempty"`
// Results contains individual test results (for single run)
Results []*Result `json:"results,omitempty"`
// StabilityResults contains stability analysis results (for multiple runs)
StabilityResults []*StabilityResult `json:"stability_results,omitempty"`
// Metadata contains additional report metadata
Metadata *ReportMetadata `json:"metadata"`
}
// ReportMetadata contains metadata about the test report
type ReportMetadata struct {
// StartedAt is when the test run started
StartedAt time.Time `json:"started_at"`
// CompletedAt is when the test run completed
CompletedAt time.Time `json:"completed_at"`
// Version is the Yao version
Version string `json:"version"`
// InputFile is the path to the input file
InputFile string `json:"input_file"`
// OutputFile is the path to the output file
OutputFile string `json:"output_file"`
// Options contains the test options used
Options *Options `json:"options,omitempty"`
}
// HasFailures returns true if there are any failed, error, or timeout tests
func (r *Report) HasFailures() bool {
return r.Summary.Failed > 0 || r.Summary.Errors > 0 || r.Summary.Timeouts > 0
}
// PassRate returns the pass rate as a percentage (0-100)
func (r *Report) PassRate() float64 {
if r.Summary.Total == 0 {
return 0
}
return float64(r.Summary.Passed) / float64(r.Summary.Total) * 100
}
// IsStabilityTest returns true if this is a stability test (multiple runs)
func (r *Report) IsStabilityTest() bool {
return r.Summary.RunsPerCase > 1
}
// AgentInfo contains information about the agent being tested
type AgentInfo struct {
// ID is the agent identifier
ID string `json:"id"`
// Name is the human-readable name
Name string `json:"name"`
// Description is the agent description
Description string `json:"description,omitempty"`
// Path is the file system path to the agent
Path string `json:"path"`
// Connector is the default connector
Connector string `json:"connector"`
// Type is the agent type (e.g., "worker", "assistant")
Type string `json:"type,omitempty"`
}
// ReporterInput is the input passed to a custom reporter agent
type ReporterInput struct {
// Report is the test report to format
Report *Report `json:"report"`
// Format is the desired output format
Format string `json:"format"`
// Options contains additional formatting options
Options *ReporterOptions `json:"options,omitempty"`
}
// ReporterOptions contains options for custom reporter agents
type ReporterOptions struct {
// Verbose includes detailed output in the report
Verbose bool `json:"verbose,omitempty"`
// IncludeOutputs includes full outputs in the report
IncludeOutputs bool `json:"include_outputs,omitempty"`
// IncludeInputs includes full inputs in the report
IncludeInputs bool `json:"include_inputs,omitempty"`
// MaxOutputLength limits the output length in the report
MaxOutputLength int `json:"max_output_length,omitempty"`
// Theme is the report theme (for HTML reports)
Theme string `json:"theme,omitempty"`
// Title is the report title
Title string `json:"title,omitempty"`
}

373
cmd/README.md Normal file
View file

@ -0,0 +1,373 @@
# Yao CLI Commands
The Yao CLI provides a set of commands for managing, running, and testing Yao applications.
## Installation
```bash
# Build from source
go build -o yao .
# Or install via go install
go install github.com/yaoapp/yao@latest
```
## Global Flags
| Flag | Short | Description |
|------|-------|-------------|
| `--app` | `-a` | Application directory path |
| `--file` | `-f` | Application package file (.yaz) |
| `--key` | `-k` | Application license key |
## Environment Variables
| Variable | Description |
|----------|-------------|
| `YAO_ROOT` | Application root directory |
| `YAO_LANG` | Language setting (e.g., `zh-CN` for Chinese) |
## Commands
### `yao start`
Start the Yao application engine.
```bash
# Start in current directory
yao start
# Start with specific app directory
yao start -a /path/to/app
# Start in debug mode
yao start --debug
```
**Flags:**
| Flag | Description |
|------|-------------|
| `--debug` | Enable development/debug mode |
| `--disable-watching` | Disable file watching |
---
### `yao run`
Execute a Yao process.
```bash
# Run a process
yao run models.user.Find 1
# Run with JSON arguments
yao run models.user.Create '::[{"name":"John","age":30}]'
# Run in silent mode (JSON output only)
yao run -s models.user.Find 1
```
**Flags:**
| Flag | Short | Description |
|------|-------|-------------|
| `--silent` | `-s` | Silent mode - output result as JSON only |
**Argument Syntax:**
- Regular arguments: `arg1 arg2`
- JSON arguments: `'::[{"key":"value"}]'` (prefix with `::`)
- Escaped `::`: `'\::literal'`
---
### `yao migrate`
Update database schema based on model definitions.
```bash
# Migrate all models
yao migrate
# Migrate specific model
yao migrate -n user
# Force migrate in production mode
yao migrate --force
# Reset (drop and recreate) tables
yao migrate --reset
```
**Flags:**
| Flag | Short | Description |
|------|-------|-------------|
| `--name` | `-n` | Specific model name to migrate |
| `--force` | | Force migrate in production mode |
| `--reset` | | Drop tables before migration |
---
### `yao inspect`
Display application configuration.
```bash
yao inspect
```
---
### `yao version`
Show Yao version information.
```bash
# Show version
yao version
# Show all version details
yao version --all
```
**Flags:**
| Flag | Description |
|------|-------------|
| `--all` | Print all version information (Go version, commit, build time, etc.) |
---
## Agent Commands
Commands for testing and managing AI agents.
### `yao agent test`
Test an agent with input cases from a JSONL file or direct message.
```bash
# Test with direct message (development mode)
yao agent test -i "Extract keywords from: AI and machine learning" -n workers.system.keyword
# Test with JSONL file
yao agent test -i tests/inputs.jsonl
# Test with custom output file
yao agent test -i tests/inputs.jsonl -o report.html
# Test with specific connector
yao agent test -i tests/inputs.jsonl -c openai.gpt4
# Stability testing (multiple runs)
yao agent test -i tests/inputs.jsonl --runs 5
# Parallel execution
yao agent test -i tests/inputs.jsonl --parallel 4
# Verbose output
yao agent test -i tests/inputs.jsonl -v
```
**Flags:**
| Flag | Short | Description |
|------|-------|-------------|
| `--input` | `-i` | Input: JSONL file path or direct message (required) |
| `--output` | `-o` | Output file path (default: `output-{timestamp}.jsonl`) |
| `--name` | `-n` | Agent ID (default: auto-detect from path) |
| `--connector` | `-c` | Override default connector |
| `--user` | `-u` | Test user ID (default: `test-user`) |
| `--team` | `-t` | Test team ID (default: `test-team`) |
| `--reporter` | `-r` | Reporter agent ID for custom report generation |
| `--runs` | | Number of runs per test case for stability analysis (default: 1) |
| `--timeout` | | Timeout per test case (default: `5m`) |
| `--parallel` | | Number of parallel test cases (default: 1) |
| `--verbose` | `-v` | Enable verbose output |
| `--fail-fast` | | Stop on first failure |
| `--app` | `-a` | Application directory |
| `--env` | `-e` | Environment file |
**Input Modes:**
1. **Direct Message Mode**: For quick development/debugging
```bash
yao agent test -i "Hello world" -n my.agent
```
- Outputs result directly to stdout
- No report file generated
- Ideal for iterative development
2. **File Mode**: For comprehensive testing
```bash
yao agent test -i tests/inputs.jsonl
```
- Reads test cases from JSONL file
- Generates detailed report
- Supports stability analysis
**JSONL Input Format:**
```jsonl
{"id": "T001", "input": "Simple text input"}
{"id": "T002", "input": {"role": "user", "content": "Message with role"}}
{"id": "T003", "input": [{"role": "system", "content": "System prompt"}, {"role": "user", "content": "User message"}]}
{"id": "T004", "input": "Test with timeout", "timeout": "30s"}
{"id": "T005", "input": "Skip this test", "skip": true}
{"id": "T006", "input": "Test with specific user", "user": "alice", "team": "engineering"}
```
**Output Formats:**
| Extension | Format | Description |
|-----------|--------|-------------|
| `.jsonl` | JSONL | Streaming format (default) |
| `.json` | JSON | Complete structured report |
| `.md` | Markdown | Human-readable with tables |
| `.html` | HTML | Interactive web report |
**Agent Resolution:**
The agent is resolved in the following priority order:
1. Explicit `-n` flag: `yao agent test -i msg -n my.agent`
2. `YAO_ROOT` environment variable
3. Auto-detect from input file path (traverses up to find `package.yao`)
4. Auto-detect from current working directory
---
## SUI Commands
SUI (Serverless UI) template engine commands.
### `yao sui watch`
Auto-build templates when files change.
```bash
yao sui watch <sui-id> <template-name> [data]
# Example
yao sui watch default index '::{}'
```
### `yao sui build`
Build a template.
```bash
yao sui build <sui-id> <template-name> [data]
# Example
yao sui build default index '::{}'
# Debug mode
yao sui build default index '::{}' --debug
```
### `yao sui trans`
Translate template content.
```bash
yao sui trans <sui-id> <template-name>
# With specific locales
yao sui trans default index -l "en-US,zh-CN,ja-JP"
```
**SUI Flags:**
| Flag | Short | Description |
|------|-------|-------------|
| `--data` | `-d` | Session data as JSON (prefix with `::`) |
| `--debug` | `-D` | Enable debug mode |
| `--locales` | `-l` | Locales for translation (comma-separated) |
---
## Examples
### Development Workflow
```bash
# Start development server
yao start --debug
# Run a process
yao run scripts.test.Hello "World"
# Test an agent interactively
yao agent test -i "What is the weather today?" -n assistant.weather
# Watch and auto-build templates
yao sui watch default home
```
### Testing Workflow
```bash
# Run comprehensive agent tests
yao agent test -i tests/inputs.jsonl -o report.html -v
# Stability analysis (run each test 10 times)
yao agent test -i tests/inputs.jsonl --runs 10 -o stability-report.json
# Parallel testing with timeout
yao agent test -i tests/inputs.jsonl --parallel 4 --timeout 2m
# CI/CD integration
yao agent test -i tests/inputs.jsonl -o results.jsonl && echo "Tests passed"
```
### Database Migration
```bash
# Migrate all models
yao migrate
# Migrate specific model with reset
yao migrate -n user --reset --force
```
---
## Exit Codes
| Code | Description |
|------|-------------|
| 0 | Success |
| 1 | Error or test failure |
---
## Directory Structure
```
myapp/
├── app.yao # Application configuration
├── .env # Environment variables
├── models/ # Data models
├── apis/ # API definitions
├── flows/ # Business flows
├── scripts/ # JavaScript/TypeScript scripts
├── assistants/ # AI agents
│ └── my-agent/
│ ├── package.yao # Agent configuration
│ ├── prompts.yml # Agent prompts
│ └── tests/
│ └── inputs.jsonl # Test cases
└── public/ # Static files
```
---
## See Also
- [Yao Documentation](https://yaoapps.com/docs)
- [Agent Test Design](../agent/test/DESIGN.md)
- [SUI Documentation](https://yaoapps.com/docs/sui)

72
cmd/agent/agent.go Normal file
View file

@ -0,0 +1,72 @@
package agent
import (
"os"
"path/filepath"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/yao/config"
)
var appPath string
var envFile string
var langs = map[string]string{
"Test an agent with input cases": "使用测试用例测试智能体",
"Test an agent with input cases from JSONL file or direct message": "使用 JSONL 文件或直接消息测试智能体",
"Application directory": "应用目录",
"Environment file": "环境变量文件",
"Input: JSONL file path or message (required)": "输入: JSONL 文件路径或消息 (必需)",
"Path to output file (default: output-{timestamp}.jsonl)": "输出文件路径 (默认: output-{timestamp}.jsonl)",
"Explicit agent ID (default: auto-detect)": "指定智能体 ID (默认: 自动检测)",
"Override connector": "覆盖连接器",
"Test user ID (default: test-user)": "测试用户 ID (默认: test-user)",
"Test team ID (default: test-team)": "测试团队 ID (默认: test-team)",
"Reporter agent ID for custom report": "自定义报告生成器智能体 ID",
"Number of runs for stability analysis": "稳定性分析的运行次数",
"Default timeout per test case": "每个测试用例的默认超时时间",
"Number of parallel test cases": "并行测试用例数",
"Verbose output": "详细输出",
"Stop on first failure": "遇到第一个失败时停止",
"Error: input is required (-i flag)": "错误: 需要输入 (-i 参数)",
"Error: failed to get current directory": "错误: 获取当前目录失败",
"Error: agent (-n) is required when using direct message input and not in an agent directory": "错误: 使用直接消息输入且不在智能体目录时需要指定 -n 参数",
"Hint: Make sure you're in a Yao application directory or specify --app flag": "提示: 确保在 Yao 应用目录中或使用 --app 参数指定",
"Error: invalid timeout format": "错误: 无效的超时格式",
}
// L Language switch
func L(words string) string {
var lang = os.Getenv("YAO_LANG")
if lang == "" {
return words
}
if trans, has := langs[words]; has {
return trans
}
return words
}
// Boot sets the configuration
func Boot() {
root := config.Conf.Root
if appPath != "" {
r, err := filepath.Abs(appPath)
if err != nil {
exception.New("Root error %s", 500, err.Error()).Throw()
}
root = r
}
if envFile != "" {
config.Conf = config.LoadFrom(envFile)
} else {
config.Conf = config.LoadFrom(filepath.Join(root, ".env"))
}
if config.Conf.Mode == "production" {
config.Production()
} else if config.Conf.Mode == "development" {
config.Development()
}
}

244
cmd/agent/test.go Normal file
View file

@ -0,0 +1,244 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/yaoapp/gou/plugin"
"github.com/yaoapp/yao/agent"
"github.com/yaoapp/yao/agent/test"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/engine"
"github.com/yaoapp/yao/kb"
"github.com/yaoapp/yao/share"
)
// Test command flags
var (
testInput string
testOutput string
testAgent string
testConnector string
testUser string
testTeam string
testReporter string
testRuns int
testTimeout string
testParallel int
testVerbose bool
testFailFast bool
)
// TestCmd is the agent test command
var TestCmd = &cobra.Command{
Use: "test",
Short: L("Test an agent with input cases"),
Long: L("Test an agent with input cases from JSONL file or direct message"),
Run: func(cmd *cobra.Command, args []string) {
defer share.SessionStop()
defer plugin.KillAll()
// Validate input
if testInput == "" {
color.Red(L("Error: input is required (-i flag)") + "\n")
os.Exit(1)
}
// Detect input mode
inputMode := test.DetectInputMode(testInput)
// For message mode, agent must be specified or resolvable from cwd
if inputMode == test.InputModeMessage && testAgent == "" {
// Try to find app root from current directory
cwd, err := os.Getwd()
if err != nil {
color.Red(L("Error: failed to get current directory")+": %s\n", err.Error())
os.Exit(1)
}
// Try to find package.yao from cwd
resolver := test.NewResolver()
_, err = resolver.ResolveFromPath(cwd)
if err != nil {
color.Red(L("Error: agent (-n) is required when using direct message input and not in an agent directory") + "\n")
os.Exit(1)
}
}
// Find app root directory
// Priority: -a flag > YAO_ROOT env > auto-detect from path
var err error
if appPath == "" {
// Check YAO_ROOT environment variable
if yaoRoot := os.Getenv("YAO_ROOT"); yaoRoot != "" {
appPath = yaoRoot
}
}
if appPath == "" {
// Auto-detect from path
if inputMode == test.InputModeFile {
// For file mode, find app root from input file path
appPath, err = findAppRoot(testInput)
} else {
// For message mode, find app root from current directory
cwd, _ := os.Getwd()
appPath, err = findAppRoot(cwd)
}
if err != nil {
color.Red("Error: %s\n", err.Error())
color.Yellow(L("Hint: Make sure you're in a Yao application directory or specify --app flag") + "\n")
os.Exit(1)
}
}
// Boot the application
Boot()
// Set Runtime Mode
config.Conf.Runtime.Mode = "standard"
cfg := config.Conf
cfg.Session.IsCLI = true
// Load engine
_, err = engine.Load(cfg, engine.LoadOption{Action: "agent-test"})
if err != nil {
color.Red("Engine: %s\n", err.Error())
os.Exit(1)
}
// Load KB (required for agent KB features)
_, err = kb.Load(cfg)
if err != nil {
color.Red("KB: %s\n", err.Error())
os.Exit(1)
}
// Load agent
err = agent.Load(cfg)
if err != nil {
color.Red("Agent: %s\n", err.Error())
os.Exit(1)
}
// Parse timeout
timeout := 5 * time.Minute
if testTimeout != "" {
d, err := time.ParseDuration(testTimeout)
if err != nil {
color.Red(L("Error: invalid timeout format")+": %s\n", testTimeout)
os.Exit(1)
}
timeout = d
}
// Build test options
opts := &test.Options{
Input: testInput,
InputMode: inputMode,
OutputFile: testOutput,
AgentID: testAgent,
Connector: testConnector,
UserID: testUser,
TeamID: testTeam,
ReporterID: testReporter,
Runs: testRuns,
Timeout: timeout,
Parallel: testParallel,
Verbose: testVerbose,
FailFast: testFailFast,
}
// Merge with defaults
opts = test.MergeOptions(opts, test.DefaultOptions())
// Resolve output path (only for file mode, direct message mode outputs to stdout)
if inputMode == test.InputModeFile {
opts.OutputFile = test.ResolveOutputPath(opts)
}
// Run tests
runner := test.NewRunner(opts)
report, err := runner.Run()
if err != nil {
color.Red("Error: %s\n", err.Error())
os.Exit(1)
}
// Exit with appropriate code
if report.HasFailures() {
os.Exit(1)
}
},
}
// findAppRoot finds the Yao application root directory by looking for app.yao
// It traverses up from the given path until it finds app.yao or reaches the filesystem root
func findAppRoot(startPath string) (string, error) {
// Get absolute path
absPath, err := filepath.Abs(startPath)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
// If it's a file, start from its directory
info, err := os.Stat(absPath)
if err != nil {
return "", fmt.Errorf("path not found: %s", absPath)
}
var dir string
if info.IsDir() {
dir = absPath
} else {
dir = filepath.Dir(absPath)
}
// Traverse up to find app.yao
for {
// Check for app.yao, app.json, or app.jsonc
for _, appFile := range []string{"app.yao", "app.json", "app.jsonc"} {
appFilePath := filepath.Join(dir, appFile)
if _, err := os.Stat(appFilePath); err == nil {
return dir, nil
}
}
// Move to parent directory
parent := filepath.Dir(dir)
if parent == dir {
// Reached root, no app.yao found
break
}
dir = parent
}
return "", fmt.Errorf("no app.yao found in path hierarchy of %s", startPath)
}
func init() {
// Test command flags
TestCmd.Flags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
TestCmd.Flags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
TestCmd.Flags().StringVarP(&testInput, "input", "i", "", L("Input: JSONL file path or message (required)"))
TestCmd.Flags().StringVarP(&testOutput, "output", "o", "", L("Path to output file (default: output-{timestamp}.jsonl)"))
TestCmd.Flags().StringVarP(&testAgent, "name", "n", "", L("Explicit agent ID (default: auto-detect)"))
TestCmd.Flags().StringVarP(&testConnector, "connector", "c", "", L("Override connector"))
TestCmd.Flags().StringVarP(&testUser, "user", "u", "", L("Test user ID (default: test-user)"))
TestCmd.Flags().StringVarP(&testTeam, "team", "t", "", L("Test team ID (default: test-team)"))
TestCmd.Flags().StringVarP(&testReporter, "reporter", "r", "", L("Reporter agent ID for custom report"))
TestCmd.Flags().IntVar(&testRuns, "runs", 1, L("Number of runs for stability analysis"))
TestCmd.Flags().StringVar(&testTimeout, "timeout", "5m", L("Default timeout per test case"))
TestCmd.Flags().IntVar(&testParallel, "parallel", 1, L("Number of parallel test cases"))
TestCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, L("Verbose output"))
TestCmd.Flags().BoolVar(&testFailFast, "fail-fast", false, L("Stop on first failure"))
// Mark input as required
TestCmd.MarkFlagRequired("input")
}

View file

@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/yao/cmd/agent"
"github.com/yaoapp/yao/cmd/sui"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/pack"
@ -84,7 +85,6 @@ var rootCmd = &cobra.Command{
Use: share.BUILDNAME,
Short: "Yao App Engine",
Long: `Yao App Engine`,
Args: cobra.MinimumNArgs(1),
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
@ -93,10 +93,14 @@ var rootCmd = &cobra.Command{
switch args[0] {
case "fuxi":
fuxi()
return
}
fmt.Fprintln(os.Stderr, L("One or more arguments are not correct"), args)
os.Exit(1)
return
}
fmt.Fprintln(os.Stderr, L("One or more arguments are not correct"), args)
os.Exit(1)
// No arguments - show help
cmd.Help()
},
}
@ -104,13 +108,11 @@ var studioCmd = &cobra.Command{
Use: "studio",
Short: "Yao Studio CLI",
Long: `Yao Studio CLI`,
Args: cobra.MinimumNArgs(1),
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
Run: func(cmd *cobra.Command, args []string) {
fmt.Fprintln(os.Stderr, L("One or more arguments are not correct"), args)
os.Exit(1)
cmd.Help()
},
}
@ -118,13 +120,23 @@ var suiCmd = &cobra.Command{
Use: "sui",
Short: L("SUI Template Engine"),
Long: L("SUI Template Engine"),
Args: cobra.MinimumNArgs(1),
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
Run: func(cmd *cobra.Command, args []string) {
fmt.Fprintln(os.Stderr, L("One or more arguments are not correct"), args)
os.Exit(1)
cmd.Help()
},
}
var agentCmd = &cobra.Command{
Use: "agent",
Short: L("Agent commands"),
Long: L("Agent commands for testing and management"),
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
}
@ -138,6 +150,9 @@ func init() {
suiCmd.AddCommand(sui.BuildCmd)
suiCmd.AddCommand(sui.TransCmd)
// Agent
agentCmd.AddCommand(agent.TestCmd)
rootCmd.AddCommand(
versionCmd,
migrateCmd,
@ -152,6 +167,7 @@ func init() {
// packCmd,
// studioCmd,
suiCmd,
agentCmd,
// upgradeCmd,
)
// rootCmd.SetHelpCommand(helpCmd)

View file

@ -3,6 +3,8 @@ package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/fatih/color"
@ -40,6 +42,16 @@ var runCmd = &cobra.Command{
}
}()
// Auto-detect app root if not specified
if appPath == "" {
cwd, err := os.Getwd()
if err == nil {
if root, err := findAppRootFromPath(cwd); err == nil {
appPath = root
}
}
}
Boot()
// Set Runtime Mode
@ -174,3 +186,47 @@ var runCmd = &cobra.Command{
func init() {
runCmd.PersistentFlags().BoolVarP(&runSilent, "silent", "s", false, L("Silent mode"))
}
// findAppRootFromPath finds the Yao application root directory by looking for app.yao
// It traverses up from the given path until it finds app.yao or reaches the filesystem root
func findAppRootFromPath(startPath string) (string, error) {
// Get absolute path
absPath, err := filepath.Abs(startPath)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
// If it's a file, start from its directory
info, err := os.Stat(absPath)
if err != nil {
return "", fmt.Errorf("path not found: %s", absPath)
}
var dir string
if info.IsDir() {
dir = absPath
} else {
dir = filepath.Dir(absPath)
}
// Traverse up to find app.yao
for {
// Check for app.yao, app.json, or app.jsonc
for _, appFile := range []string{"app.yao", "app.json", "app.jsonc"} {
appFilePath := filepath.Join(dir, appFile)
if _, err := os.Stat(appFilePath); err == nil {
return dir, nil
}
}
// Move to parent directory
parent := filepath.Dir(dir)
if parent == dir {
// Reached root, no app.yao found
break
}
dir = parent
}
return "", fmt.Errorf("no app.yao found in path hierarchy of %s", startPath)
}

View file

@ -2,7 +2,7 @@ package openapi
import (
"errors"
"fmt"
// "fmt"
"path/filepath"
"strings"
"time"
@ -135,10 +135,10 @@ func (config *Config) UnmarshalJSON(data []byte) error {
Features: tempConfig.OAuth.Features,
}
fmt.Println("----debug----")
fmt.Println("tempConfig.OAuth.IssuerURL", tempConfig.OAuth.IssuerURL)
fmt.Println("config.OAuth.IssuerURL", config.OAuth.IssuerURL)
fmt.Println("----debug----")
// fmt.Println("----debug----")
// fmt.Println("tempConfig.OAuth.IssuerURL", tempConfig.OAuth.IssuerURL)
// fmt.Println("config.OAuth.IssuerURL", config.OAuth.IssuerURL)
// fmt.Println("----debug----")
// Convert signing config with duration parsing
config.OAuth.Signing = types.SigningConfig{