Implement Dynamic Testing Features in Agent Test Framework

- Introduced dynamic testing capabilities, allowing for multi-turn conversations with checkpoints through the new `DynamicRunner`.
- Enhanced the `runSingleTest` method to support dynamic mode, including the execution of before/after scripts and detailed output for dynamic test results.
- Added new output methods in `output.go` for dynamic test start, turns, checkpoints, and results, improving console feedback during testing.
- Updated `DESIGN_V2.md` and `TODO_V2.md` to reflect the new dynamic mode features, including simulator configurations and checkpoint definitions.
- Revised the `Case` struct in `types.go` to include fields for dynamic testing, such as `Simulator`, `Checkpoints`, and `MaxTurns`.
This commit is contained in:
Max 2025-12-26 11:17:54 +08:00
parent 6ea982f027
commit 8de302d4e8
10 changed files with 1338 additions and 31 deletions

View file

@ -633,20 +633,25 @@ options := &context.Options{
### Flags Reference
| Flag | Long | Description |
| ---- | ------------- | ---------------------------------------------------------- |
| `-i` | `--input` | Input source: file path, message, or `type:id` reference |
| `-n` | `--name` | Target agent ID (the agent being tested) |
| `-o` | `--output` | Output file path for results |
| `-c` | `--connector` | Override connector for the target agent |
| `-v` | `--verbose` | Verbose output |
| | `--simulator` | Default simulator agent ID |
| | `--before` | Global before script (e.g., `env_test.BeforeAll`) |
| | `--after` | Global after script (e.g., `env_test.AfterAll`) |
| | `--timeout` | Timeout per test case (default: 5m) |
| | `--parallel` | Number of parallel test cases |
| | `--fail-fast` | Stop on first failure |
| | `--dry-run` | Generate/parse tests without running |
| Flag | Long | Description |
| ---- | ------------- | ------------------------------------------------------------ |
| `-i` | `--input` | Input source: file path, message, or `agents:`/`scripts:` ID |
| `-n` | `--name` | Target agent ID (the agent being tested) |
| `-o` | `--output` | Output file path for results |
| `-c` | `--connector` | Override connector for the target agent |
| `-u` | `--user` | Test user ID (default: test-user) |
| `-t` | `--team` | Test team ID (default: test-team) |
| `-v` | `--verbose` | Verbose output |
| | `--ctx` | Path to context JSON file for custom authorization |
| | `--simulator` | Default simulator agent ID for dynamic mode |
| | `--before` | Global before script (e.g., `env_test.BeforeAll`) |
| | `--after` | Global after script (e.g., `env_test.AfterAll`) |
| | `--timeout` | Timeout per test case (default: 5m) |
| | `--parallel` | Number of parallel test cases |
| | `--runs` | Number of runs for stability analysis |
| | `--run` | Regex pattern to filter which tests to run |
| | `--fail-fast` | Stop on first failure |
| | `--dry-run` | Generate/parse tests without running |
### Examples
@ -1000,7 +1005,8 @@ Existing single-turn tests work unchanged:
| Agent-driven assertions | ✅ Done | `type: "agent"` + `t.assert.Agent()` JSAPI |
| Agent-driven input | ✅ Done | `-i agents:xxx` for test generation |
| Dry-run mode | ✅ Done | `--dry-run` to preview generated tests |
| Dynamic mode | 🔲 Planned | Simulator + Checkpoints |
| Dynamic mode | ✅ Done | Simulator + Checkpoints |
| Console output | ✅ Done | Dynamic mode tree output, checkpoint display |
## Open Questions

View file

@ -65,7 +65,7 @@
- [x] 创建单元测试 `input_source_test.go`
## Phase 4: Dynamic Mode (Simulator + Checkpoints)
## Phase 4: Dynamic Mode (Simulator + Checkpoints)
**新增文件**: `dynamic_runner.go`, `dynamic_types.go`
@ -73,31 +73,31 @@
**准备工作**:
- [ ] 创建 simulator agent (`yao-dev-app/assistants/tests/simulator-agent`)
- [ ] 编写 simulator agent 的 prompts.yml (模拟用户行为)
- [x] 创建 simulator agent (`yao-dev-app/assistants/tests/simulator-agent`)
- [x] 编写 simulator agent 的 prompts.yml (模拟用户行为)
**实现**:
- [ ] `types.go`: 添加 `Simulator`, `Checkpoints` 字段到 `Case`
- [ ] `dynamic_types.go`: 定义 `Checkpoint`, `DynamicResult` 等类型
- [ ] `dynamic_runner.go`: 实现 `DynamicRunner`
- [ ] `dynamic_runner.go`: 实现 checkpoint 匹配逻辑
- [ ] `dynamic_runner.go`: 实现终止条件判断
- [ ] `runner.go`: 在 `runSingleTest` 判断并调用动态模式
- [x] `types.go`: 添加 `Simulator`, `Checkpoints` 字段到 `Case`
- [x] `dynamic_types.go`: 定义 `Checkpoint`, `DynamicResult` 等类型
- [x] `dynamic_runner.go`: 实现 `DynamicRunner`
- [x] `dynamic_runner.go`: 实现 checkpoint 匹配逻辑
- [x] `dynamic_runner.go`: 实现终止条件判断
- [x] `runner.go`: 在 `runSingleTest` 判断并调用动态模式
**测试**:
- [ ] 创建单元测试 `dynamic_runner_test.go`
- [x] 创建单元测试 `dynamic_runner_test.go`
## Phase 5: Console Output Optimization
## Phase 5: Console Output Optimization
**修改文件**: `output.go`
- [ ] `output.go`: 添加 `DynamicTestStart` 方法
- [ ] `output.go`: 添加 `DynamicTurn` 方法
- [ ] `output.go`: 添加 `DynamicTestResult` 方法
- [ ] `output.go`: 添加 `ParallelResults` 方法
- [ ] 测试并行模式输出效果
- [x] `output.go`: 添加 `DynamicTestStart` 方法
- [x] `output.go`: 添加 `DynamicTurn` 方法
- [x] `output.go`: 添加 `DynamicCheckpoint` 方法
- [x] `output.go`: 添加 `DynamicTestResult` 方法
- [x] 动态模式输出效果已验证
## Already Implemented ✅
@ -111,6 +111,9 @@
- [x] Agent-driven assertions (Phase 2)
- [x] Agent-driven input (Phase 3)
- [x] `--dry-run` flag
- [x] Dynamic mode (Phase 4)
- [x] `--simulator` flag
- [x] Console output optimization (Phase 5)
## Open Questions

View file

@ -0,0 +1,250 @@
package test_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent"
agenttest "github.com/yaoapp/yao/agent/test"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
// TestDynamicRunner_CoffeeOrder tests a complete dynamic mode flow:
// Simulator acts as a customer ordering coffee, agent handles the order
func TestDynamicRunner_CoffeeOrder(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agents
err := agent.Load(config.Conf)
require.NoError(t, err, "Failed to load agents")
// Create a temporary JSONL file with a dynamic test case
tmpDir := t.TempDir()
inputFile := filepath.Join(tmpDir, "dynamic-inputs.jsonl")
// Dynamic test case: customer ordering coffee (JSONL must be single line)
testCase := `{"id": "coffee-order-flow", "name": "Complete Coffee Order", "input": "Hi, I would like to order a coffee please", "simulator": {"use": "tests.simulator-agent", "options": {"metadata": {"persona": "A customer who wants to order a medium latte with oat milk", "goal": "Successfully complete a coffee order"}}}, "checkpoints": [{"id": "greeting", "description": "Agent greets and asks for order", "assert": {"type": "regex", "value": "(?i)(order|like|help)"}}, {"id": "ask_size", "description": "Agent asks for size", "after": ["greeting"], "assert": {"type": "regex", "value": "(?i)size"}}, {"id": "confirm_order", "description": "Agent confirms the order", "after": ["ask_size"], "assert": {"type": "regex", "value": "(?i)confirm"}}], "max_turns": 8}`
err = os.WriteFile(inputFile, []byte(testCase), 0644)
require.NoError(t, err, "Failed to write test file")
// Run dynamic test
opts := &agenttest.Options{
Input: inputFile,
AgentID: "tests.dynamic-test-agent",
Verbose: true,
InputMode: agenttest.InputModeFile,
}
opts = agenttest.MergeOptions(opts, agenttest.DefaultOptions())
runner := agenttest.NewRunner(opts)
report, err := runner.Run()
require.NoError(t, err, "Runner should not return error")
require.NotNil(t, report, "Report should not be nil")
require.NotNil(t, report.Summary, "Summary should not be nil")
// Log results
t.Logf("Total: %d, Passed: %d, Failed: %d",
report.Summary.Total, report.Summary.Passed, report.Summary.Failed)
// Check results
if len(report.Results) > 0 {
result := report.Results[0]
t.Logf("Test [%s] Status: %s", result.ID, result.Status)
// Check metadata for dynamic mode info
if result.Metadata != nil {
if mode, ok := result.Metadata["mode"].(string); ok {
assert.Equal(t, "dynamic", mode, "Should be dynamic mode")
}
if turns, ok := result.Metadata["total_turns"].(int); ok {
t.Logf("Total turns: %d", turns)
}
}
if result.Error != "" {
t.Logf("Error: %s", result.Error)
}
}
}
// TestDynamicRunner_WithInitialInput tests dynamic mode with initial user input
func TestDynamicRunner_WithInitialInput(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agents
err := agent.Load(config.Conf)
require.NoError(t, err, "Failed to load agents")
// Create a test case with initial input
tmpDir := t.TempDir()
inputFile := filepath.Join(tmpDir, "dynamic-inputs.jsonl")
// Start with user's first message (JSONL must be single line)
testCase := `{"id": "coffee-with-initial", "name": "Coffee Order with Initial Message", "input": "Hi, I want to order a coffee", "simulator": {"use": "tests.simulator-agent", "options": {"metadata": {"persona": "Customer ordering a large cappuccino", "goal": "Complete the coffee order"}}}, "checkpoints": [{"id": "acknowledge", "description": "Agent acknowledges the order request", "assert": {"type": "regex", "value": "(?i)(coffee|order|help)"}}, {"id": "ask_details", "description": "Agent asks for more details", "after": ["acknowledge"], "assert": {"type": "regex", "value": "(?i)(size|type|what)"}}], "max_turns": 5}`
err = os.WriteFile(inputFile, []byte(testCase), 0644)
require.NoError(t, err, "Failed to write test file")
// Run test
opts := &agenttest.Options{
Input: inputFile,
AgentID: "tests.dynamic-test-agent",
Verbose: true,
InputMode: agenttest.InputModeFile,
}
opts = agenttest.MergeOptions(opts, agenttest.DefaultOptions())
runner := agenttest.NewRunner(opts)
report, err := runner.Run()
require.NoError(t, err, "Runner should not return error")
require.NotNil(t, report, "Report should not be nil")
t.Logf("Total: %d, Passed: %d, Failed: %d",
report.Summary.Total, report.Summary.Passed, report.Summary.Failed)
}
// TestDynamicRunner_OptionalCheckpoint tests optional checkpoint behavior
func TestDynamicRunner_OptionalCheckpoint(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agents
err := agent.Load(config.Conf)
require.NoError(t, err, "Failed to load agents")
tmpDir := t.TempDir()
inputFile := filepath.Join(tmpDir, "dynamic-inputs.jsonl")
// Test with one required and one optional checkpoint (JSONL must be single line)
testCase := `{"id": "optional-checkpoint-test", "name": "Test with Optional Checkpoint", "input": "Hello", "simulator": {"use": "tests.simulator-agent", "options": {"metadata": {"persona": "Simple customer", "goal": "Get a greeting response"}}}, "checkpoints": [{"id": "greeting_response", "description": "Agent responds with greeting", "assert": {"type": "regex", "value": "(?i)(hello|hi|help)"}}, {"id": "special_offer", "description": "Agent mentions special offer (optional)", "required": false, "assert": {"type": "contains", "value": "special offer"}}], "max_turns": 3}`
err = os.WriteFile(inputFile, []byte(testCase), 0644)
require.NoError(t, err, "Failed to write test file")
// Run test
opts := &agenttest.Options{
Input: inputFile,
AgentID: "tests.dynamic-test-agent",
Verbose: true,
InputMode: agenttest.InputModeFile,
}
opts = agenttest.MergeOptions(opts, agenttest.DefaultOptions())
runner := agenttest.NewRunner(opts)
report, err := runner.Run()
require.NoError(t, err, "Runner should not return error")
require.NotNil(t, report, "Report should not be nil")
// Test should pass even if optional checkpoint is not reached
t.Logf("Total: %d, Passed: %d, Failed: %d",
report.Summary.Total, report.Summary.Passed, report.Summary.Failed)
// If the required checkpoint is reached, the test should pass
if len(report.Results) > 0 && report.Results[0].Metadata != nil {
if checkpoints, ok := report.Results[0].Metadata["checkpoints"].(map[string]*agenttest.CheckpointResult); ok {
for id, cp := range checkpoints {
t.Logf("Checkpoint [%s]: reached=%v, required=%v", id, cp.Reached, cp.Required)
}
}
}
}
// TestDynamicRunner_MaxTurnsExceeded tests behavior when max turns is exceeded
func TestDynamicRunner_MaxTurnsExceeded(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agents
err := agent.Load(config.Conf)
require.NoError(t, err, "Failed to load agents")
tmpDir := t.TempDir()
inputFile := filepath.Join(tmpDir, "dynamic-inputs.jsonl")
// Test case with impossible checkpoint and low max_turns (JSONL must be single line)
testCase := `{"id": "max-turns-test", "name": "Test Max Turns Exceeded", "input": "Hello", "simulator": {"use": "tests.simulator-agent", "options": {"metadata": {"persona": "Persistent customer", "goal": "Keep talking"}}}, "checkpoints": [{"id": "impossible", "description": "This checkpoint will never be reached", "assert": {"type": "contains", "value": "IMPOSSIBLE_STRING_NEVER_APPEARS_12345"}}], "max_turns": 2}`
err = os.WriteFile(inputFile, []byte(testCase), 0644)
require.NoError(t, err, "Failed to write test file")
// Run test
opts := &agenttest.Options{
Input: inputFile,
AgentID: "tests.dynamic-test-agent",
Verbose: true,
InputMode: agenttest.InputModeFile,
}
opts = agenttest.MergeOptions(opts, agenttest.DefaultOptions())
runner := agenttest.NewRunner(opts)
report, err := runner.Run()
require.NoError(t, err, "Runner should not return error")
require.NotNil(t, report, "Report should not be nil")
// Test should fail due to max turns exceeded
assert.Equal(t, 1, report.Summary.Failed, "Test should fail")
if len(report.Results) > 0 {
result := report.Results[0]
assert.Equal(t, agenttest.StatusFailed, result.Status, "Status should be failed")
assert.Contains(t, result.Error, "max turns", "Error should mention max turns")
t.Logf("Error (expected): %s", result.Error)
}
}
// TestDynamicRunner_CheckpointOrdering tests that checkpoint ordering is enforced
func TestDynamicRunner_CheckpointOrderingEnforced(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agents
err := agent.Load(config.Conf)
require.NoError(t, err, "Failed to load agents")
tmpDir := t.TempDir()
inputFile := filepath.Join(tmpDir, "dynamic-inputs.jsonl")
// Test case with ordered checkpoints (JSONL must be single line)
testCase := `{"id": "ordered-checkpoints", "name": "Test Checkpoint Ordering", "input": "I want to order coffee", "simulator": {"use": "tests.simulator-agent", "options": {"metadata": {"persona": "Customer ordering step by step", "goal": "Complete coffee order following the flow"}}}, "checkpoints": [{"id": "step1_greeting", "description": "Agent greets", "assert": {"type": "regex", "value": "(?i)(hello|hi|help|order)"}}, {"id": "step2_size", "description": "Agent asks about size", "after": ["step1_greeting"], "assert": {"type": "regex", "value": "(?i)size"}}, {"id": "step3_confirm", "description": "Agent confirms", "after": ["step2_size"], "assert": {"type": "regex", "value": "(?i)confirm"}}], "max_turns": 10}`
err = os.WriteFile(inputFile, []byte(testCase), 0644)
require.NoError(t, err, "Failed to write test file")
// Run test
opts := &agenttest.Options{
Input: inputFile,
AgentID: "tests.dynamic-test-agent",
Verbose: true,
InputMode: agenttest.InputModeFile,
}
opts = agenttest.MergeOptions(opts, agenttest.DefaultOptions())
runner := agenttest.NewRunner(opts)
report, err := runner.Run()
require.NoError(t, err, "Runner should not return error")
require.NotNil(t, report, "Report should not be nil")
t.Logf("Total: %d, Passed: %d, Failed: %d",
report.Summary.Total, report.Summary.Passed, report.Summary.Failed)
// Log checkpoint order
if len(report.Results) > 0 && report.Results[0].Metadata != nil {
if checkpoints, ok := report.Results[0].Metadata["checkpoints"].(map[string]*agenttest.CheckpointResult); ok {
for id, cp := range checkpoints {
t.Logf("Checkpoint [%s]: reached=%v, at_turn=%d", id, cp.Reached, cp.ReachedAtTurn)
}
}
}
}

View file

@ -0,0 +1,413 @@
package test
import (
"fmt"
"time"
jsoniter "github.com/json-iterator/go"
goutext "github.com/yaoapp/gou/text"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
)
// DynamicRunner handles dynamic (simulator-driven) test execution
type DynamicRunner struct {
opts *Options
output *OutputWriter
asserter *Asserter
}
// NewDynamicRunner creates a new dynamic runner
func NewDynamicRunner(opts *Options) *DynamicRunner {
return &DynamicRunner{
opts: opts,
output: NewOutputWriter(opts.Verbose),
asserter: NewAsserter(),
}
}
// RunDynamic executes a dynamic test case
func (r *DynamicRunner) RunDynamic(ast *assistant.Assistant, tc *Case, agentID string) *DynamicResult {
startTime := time.Now()
result := &DynamicResult{
ID: tc.ID,
Turns: make([]*TurnResult, 0),
Checkpoints: make(map[string]*CheckpointResult),
}
// Initialize checkpoints
for _, cp := range tc.Checkpoints {
result.Checkpoints[cp.ID] = &CheckpointResult{
ID: cp.ID,
Reached: false,
Required: cp.IsRequired(),
}
}
// Get simulator agent
simAST, err := assistant.Get(tc.Simulator.Use)
if err != nil {
result.Status = StatusError
result.Error = fmt.Sprintf("failed to get simulator agent: %s", err.Error())
result.DurationMs = time.Since(startTime).Milliseconds()
return result
}
// Get configuration
maxTurns := tc.GetMaxTurns()
timeout := tc.GetTimeout(r.opts.Timeout)
// Build simulator metadata
simMetadata := make(map[string]interface{})
if tc.Simulator.Options != nil && tc.Simulator.Options.Metadata != nil {
for k, v := range tc.Simulator.Options.Metadata {
simMetadata[k] = v
}
}
// Conversation history
messages := make([]context.Message, 0)
// Get initial input if provided
initialMessages, err := tc.GetMessages()
if err == nil && len(initialMessages) > 0 {
messages = append(messages, initialMessages...)
}
// Output dynamic test start
if r.opts.Verbose {
r.output.Info("Dynamic test: %s (max %d turns)", tc.ID, maxTurns)
}
// Conversation loop
for turn := 1; turn <= maxTurns; turn++ {
turnStart := time.Now()
turnResult := &TurnResult{Turn: turn}
// Check timeout
if time.Since(startTime) > timeout {
result.Status = StatusTimeout
result.Error = fmt.Sprintf("timeout after %s", timeout)
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = turn - 1
return result
}
// For turns after the first, get input from simulator
if turn > 1 || len(messages) == 0 {
simInput := r.buildSimulatorInput(tc, messages, result, turn, maxTurns, simMetadata)
simOutput, err := r.callSimulator(simAST, tc, simInput)
if err != nil {
turnResult.Error = fmt.Sprintf("simulator error: %s", err.Error())
result.Turns = append(result.Turns, turnResult)
result.Status = StatusError
result.Error = turnResult.Error
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = turn
return result
}
// Check if goal achieved
if simOutput.GoalAchieved {
if r.opts.Verbose {
r.output.Info(" Turn %d: Simulator signaled goal achieved", turn)
}
// Check if all required checkpoints reached
if r.allRequiredCheckpointsReached(result) {
result.Status = StatusPassed
} else {
result.Status = StatusFailed
result.Error = "simulator signaled goal achieved but not all required checkpoints reached"
}
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = turn - 1
return result
}
// Add user message
userMessage := context.Message{
Role: context.RoleUser,
Content: simOutput.Message,
}
messages = append(messages, userMessage)
turnResult.Input = simOutput.Message
if r.opts.Verbose {
r.output.Info(" Turn %d: User: %s", turn, truncateOutput(simOutput.Message, 50))
}
} else {
// Use initial input for first turn
if len(messages) > 0 {
lastMsg := messages[len(messages)-1]
turnResult.Input = lastMsg.Content
if r.opts.Verbose {
r.output.Info(" Turn %d: User: %s", turn, truncateOutput(lastMsg.Content, 50))
}
}
}
// Call target agent
ctx := NewTestContextFromOptions(
fmt.Sprintf("dynamic-%s-%d", tc.ID, turn),
agentID,
r.opts,
tc,
)
opts := buildContextOptions(tc, r.opts)
response, err := ast.Stream(ctx, messages, opts)
ctx.Release()
if err != nil {
turnResult.Error = err.Error()
turnResult.DurationMs = time.Since(turnStart).Milliseconds()
result.Turns = append(result.Turns, turnResult)
result.Status = StatusError
result.Error = fmt.Sprintf("agent error at turn %d: %s", turn, err.Error())
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = turn
return result
}
// Extract output
output := extractOutput(response)
turnResult.Output = output
turnResult.DurationMs = time.Since(turnStart).Milliseconds()
if r.opts.Verbose {
r.output.Info(" Turn %d: Agent: %s", turn, truncateOutput(output, 50))
}
// Add assistant response to messages
messages = append(messages, context.Message{
Role: context.RoleAssistant,
Content: output,
})
// Check checkpoints against this response
reachedIDs := r.checkCheckpoints(tc.Checkpoints, output, result)
turnResult.CheckpointsReached = reachedIDs
if r.opts.Verbose && len(reachedIDs) > 0 {
for _, id := range reachedIDs {
r.output.Info(" ✓ checkpoint: %s", id)
}
}
result.Turns = append(result.Turns, turnResult)
// Check if all required checkpoints reached
if r.allRequiredCheckpointsReached(result) {
result.Status = StatusPassed
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = turn
return result
}
}
// Max turns exceeded
result.Status = StatusFailed
result.Error = fmt.Sprintf("max turns (%d) exceeded without reaching all checkpoints", maxTurns)
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = maxTurns
return result
}
// buildSimulatorInput builds the input for the simulator agent
func (r *DynamicRunner) buildSimulatorInput(
tc *Case,
messages []context.Message,
result *DynamicResult,
turn, maxTurns int,
metadata map[string]interface{},
) *SimulatorInput {
input := &SimulatorInput{
Conversation: messages,
TurnNumber: turn,
MaxTurns: maxTurns,
}
// Extract persona and goal from metadata
if persona, ok := metadata["persona"].(string); ok {
input.Persona = persona
}
if goal, ok := metadata["goal"].(string); ok {
input.Goal = goal
}
// Build checkpoint lists
input.CheckpointsReached = make([]string, 0)
input.CheckpointsPending = make([]string, 0)
for id, cp := range result.Checkpoints {
if cp.Reached {
input.CheckpointsReached = append(input.CheckpointsReached, id)
} else {
input.CheckpointsPending = append(input.CheckpointsPending, id)
}
}
// Store extra metadata
input.Extra = make(map[string]interface{})
for k, v := range metadata {
if k != "persona" && k != "goal" {
input.Extra[k] = v
}
}
return input
}
// callSimulator calls the simulator agent and parses the response
func (r *DynamicRunner) callSimulator(simAST *assistant.Assistant, tc *Case, input *SimulatorInput) (*SimulatorOutput, error) {
// Create context
env := NewEnvironment("", "")
ctx := NewTestContext("simulator", tc.Simulator.Use, env)
defer ctx.Release()
// Build options - skip history and trace
opts := &context.Options{
Skip: &context.Skip{
History: true,
Trace: true,
Output: true,
},
Metadata: map[string]interface{}{
"test_mode": "simulator",
},
}
// Override connector if specified
if tc.Simulator.Options != nil && tc.Simulator.Options.Connector != "" {
opts.Connector = tc.Simulator.Options.Connector
}
// Build message
inputJSON, err := jsoniter.Marshal(input)
if err != nil {
return nil, fmt.Errorf("failed to marshal simulator input: %w", err)
}
messages := []context.Message{{
Role: context.RoleUser,
Content: string(inputJSON),
}}
// Call simulator
response, err := simAST.Stream(ctx, messages, opts)
if err != nil {
return nil, fmt.Errorf("simulator agent error: %w", err)
}
// Parse response
return r.parseSimulatorResponse(response)
}
// parseSimulatorResponse parses the simulator agent's response
func (r *DynamicRunner) parseSimulatorResponse(response *context.Response) (*SimulatorOutput, error) {
if response == nil || response.Completion == nil {
return nil, fmt.Errorf("empty response from simulator")
}
// Extract content
content := response.Completion.Content
if content == nil {
return nil, fmt.Errorf("no content in simulator response")
}
// Convert to string
var text string
switch v := content.(type) {
case string:
text = v
default:
data, err := jsoniter.Marshal(content)
if err != nil {
return nil, fmt.Errorf("failed to marshal content: %w", err)
}
text = string(data)
}
// Use goutext.ExtractJSON for fault-tolerant parsing
parsed := goutext.ExtractJSON(text)
if parsed == nil {
// Try to use the text as the message directly
return &SimulatorOutput{
Message: text,
GoalAchieved: false,
}, nil
}
// Parse as SimulatorOutput
output := &SimulatorOutput{}
if m, ok := parsed.(map[string]interface{}); ok {
if msg, ok := m["message"].(string); ok {
output.Message = msg
}
if achieved, ok := m["goal_achieved"].(bool); ok {
output.GoalAchieved = achieved
}
if reasoning, ok := m["reasoning"].(string); ok {
output.Reasoning = reasoning
}
}
if output.Message == "" {
return nil, fmt.Errorf("simulator returned empty message")
}
return output, nil
}
// checkCheckpoints validates checkpoints against current output
func (r *DynamicRunner) checkCheckpoints(checkpoints []*Checkpoint, output interface{}, result *DynamicResult) []string {
reachedIDs := make([]string, 0)
for _, cp := range checkpoints {
cpResult := result.Checkpoints[cp.ID]
if cpResult.Reached {
continue // Already reached
}
// Check "after" constraint
if len(cp.After) > 0 {
allAfterReached := true
for _, afterID := range cp.After {
if afterResult, ok := result.Checkpoints[afterID]; ok {
if !afterResult.Reached {
allAfterReached = false
break
}
}
}
if !allAfterReached {
continue // Dependencies not met
}
}
// Validate using asserter
tempCase := &Case{Assert: cp.Assert}
passed, msg := r.asserter.Validate(tempCase, output)
if passed {
cpResult.Reached = true
cpResult.Passed = true
cpResult.ReachedAtTurn = len(result.Turns) + 1
cpResult.Message = msg
reachedIDs = append(reachedIDs, cp.ID)
}
}
return reachedIDs
}
// allRequiredCheckpointsReached checks if all required checkpoints are reached
func (r *DynamicRunner) allRequiredCheckpointsReached(result *DynamicResult) bool {
for _, cp := range result.Checkpoints {
if cp.Required && !cp.Reached {
return false
}
}
return true
}

View file

@ -0,0 +1,319 @@
package test_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent"
"github.com/yaoapp/yao/agent/test"
"github.com/yaoapp/yao/config"
testutils "github.com/yaoapp/yao/test"
)
func TestCase_IsDynamicMode(t *testing.T) {
tests := []struct {
name string
tc *test.Case
expected bool
}{
{
name: "standard mode - no simulator",
tc: &test.Case{
ID: "T001",
Input: "Hello",
},
expected: false,
},
{
name: "standard mode - simulator but no checkpoints",
tc: &test.Case{
ID: "T002",
Input: "Hello",
Simulator: &test.Simulator{Use: "tests.simulator-agent"},
},
expected: false,
},
{
name: "standard mode - checkpoints but no simulator",
tc: &test.Case{
ID: "T003",
Input: "Hello",
Checkpoints: []*test.Checkpoint{
{ID: "cp1", Assert: map[string]interface{}{"type": "contains", "value": "hi"}},
},
},
expected: false,
},
{
name: "dynamic mode - has both simulator and checkpoints",
tc: &test.Case{
ID: "T004",
Simulator: &test.Simulator{Use: "tests.simulator-agent"},
Checkpoints: []*test.Checkpoint{
{ID: "cp1", Assert: map[string]interface{}{"type": "contains", "value": "hi"}},
},
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.tc.IsDynamicMode()
assert.Equal(t, tt.expected, result)
})
}
}
func TestCase_GetMaxTurns(t *testing.T) {
tests := []struct {
name string
tc *test.Case
expected int
}{
{
name: "default max turns",
tc: &test.Case{ID: "T001"},
expected: 20,
},
{
name: "custom max turns",
tc: &test.Case{ID: "T002", MaxTurns: 10},
expected: 10,
},
{
name: "zero max turns uses default",
tc: &test.Case{ID: "T003", MaxTurns: 0},
expected: 20,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.tc.GetMaxTurns()
assert.Equal(t, tt.expected, result)
})
}
}
func TestCheckpoint_IsRequired(t *testing.T) {
boolTrue := true
boolFalse := false
tests := []struct {
name string
cp *test.Checkpoint
expected bool
}{
{
name: "default is required",
cp: &test.Checkpoint{ID: "cp1"},
expected: true,
},
{
name: "explicitly required",
cp: &test.Checkpoint{ID: "cp2", Required: &boolTrue},
expected: true,
},
{
name: "explicitly not required",
cp: &test.Checkpoint{ID: "cp3", Required: &boolFalse},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.cp.IsRequired()
assert.Equal(t, tt.expected, result)
})
}
}
func TestDynamicResult_ToResult(t *testing.T) {
dr := &test.DynamicResult{
ID: "T001",
Status: test.StatusPassed,
TotalTurns: 3,
DurationMs: 5000,
Turns: []*test.TurnResult{
{Turn: 1, Input: "Hello", Output: "Hi there!"},
{Turn: 2, Input: "How are you?", Output: "I'm doing well!"},
{Turn: 3, Input: "Goodbye", Output: "Bye!"},
},
Checkpoints: map[string]*test.CheckpointResult{
"greet": {ID: "greet", Reached: true, ReachedAtTurn: 1, Required: true},
"bye": {ID: "bye", Reached: true, ReachedAtTurn: 3, Required: true},
},
}
result := dr.ToResult()
assert.Equal(t, "T001", result.ID)
assert.Equal(t, test.StatusPassed, result.Status)
assert.Equal(t, int64(5000), result.DurationMs)
assert.Equal(t, "Hello", result.Input)
assert.Equal(t, "Bye!", result.Output)
// Check metadata
assert.NotNil(t, result.Metadata)
assert.Equal(t, "dynamic", result.Metadata["mode"])
assert.Equal(t, 3, result.Metadata["total_turns"])
}
func TestDynamicRunner_Integration(t *testing.T) {
// Skip if running in short mode
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Prepare test environment
testutils.Prepare(t, config.Conf)
defer testutils.Clean()
// Load agents
err := agent.Load(config.Conf)
if err != nil {
t.Skipf("Failed to load agents: %v", err)
}
// Create a dynamic test case
tc := &test.Case{
ID: "dynamic-greeting",
Simulator: &test.Simulator{
Use: "tests.simulator-agent",
Options: &test.SimulatorOptions{
Metadata: map[string]interface{}{
"persona": "Friendly user",
"goal": "Have a brief greeting exchange",
},
},
},
Input: "Hello!",
Checkpoints: []*test.Checkpoint{
{
ID: "greeting",
Description: "Agent should greet back",
Assert: map[string]interface{}{
"type": "regex",
"value": "(?i)(hello|hi|hey|greetings)",
},
},
},
MaxTurns: 3,
}
// Verify it's dynamic mode
assert.True(t, tc.IsDynamicMode())
// Create runner options
opts := &test.Options{
Verbose: true,
Timeout: 30 * time.Second,
}
// Create dynamic runner
runner := test.NewDynamicRunner(opts)
assert.NotNil(t, runner)
// Note: Full integration test would require the simulator agent to be loaded
// and would make actual LLM calls. For CI, we test the structure and logic.
}
func TestDynamicRunner_CheckpointOrdering(t *testing.T) {
// Test that checkpoints with "after" constraints are properly ordered
testutils.Prepare(t, config.Conf)
defer testutils.Clean()
// Load agents
err := agent.Load(config.Conf)
if err != nil {
t.Skipf("Failed to load agents: %v", err)
}
// Create a test case with ordered checkpoints
tc := &test.Case{
ID: "ordered-checkpoints",
Simulator: &test.Simulator{
Use: "tests.simulator-agent",
Options: &test.SimulatorOptions{
Metadata: map[string]interface{}{
"persona": "Customer",
"goal": "Complete a purchase",
},
},
},
Checkpoints: []*test.Checkpoint{
{
ID: "ask_product",
Description: "Agent asks about product",
Assert: map[string]interface{}{
"type": "contains",
"value": "product",
},
},
{
ID: "confirm_order",
Description: "Agent confirms order",
After: []string{"ask_product"},
Assert: map[string]interface{}{
"type": "contains",
"value": "confirm",
},
},
{
ID: "complete",
Description: "Order completed",
After: []string{"confirm_order"},
Assert: map[string]interface{}{
"type": "contains",
"value": "complete",
},
},
},
MaxTurns: 10,
}
// Verify checkpoint structure
assert.Len(t, tc.Checkpoints, 3)
assert.Empty(t, tc.Checkpoints[0].After)
assert.Equal(t, []string{"ask_product"}, tc.Checkpoints[1].After)
assert.Equal(t, []string{"confirm_order"}, tc.Checkpoints[2].After)
}
func TestSimulatorInput_Structure(t *testing.T) {
// Test SimulatorInput structure
input := &test.SimulatorInput{
Persona: "Test user",
Goal: "Complete task",
TurnNumber: 3,
MaxTurns: 10,
CheckpointsReached: []string{"cp1", "cp2"},
CheckpointsPending: []string{"cp3"},
Extra: map[string]interface{}{
"style": "formal",
},
}
assert.Equal(t, "Test user", input.Persona)
assert.Equal(t, "Complete task", input.Goal)
assert.Equal(t, 3, input.TurnNumber)
assert.Equal(t, 10, input.MaxTurns)
assert.Len(t, input.CheckpointsReached, 2)
assert.Len(t, input.CheckpointsPending, 1)
assert.Equal(t, "formal", input.Extra["style"])
}
func TestSimulatorOutput_Structure(t *testing.T) {
// Test SimulatorOutput structure
output := &test.SimulatorOutput{
Message: "I'd like to buy a product",
GoalAchieved: false,
Reasoning: "Continuing toward purchase goal",
}
assert.Equal(t, "I'd like to buy a product", output.Message)
assert.False(t, output.GoalAchieved)
assert.Equal(t, "Continuing toward purchase goal", output.Reasoning)
}

159
agent/test/dynamic_types.go Normal file
View file

@ -0,0 +1,159 @@
package test
import "github.com/yaoapp/yao/agent/context"
// DynamicResult represents the result of a dynamic (simulator-driven) test
type DynamicResult struct {
// ID is the test case identifier
ID string `json:"id"`
// Status is the overall test status
Status Status `json:"status"`
// Turns contains results for each conversation turn
Turns []*TurnResult `json:"turns"`
// Checkpoints maps checkpoint ID to its result
Checkpoints map[string]*CheckpointResult `json:"checkpoints"`
// TotalTurns is the number of turns executed
TotalTurns int `json:"total_turns"`
// DurationMs is the total execution time in milliseconds
DurationMs int64 `json:"duration_ms"`
// Error contains error message if status is failed/error/timeout
Error string `json:"error,omitempty"`
}
// TurnResult represents the result of a single conversation turn
type TurnResult struct {
// Turn is the turn number (1-based)
Turn int `json:"turn"`
// Input is the user message (from simulator or initial input)
Input interface{} `json:"input"`
// Output is the agent's response
Output interface{} `json:"output,omitempty"`
// CheckpointsReached lists checkpoint IDs reached in this turn
CheckpointsReached []string `json:"checkpoints_reached,omitempty"`
// DurationMs is the turn execution time in milliseconds
DurationMs int64 `json:"duration_ms"`
// Error contains error message if this turn failed
Error string `json:"error,omitempty"`
}
// CheckpointResult represents the result of a checkpoint validation
type CheckpointResult struct {
// ID is the checkpoint identifier
ID string `json:"id"`
// Reached indicates if the checkpoint was reached
Reached bool `json:"reached"`
// ReachedAtTurn is the turn number when checkpoint was reached (0 if not reached)
ReachedAtTurn int `json:"reached_at_turn,omitempty"`
// Required indicates if this checkpoint is required
Required bool `json:"required"`
// Passed indicates if the checkpoint assertion passed
Passed bool `json:"passed"`
// Message contains assertion result message
Message string `json:"message,omitempty"`
}
// SimulatorInput is the input sent to the simulator agent
type SimulatorInput struct {
// Persona describes the user being simulated
Persona string `json:"persona,omitempty"`
// Goal is what the user is trying to achieve
Goal string `json:"goal,omitempty"`
// Conversation is the message history
Conversation []context.Message `json:"conversation"`
// TurnNumber is the current turn (1-based)
TurnNumber int `json:"turn_number"`
// MaxTurns is the maximum allowed turns
MaxTurns int `json:"max_turns"`
// CheckpointsReached lists checkpoint IDs already reached
CheckpointsReached []string `json:"checkpoints_reached,omitempty"`
// CheckpointsPending lists checkpoint IDs still pending
CheckpointsPending []string `json:"checkpoints_pending,omitempty"`
// Extra metadata from simulator options
Extra map[string]interface{} `json:"extra,omitempty"`
}
// SimulatorOutput is the expected output from the simulator agent
type SimulatorOutput struct {
// Message is the simulated user message
Message string `json:"message"`
// GoalAchieved indicates if the user's goal has been accomplished
GoalAchieved bool `json:"goal_achieved"`
// Reasoning explains the simulator's response strategy
Reasoning string `json:"reasoning,omitempty"`
}
// ToResult converts DynamicResult to standard Result for reporting
func (dr *DynamicResult) ToResult() *Result {
result := &Result{
ID: dr.ID,
Status: dr.Status,
DurationMs: dr.DurationMs,
Error: dr.Error,
}
// Store dynamic-specific data in metadata
result.Metadata = map[string]interface{}{
"mode": "dynamic",
"total_turns": dr.TotalTurns,
"turns": dr.Turns,
"checkpoints": dr.Checkpoints,
}
// Set input from first turn
if len(dr.Turns) > 0 {
result.Input = dr.Turns[0].Input
}
// Set output from last turn
if len(dr.Turns) > 0 {
result.Output = dr.Turns[len(dr.Turns)-1].Output
}
return result
}
// IsDynamicMode checks if a test case should run in dynamic mode
func (tc *Case) IsDynamicMode() bool {
return tc.Simulator != nil && len(tc.Checkpoints) > 0
}
// GetMaxTurns returns the max turns for dynamic mode
func (tc *Case) GetMaxTurns() int {
if tc.MaxTurns > 0 {
return tc.MaxTurns
}
return 20 // Default max turns
}
// IsRequired returns true if the checkpoint is required
func (cp *Checkpoint) IsRequired() bool {
if cp.Required == nil {
return true // Default to required
}
return *cp.Required
}

View file

@ -303,6 +303,45 @@ func (w *OutputWriter) ScriptTestSummary(summary *ScriptTestSummary, duration ti
fmt.Printf("%s\n", formatDuration(duration))
}
// DynamicTestStart outputs the start of a dynamic test
func (w *OutputWriter) DynamicTestStart(id string, checkpointCount int) {
color.New(color.FgWhite).Printf("► [%s] ", id)
color.New(color.FgCyan).Printf("(dynamic, %d checkpoints)\n", checkpointCount)
}
// DynamicTurn outputs a single turn in dynamic testing
func (w *OutputWriter) DynamicTurn(turn int, inputSummary string, checkpointsReached, total int) {
if w.verbose {
color.New(color.FgHiBlack).Printf("│ ├─ Turn %d: %s ", turn, inputSummary)
color.New(color.FgCyan).Printf("[%d/%d checkpoints]\n", checkpointsReached, total)
}
}
// DynamicCheckpoint outputs a checkpoint being reached
func (w *OutputWriter) DynamicCheckpoint(checkpointID string) {
if w.verbose {
color.New(color.FgGreen).Printf("│ │ └─ ✓ checkpoint: %s\n", checkpointID)
}
}
// DynamicTestResult outputs the result of a dynamic test
func (w *OutputWriter) DynamicTestResult(status Status, turns int, checkpoints int, duration time.Duration) {
color.New(color.FgHiBlack).Printf(" └─ ")
switch status {
case StatusPassed:
color.New(color.FgGreen).Printf("PASSED")
case StatusFailed:
color.New(color.FgRed).Printf("FAILED")
case StatusError:
color.New(color.FgRed).Printf("ERROR")
case StatusTimeout:
color.New(color.FgRed).Printf("TIMEOUT")
}
color.New(color.FgHiBlack).Printf(" (%d turns, %d checkpoints, %s)\n", turns, checkpoints, formatDuration(duration))
}
// StabilityResult prints stability analysis result for a test case
func (w *OutputWriter) StabilityResult(sr *StabilityResult) {
color.New(color.FgWhite).Printf(" [%s] ", sr.ID)

View file

@ -377,6 +377,11 @@ func (r *Executor) runParallel(ast *assistant.Assistant, testCases []*Case, agen
// runSingleTest runs a single test case
func (r *Executor) runSingleTest(ast *assistant.Assistant, tc *Case, agentID string, runNum int) *Result {
// Check if this is a dynamic mode test
if tc.IsDynamicMode() {
return r.runDynamicTest(ast, tc, agentID)
}
// Get input summary for display
inputSummary := SummarizeInput(tc.Input, 50)
r.output.TestStart(tc.ID, inputSummary, runNum)
@ -489,6 +494,58 @@ func (r *Executor) runSingleTest(ast *assistant.Assistant, tc *Case, agentID str
return result
}
// runDynamicTest runs a dynamic (simulator-driven) test case
func (r *Executor) runDynamicTest(ast *assistant.Assistant, tc *Case, agentID string) *Result {
// Output test start for dynamic mode
r.output.DynamicTestStart(tc.ID, len(tc.Checkpoints))
startTime := time.Now()
// Execute before script if specified
var beforeData interface{}
if tc.Before != "" {
var err error
beforeData, err = r.hookExecutor.ExecuteBefore(tc.Before, tc, r.agentPath)
if err != nil {
result := &Result{
ID: tc.ID,
Status: StatusError,
Error: fmt.Sprintf("before script failed: %s", err.Error()),
DurationMs: time.Since(startTime).Milliseconds(),
}
r.output.TestResult(result.Status, time.Since(startTime))
r.output.TestError(result.Error)
return result
}
}
// Create dynamic runner and execute
dynamicRunner := NewDynamicRunner(r.opts)
dynamicResult := dynamicRunner.RunDynamic(ast, tc, agentID)
// Convert to standard result
result := dynamicResult.ToResult()
// Execute after script if specified
defer func() {
if tc.After != "" && (tc.Before == "" || beforeData != nil || result.Status != StatusError || !isBeforeError(result.Error)) {
if err := r.hookExecutor.ExecuteAfter(tc.After, tc, result, beforeData, r.agentPath); err != nil {
r.output.Warning("after script failed: %s", err.Error())
}
}
}()
// Output result
duration := time.Duration(result.DurationMs) * time.Millisecond
r.output.DynamicTestResult(result.Status, dynamicResult.TotalTurns, len(tc.Checkpoints), duration)
if result.Error != "" {
r.output.TestError(result.Error)
}
return result
}
// isBeforeError checks if the error message indicates a before script failure
func isBeforeError(errMsg string) bool {
return len(errMsg) > 0 && errMsg[:min(len(errMsg), 20)] == "before script failed"

View file

@ -156,6 +156,10 @@ type Options struct {
// DryRun generates test cases without running them
// Useful for previewing agent-generated test cases
DryRun bool `json:"dry_run,omitempty"`
// Simulator is the default simulator agent ID for dynamic mode
// Can be overridden per test case in JSONL
Simulator string `json:"simulator,omitempty"`
}
// ContextConfig represents custom context configuration from JSON file
@ -395,6 +399,60 @@ type Case struct {
// After script function (e.g., "scripts:tests.env.After")
// Called after the test case completes (pass or fail)
After string `json:"after,omitempty"`
// Dynamic Mode Fields
// ===============================
// Simulator configures the user simulator for dynamic testing
// When set, the test runs in dynamic mode with multi-turn conversation
Simulator *Simulator `json:"simulator,omitempty"`
// Checkpoints define validation points for dynamic testing
// Each checkpoint is checked after every agent response
Checkpoints []*Checkpoint `json:"checkpoints,omitempty"`
// MaxTurns is the maximum number of conversation turns (default: 20)
MaxTurns int `json:"max_turns,omitempty"`
}
// Simulator configures the user simulator for dynamic testing
type Simulator struct {
// Use is the simulator agent ID (no prefix needed)
Use string `json:"use"`
// Options for the simulator agent
Options *SimulatorOptions `json:"options,omitempty"`
}
// SimulatorOptions configures simulator behavior
type SimulatorOptions struct {
// Metadata passed to the simulator agent
// Common fields: persona, goal, style
Metadata map[string]interface{} `json:"metadata,omitempty"`
// Connector overrides the simulator's default connector
Connector string `json:"connector,omitempty"`
}
// Checkpoint defines a validation point in dynamic testing
type Checkpoint struct {
// ID is the unique identifier for this checkpoint
ID string `json:"id"`
// Description is a human-readable description
Description string `json:"description,omitempty"`
// Assert defines the assertion to validate
// Same format as Case.Assert
Assert interface{} `json:"assert"`
// After specifies checkpoint IDs that must be reached before this one
// Used to enforce ordering (e.g., "ask_type" must come before "confirm")
After []string `json:"after,omitempty"`
// Required indicates if this checkpoint must be reached (default: true)
// Optional checkpoints don't cause test failure if not reached
Required *bool `json:"required,omitempty"`
}
// CaseOptions represents per-test-case context options

View file

@ -36,6 +36,7 @@ var (
testBefore string // --before flag for global BeforeAll hook
testAfter string // --after flag for global AfterAll hook
testDryRun bool // --dry-run flag for generating tests without running
testSimulator string // --simulator flag for default simulator agent in dynamic mode
)
// TestCmd is the agent test command
@ -163,6 +164,7 @@ var TestCmd = &cobra.Command{
BeforeAll: testBefore,
AfterAll: testAfter,
DryRun: testDryRun,
Simulator: testSimulator,
}
// Merge with defaults
@ -253,6 +255,7 @@ func init() {
TestCmd.Flags().StringVar(&testBefore, "before", "", L("Global BeforeAll hook (e.g., env_test.BeforeAll)"))
TestCmd.Flags().StringVar(&testAfter, "after", "", L("Global AfterAll hook (e.g., env_test.AfterAll)"))
TestCmd.Flags().BoolVar(&testDryRun, "dry-run", false, L("Generate test cases without running them"))
TestCmd.Flags().StringVar(&testSimulator, "simulator", "", L("Default simulator agent for dynamic mode (e.g., tests.simulator-agent)"))
// Mark input as required
TestCmd.MarkFlagRequired("input")