- Revised DESIGN_V2.md to clarify the `input` field's capabilities, allowing for string, single message, or message array formats for conversation context. - Updated the test case format to reflect the new `input` structure, ensuring compatibility with existing single-turn tests. - Enhanced TODO_V2.md to indicate the completion of message history support and outline remaining tasks, including options field support and JSONL output format updates. - Improved documentation to ensure clarity on the new input handling and its implications for agent-driven testing.
28 KiB
Agent Test Framework V2 Design
Overview
This document describes the design for Agent Test Framework V2, which extends the existing testing capabilities with:
- Message history support - Test agents with conversation context via
inputarray (already implemented) - Agent-driven testing - Use agents to generate test cases and validate responses
- Dynamic testing - Simulator-driven testing with checkpoint validation
Quick Reference: Format Rules
| Context | Format | Example |
|---|---|---|
-i flag (CLI) |
Prefix required | agents:workers.test.gen, scripts:tests.gen |
JSONL assertion use |
Prefix required | "use": "agents:workers.test.validator" |
JSONL simulator.use |
No prefix (agent only) | "use": "workers.test.user-simulator" |
--simulator flag |
No prefix (agent only) | --simulator workers.test.user-simulator |
t.assert.Agent() |
No prefix (method-bound) | t.assert.Agent(resp, "workers.test.validator", {...}) |
Design Goals
- Simple - Single-turn with optional message history, no complex multi-turn state
- Stateless - Each test is independent, no session management needed
- Parallel - Tests can run in parallel since they don't share state
- Flexible - Support both static (messages) and dynamic (simulator) testing
- Agent-driven - Input generation, simulation, and validation can all be agent-powered
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ yao agent test │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ INPUT SOURCES (-i flag) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ JSONL File │ │ Message │ │ Generator │ │
│ │ ./test.jsonl│ │ "Hello..." │ │ agents:xxx │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┴────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Test Case Parser │ │
│ │ │ │
│ │ Standard Mode: {input: "..." | [...], assertions} │ │
│ │ Dynamic Mode: {simulator: {...}, checkpoints: [...]} │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ ▼ ▼ │
│ ┌───────────────────┐ ┌───────────────────────┐ │
│ │ STANDARD MODE │ │ DYNAMIC MODE │ │
│ │ │ │ │ │
│ │ 1. Build messages │ │ LOOP: │ │
│ │ 2. Call Agent │ │ 1. Simulator→input │ │
│ │ 3. Run assertions │ │ 2. Call Agent │ │
│ │ │ │ 3. Check checkpoints │ │
│ │ → PASS/FAIL │ │ 4. Until done │ │
│ └───────────────────┘ └───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Reporter │ │
│ │ - Console output │ │
│ │ - JSONL output │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Test Modes
Standard Mode (Default)
Single call to agent with optional message history. No multi-turn state management needed.
| Field | Type | Description |
|---|---|---|
input |
string | Message | Message[] | Text, single message, or conversation history |
assertions |
array | Assertions to validate response |
options |
object | context.Options passed to agent |
Dynamic Mode
Simulator-driven testing with checkpoint validation.
| Field | Type | Description |
|---|---|---|
simulator |
object | Simulator agent configuration |
checkpoints |
array | Functional checkpoints to verify |
max_turns |
int | Maximum turns before timeout |
timeout |
string | Maximum time (e.g., "5m") |
Test Case Format
Simple Input (Existing)
{
"id": "T001",
"input": "Hello",
"assertions": [
{
"type": "contains",
"value": "Hi"
}
]
}
With Message History (Existing)
The input field already supports message arrays for conversation context:
{
"id": "T002",
"name": "Expense submission - final confirmation",
"input": [
{
"role": "user",
"content": "I want to submit an expense"
},
{
"role": "assistant",
"content": "What type of expense would you like to submit?"
},
{
"role": "user",
"content": "Business travel to Beijing, $3500"
},
{
"role": "assistant",
"content": "I'll create an expense for business travel, $3500. Please confirm."
},
{
"role": "user",
"content": "Yes, confirm"
}
],
"assertions": [
{
"type": "contains",
"value": "submitted"
},
{
"type": "tool_called",
"name": "create_expense"
}
]
}
Key insight: Instead of executing 3 turns sequentially, we pass the full conversation history. The agent sees the context and responds to the last message. This is:
- Simpler - No turn-by-turn execution, no session state
- Faster - Single API call instead of multiple
- Parallelizable - Each test is independent
- Debuggable - Clear input/output for each test
Testing Different Points in a Conversation
To test agent behavior at different conversation stages, create separate test cases:
// Test 1: First turn - agent should ask for expense type
{
"id": "expense-turn1",
"input": [{"role": "user", "content": "I want to submit an expense"}],
"assertions": [{"type": "contains", "value": "type"}]
}
// Test 2: Second turn - agent should create expense
{
"id": "expense-turn2",
"input": [
{"role": "user", "content": "I want to submit an expense"},
{"role": "assistant", "content": "What type of expense would you like to submit?"},
{"role": "user", "content": "Business travel, $3500"}
],
"assertions": [{"type": "tool_called", "name": "create_expense"}]
}
// Test 3: Final turn - agent should confirm submission
{
"id": "expense-turn3",
"input": [
{"role": "user", "content": "I want to submit an expense"},
{"role": "assistant", "content": "What type of expense?"},
{"role": "user", "content": "Business travel, $3500"},
{"role": "assistant", "content": "Confirm $3500 expense?"},
{"role": "user", "content": "Yes"}
],
"assertions": [{"type": "contains", "value": "submitted"}]
}
With Attachments
{
"id": "T003",
"input": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "What's in this receipt?"
},
{
"type": "image",
"source": "file://./fixtures/receipt.jpg"
}
]
}
],
"assertions": [
{
"type": "contains",
"value": "amount"
}
]
}
Dynamic Mode (Simulator + Checkpoints)
For coverage testing where conversation flow is unpredictable:
{
"id": "T004",
"name": "Expense Submission Coverage",
"simulator": {
"use": "workers.test.user-simulator",
"options": {
"metadata": {
"persona": "New employee unfamiliar with expense process",
"goal": "Submit a $3500 travel expense"
}
}
},
"checkpoints": [
{
"id": "ask_type",
"description": "Agent asks for expense type",
"assertion": {
"type": "contains",
"value": "type"
}
},
{
"id": "call_create",
"description": "Agent calls create_expense",
"after": [
"ask_type"
],
"assertion": {
"type": "tool_called",
"name": "create_expense"
}
},
{
"id": "confirm",
"description": "Agent confirms submission",
"after": [
"call_create"
],
"assertion": {
"type": "contains",
"value": "submitted"
}
}
],
"max_turns": 10,
"timeout": "2m"
}
Field Descriptions
Standard Mode Fields
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Unique test identifier |
name |
string | No | Human-readable test name |
input |
string | Message | Message[] | Yes | Input: text, single message, or message array |
assertions |
array | No | Assertions to validate response (alias: assert) |
options |
object | No | context.Options passed to agent |
Note: The input field supports three formats:
string: Simple text (converted to[{role: "user", content: "..."}])object: Single message{role: "...", content: "..."}array: Message history[{role: "user", ...}, {role: "assistant", ...}, ...]
Dynamic Mode Fields
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Unique test identifier |
name |
string | No | Human-readable test name |
simulator |
object | Yes | User simulator configuration |
simulator.use |
string | Yes | Simulator agent ID (no prefix) |
simulator.options |
object | No | context.Options passed to simulator |
checkpoints |
array | Yes | Functionality checkpoints to verify |
checkpoints[].id |
string | Yes | Unique checkpoint identifier |
checkpoints[].description |
string | No | Human-readable description |
checkpoints[].assertion |
object | Yes | Assertion to verify |
checkpoints[].after |
array | No | Checkpoint IDs that must occur first |
max_turns |
int | No | Maximum turns before timeout (default: 20) |
timeout |
string | No | Maximum time (default: "5m") |
options |
object | No | context.Options passed to target agent |
Execution Flow
Standard Mode
┌─────────────────────────────────────────────────────────────────┐
│ Standard Mode Execution │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Parse test case │
│ ├─ `input` is array? → Use as messages │
│ └─ `input` is string? → Convert to [{role: "user", content}] │
│ ↓ │
│ 2. Call Agent.Stream(ctx, messages, options) │
│ ↓ │
│ 3. Run assertions against response │
│ ├─ All PASS → Test PASSED ✅ │
│ └─ Any FAIL → Test FAILED ❌ │
│ │
└─────────────────────────────────────────────────────────────────┘
Dynamic Mode
┌─────────────────────────────────────────────────────────────────┐
│ Dynamic Mode Execution │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Initialize: │
│ - pending_checkpoints = all checkpoints │
│ - messages = [] │
│ - turn_count = 0 │
│ ↓ │
│ LOOP: │
│ 1. Call Simulator → get user input │
│ 2. Append user message to messages │
│ 3. Call Agent.Stream(ctx, messages, options) │
│ 4. Append assistant response to messages │
│ 5. Check response against pending_checkpoints │
│ └─ If matched (and `after` satisfied) → move to reached │
│ 6. Check termination: │
│ ├─ All checkpoints reached → PASSED ✅ │
│ ├─ Simulator signals goal_achieved → FAILED ❌ │
│ ├─ turn_count >= max_turns → FAILED ❌ │
│ └─ timeout exceeded → FAILED ❌ │
│ │
└─────────────────────────────────────────────────────────────────┘
Assertion Types
Static Assertions
| Type | Description | Example |
|---|---|---|
contains |
Response contains text | {"type": "contains", "value": "success"} |
equals |
Exact match | {"type": "equals", "value": "OK"} |
regex |
Regex pattern match | {"type": "regex", "pattern": "order-\\d+"} |
json_path |
JSONPath value check | {"type": "json_path", "path": "$.status", "value": "ok"} |
tool_called |
Tool was invoked | {"type": "tool_called", "name": "create_expense"} |
type |
Value type check | {"type": "type", "path": "$.count", "value": "number"} |
Agent-Driven Assertions
For semantic or fuzzy validation:
{
"type": "agent",
"use": "agents:workers.test.validator",
"options": {
"metadata": {
"criteria": "Response should be helpful and answer the user's question",
"tone": "professional and friendly"
}
}
}
Script Assertions
For custom validation logic:
{
"type": "script",
"use": "scripts:tests.validate-expense",
"options": {
"metadata": {
"min_amount": 100,
"max_amount": 10000
}
}
}
Script Testing with Agent Assertions
Script tests can use Agent-driven assertions via t.assert.Agent():
export function TestExpenseResponse(t: TestingT, ctx: Context) {
const messages = [
{ role: "user", content: "I want to submit an expense" },
{ role: "assistant", content: "What type of expense?" },
{ role: "user", content: "Travel, $500" },
];
const response = Process("agents.expense.Stream", ctx, messages);
// Static assertion
t.assert.Contains(response.content, "confirm");
// Agent-driven assertion
t.assert.Agent(response.content, "workers.test.validator", {
metadata: {
criteria: "Response should ask for confirmation before creating expense",
conversation: messages,
},
});
}
Standard Agent Interface
All agent-driven features use context.Options:
type Options struct {
Skip *Skip `json:"skip,omitempty"`
Connector string `json:"connector,omitempty"`
Search any `json:"search,omitempty"`
Mode string `json:"mode,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
Generator Agent
Called when -i agents:xxx is used:
options := &context.Options{
Metadata: map[string]any{
"test_mode": "generator",
"target_agent": "assistants.expense",
"count": 10,
"focus": "edge-cases",
},
}
Simulator Agent
Called in dynamic mode to generate user input:
options := &context.Options{
Metadata: map[string]any{
"test_mode": "simulator",
"persona": "New employee",
"goal": "Submit expense",
"turn_number": 3,
},
}
Validator Agent
Called for agent-driven assertions:
options := &context.Options{
Metadata: map[string]any{
"test_mode": "validator",
"criteria": "Response should be helpful",
},
}
Command Line Interface
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 | |
--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 |
Examples
# Simple test
yao agent test -i "Hello, how are you?" -n assistants.chat
# From JSONL file
yao agent test -i ./tests/expense.jsonl
# Agent-generated tests
yao agent test -i "agents:workers.test.generator?count=10" -n assistants.expense
# With simulator for dynamic mode
yao agent test -i ./tests/dynamic.jsonl --simulator workers.test.user-simulator
# Parallel execution
yao agent test -i ./tests/expense.jsonl --parallel 5
# Verbose output
yao agent test -i ./tests/expense.jsonl -v
Output Format
Console Output
═══════════════════════════════════════════════════════════════
Agent Test
═══════════════════════════════════════════════════════════════
ℹ Agent: assistants.expense
ℹ Input: ./tests/expense.jsonl (3 test cases)
───────────────────────────────────────────────────────────────
Running Tests
───────────────────────────────────────────────────────────────
✓ [expense-turn1] First turn - ask type (1.2s)
Messages: 1, Assertions: 1/1 passed
✓ [expense-turn2] Second turn - create expense (2.1s)
Messages: 3, Assertions: 1/1 passed
✓ [expense-turn3] Final turn - confirm (1.8s)
Messages: 5, Assertions: 1/1 passed
───────────────────────────────────────────────────────────────
Summary
───────────────────────────────────────────────────────────────
Total: 3 tests
Passed: 3
Failed: 0
Time: 5.1s
JSONL Output
{
"id": "expense-turn3",
"name": "Final turn - confirm",
"status": "passed",
"messages_count": 5,
"response": "Expense submitted. Reference: EXP-2025-001",
"assertions": [
{
"type": "contains",
"value": "submitted",
"passed": true
}
],
"duration_ms": 1800
}
Dynamic Mode Output
{
"id": "expense-dynamic",
"name": "Expense Coverage Test",
"status": "passed",
"turns": [
{
"turn": 1,
"input": "Help me file an expense",
"output": "What type?"
},
{
"turn": 2,
"input": "Client dinner, $250",
"output": "Confirm?"
},
{
"turn": 3,
"input": "Yes",
"output": "Submitted!"
}
],
"checkpoints": [
{
"id": "ask_type",
"reached_at_turn": 1,
"passed": true
},
{
"id": "call_create",
"reached_at_turn": 2,
"passed": true
},
{
"id": "confirm",
"reached_at_turn": 3,
"passed": true
}
],
"total_turns": 3,
"duration_ms": 6800
}
User Simulator Agent
Interface
interface SimulatorInput {
persona: string;
goal: string;
conversation: Message[];
turn_number: number;
max_turns: number;
}
interface SimulatorOutput {
input: string;
goal_achieved: boolean;
reasoning?: string;
}
Example Prompt
You are simulating a user with the following characteristics:
Persona: {{persona}}
Goal: {{goal}}
Current conversation:
{{conversation}}
Generate the next user message to continue toward the goal.
If the goal has been achieved, set goal_achieved to true.
Respond in JSON format:
{
"input": "your response as the user",
"goal_achieved": true/false,
"reasoning": "brief explanation"
}
Backward Compatibility
Existing single-turn tests work unchanged:
// Simple string input
{"id": "T001", "input": "Hello", "assertions": [...]}
// Equivalent to array format
{"id": "T001", "input": [{"role": "user", "content": "Hello"}], "assertions": [...]}
Error Handling
Standard Mode Errors
| Error Type | Behavior | Output |
|---|---|---|
| Agent timeout | Test FAILED | error: "timeout after 30s" |
| Agent error | Test FAILED | error: "agent error: ..." |
| Assertion failed | Test FAILED | assertion_errors: [...] |
Dynamic Mode Errors
| Error Type | Behavior | Output |
|---|---|---|
| All checkpoints reached | Test PASSED | status: "passed" |
| Checkpoints missing | Test FAILED | error: "missing checkpoints: ..." |
| Max turns exceeded | Test FAILED | error: "max turns (20) exceeded" |
| Timeout exceeded | Test FAILED | error: "timeout after 5m" |
| Simulator error | Test FAILED | error: "simulator error: ..." |
| Checkpoint assertion failed | Test FAILED | error: "checkpoint X failed" |
Current Implementation Status
| Feature | Status | Notes |
|---|---|---|
| Simple text input | ✅ Done | input: "Hello" |
| Message history | ✅ Done | input: [{role, content}, ...] |
| File attachments | ✅ Done | file:// protocol in content parts |
| Static assertions | ✅ Done | contains, equals, regex, json_path, etc. |
| Agent-driven assertions | 🔲 Planned | type: "agent" with validator agent |
| Dynamic mode | 🔲 Planned | Simulator + Checkpoints |
| Agent-driven input | 🔲 Planned | -i agents:xxx for test generation |
Open Questions
-
Message Generation: Should we provide a helper to generate message history from a script?
-
Snapshot Testing: Should we support "golden file" comparison for responses?
-
Retry Logic: If a test fails, should we support automatic retry?