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:
parent
6ea982f027
commit
8de302d4e8
10 changed files with 1338 additions and 31 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
250
agent/test/dynamic_integration_test.go
Normal file
250
agent/test/dynamic_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
413
agent/test/dynamic_runner.go
Normal file
413
agent/test/dynamic_runner.go
Normal 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
|
||||
}
|
||||
319
agent/test/dynamic_runner_test.go
Normal file
319
agent/test/dynamic_runner_test.go
Normal 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
159
agent/test/dynamic_types.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue