@ozperium/agentspec 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/action.yml +32 -0
- package/dist/assertions/index.d.ts +14 -0
- package/dist/assertions/index.js +215 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +181 -0
- package/dist/diff.d.ts +7 -0
- package/dist/diff.js +167 -0
- package/dist/http-agent.d.ts +14 -0
- package/dist/http-agent.js +102 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +43 -0
- package/dist/llm-judge.d.ts +11 -0
- package/dist/llm-judge.js +124 -0
- package/dist/loader.d.ts +3 -0
- package/dist/loader.js +64 -0
- package/dist/reporter.d.ts +4 -0
- package/dist/reporter.js +80 -0
- package/dist/runner.d.ts +19 -0
- package/dist/runner.js +95 -0
- package/dist/types/diff.d.ts +18 -0
- package/dist/types/diff.js +2 -0
- package/dist/types/index.d.ts +58 -0
- package/dist/types/index.js +2 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ozperium
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# AgentSpec
|
|
2
|
+
|
|
3
|
+
> Testing framework for AI agents — Jest for non-deterministic AI behavior
|
|
4
|
+
|
|
5
|
+
AI agents are non-deterministic. When you change a prompt, swap a model, or update a tool, behavior shifts in ways you can't predict. AgentSpec lets you write tests that catch those shifts before they reach production.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g agentspec
|
|
11
|
+
agentspec init
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This creates `agentspec.yaml`:
|
|
15
|
+
|
|
16
|
+
```yaml
|
|
17
|
+
name: "my-agent-tests"
|
|
18
|
+
tests:
|
|
19
|
+
- name: "handles greeting"
|
|
20
|
+
input: "hello"
|
|
21
|
+
expect:
|
|
22
|
+
contains: "help"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Run tests:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
agentspec run
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Assertions for non-deterministic output
|
|
32
|
+
|
|
33
|
+
AgentSpec provides assertions designed for AI output, not just string equality:
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
tests:
|
|
37
|
+
- name: "handles expired token"
|
|
38
|
+
input: "my token expired"
|
|
39
|
+
expect:
|
|
40
|
+
contains: "refresh token"
|
|
41
|
+
not_contains: "I don't know"
|
|
42
|
+
max_latency_ms: 5000
|
|
43
|
+
|
|
44
|
+
- name: "routes billing question"
|
|
45
|
+
input: "I want a refund"
|
|
46
|
+
expect:
|
|
47
|
+
contains_any: ["billing", "refund", "support"]
|
|
48
|
+
tool_called: "transfer_to_billing"
|
|
49
|
+
|
|
50
|
+
- name: "response is semantically on-topic"
|
|
51
|
+
input: "how do I reset my password?"
|
|
52
|
+
expect:
|
|
53
|
+
semantically_similar: "reset password credentials"
|
|
54
|
+
min_confidence: 0.5
|
|
55
|
+
|
|
56
|
+
- name: "JSON output has correct structure"
|
|
57
|
+
input: "get user profile"
|
|
58
|
+
expect:
|
|
59
|
+
json_path: "data.email"
|
|
60
|
+
json_value: "user@example.com"
|
|
61
|
+
|
|
62
|
+
- name: "matches error code pattern"
|
|
63
|
+
input: "simulate error"
|
|
64
|
+
expect:
|
|
65
|
+
regex: "ERR-\\d{5}"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## LLM-as-judge
|
|
69
|
+
|
|
70
|
+
Use a local model to evaluate response quality — no API costs, full privacy:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Use local Ollama as judge
|
|
74
|
+
agentspec run --judge-endpoint http://127.0.0.1:11434/v1/chat/completions --judge-model qwen2.5:7b
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
tests:
|
|
79
|
+
- name: "response is helpful"
|
|
80
|
+
input: "How do I reset my password?"
|
|
81
|
+
expect:
|
|
82
|
+
llm_judge: "The response should explain how to reset a password"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Behavior diff reports
|
|
86
|
+
|
|
87
|
+
AgentSpec stores the last passing output per test. When behavior changes, you see exactly what shifted:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
⚠️ REGRESSION support agent > handles expired token
|
|
91
|
+
⚠ Behavior REGRESSED — test was passing, now failing
|
|
92
|
+
+ added: your, token, has, expired, please, contact, support
|
|
93
|
+
- removed: you, need, refresh, the, token, settings, security
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Testing real agents
|
|
97
|
+
|
|
98
|
+
Use `--endpoint` to test any HTTP-accessible agent:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
agentspec run --endpoint https://my-agent.example.com/chat
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
AgentSpec POSTs `{input: "..."}` to your endpoint and expects `{output: "..."}` in the response.
|
|
105
|
+
|
|
106
|
+
## CI/CD integration
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Exit code 1 on failure
|
|
110
|
+
agentspec run --ci
|
|
111
|
+
|
|
112
|
+
# JUnit XML for CI systems
|
|
113
|
+
agentspec run --junit > results.xml
|
|
114
|
+
|
|
115
|
+
# JSON output
|
|
116
|
+
agentspec run --json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### GitHub Action
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
- uses: agentspec/action@v1
|
|
123
|
+
with:
|
|
124
|
+
test-dir: tests
|
|
125
|
+
format: junit
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## CLI commands
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
agentspec run [--dir tests] [--ci] [--json] [--junit] [--watch] [--test "pattern"]
|
|
132
|
+
agentspec init
|
|
133
|
+
agentspec list [--dir tests]
|
|
134
|
+
agentspec version
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Why AgentSpec?
|
|
138
|
+
|
|
139
|
+
| Problem | AgentSpec |
|
|
140
|
+
|---------|-----------|
|
|
141
|
+
| Agent behavior changes with model updates | Regression tests catch the shift |
|
|
142
|
+
| "It worked yesterday" debugging | Diff reports show what changed |
|
|
143
|
+
| Manual testing before each deploy | CI/CD gates with `--ci` exit codes |
|
|
144
|
+
| Jest/Vitest don't handle non-deterministic output | `contains_any`, `regex`, `llm_judge`, `semantically_similar` |
|
|
145
|
+
| No CI integration for AI features | GitHub Action + JUnit XML |
|
|
146
|
+
|
|
147
|
+
## Roadmap
|
|
148
|
+
|
|
149
|
+
- [x] YAML test definitions
|
|
150
|
+
- [x] Core assertions (contains, not_contains, contains_any, contains_all, regex)
|
|
151
|
+
- [x] Metadata assertions (max_latency_ms, max_tokens, tool_called)
|
|
152
|
+
- [x] Semantic similarity (word overlap)
|
|
153
|
+
- [x] JSON path assertions
|
|
154
|
+
- [x] CLI with run/init/list commands
|
|
155
|
+
- [x] CI mode with exit codes
|
|
156
|
+
- [x] JUnit XML and JSON output
|
|
157
|
+
- [x] Test filtering
|
|
158
|
+
- [x] GitHub Action
|
|
159
|
+
- [x] **Behavior diff reports** — see what changed between runs
|
|
160
|
+
- [x] **LLM-as-judge** — use a local model (Ollama) to evaluate output quality
|
|
161
|
+
- [x] **HTTP agent adapter** — test real agents via `--endpoint`
|
|
162
|
+
- [ ] Auto-test generation from conversation history
|
|
163
|
+
- [ ] Python agent support
|
|
164
|
+
- [ ] Watch mode with file watcher
|
|
165
|
+
- [ ] Web dashboard
|
|
166
|
+
- [ ] GitHub PR integration (comment on PRs with behavior diffs)
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT
|
package/action.yml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: 'AgentSpec — AI Agent Tests'
|
|
2
|
+
description: 'Run AgentSpec tests for your AI agents in CI'
|
|
3
|
+
branding:
|
|
4
|
+
icon: 'check-circle'
|
|
5
|
+
color: 'blue'
|
|
6
|
+
inputs:
|
|
7
|
+
test-dir:
|
|
8
|
+
description: 'Directory containing test suites'
|
|
9
|
+
required: false
|
|
10
|
+
default: 'tests'
|
|
11
|
+
format:
|
|
12
|
+
description: 'Output format (text, json, junit)'
|
|
13
|
+
required: false
|
|
14
|
+
default: 'junit'
|
|
15
|
+
fail-on-failure:
|
|
16
|
+
description: 'Exit with error code on test failures'
|
|
17
|
+
required: false
|
|
18
|
+
default: 'true'
|
|
19
|
+
runs:
|
|
20
|
+
using: 'composite'
|
|
21
|
+
steps:
|
|
22
|
+
- name: Setup Node.js
|
|
23
|
+
uses: actions/setup-node@v4
|
|
24
|
+
with:
|
|
25
|
+
node-version: '20'
|
|
26
|
+
- name: Install AgentSpec
|
|
27
|
+
shell: bash
|
|
28
|
+
run: npm install -g agentspec
|
|
29
|
+
- name: Run AgentSpec tests
|
|
30
|
+
shell: bash
|
|
31
|
+
run: agentspec run --dir ${{ inputs.test-dir }} ${{ inputs.format == 'junit' && '--junit' || '' }} ${{ inputs.format == 'json' && '--json' || '' }} --ci
|
|
32
|
+
continue-on-error: ${{ inputs.fail-on-failure != 'true' }}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { TestExpect, AssertionResult } from '../types';
|
|
2
|
+
import { LLMJudgeConfig } from '../llm-judge';
|
|
3
|
+
export declare function setLLMJudgeConfig(config: LLMJudgeConfig | undefined): void;
|
|
4
|
+
export declare function getLLMJudgeConfig(): LLMJudgeConfig | undefined;
|
|
5
|
+
export declare function evaluateAssertions(output: string, expect: TestExpect, metadata?: {
|
|
6
|
+
tokens?: number;
|
|
7
|
+
latencyMs?: number;
|
|
8
|
+
toolsCalled?: string[];
|
|
9
|
+
}): AssertionResult[];
|
|
10
|
+
export declare function evaluateAssertionsAsync(output: string, expect: TestExpect, input: string, metadata?: {
|
|
11
|
+
tokens?: number;
|
|
12
|
+
latencyMs?: number;
|
|
13
|
+
toolsCalled?: string[];
|
|
14
|
+
}): Promise<AssertionResult[]>;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.setLLMJudgeConfig = setLLMJudgeConfig;
|
|
4
|
+
exports.getLLMJudgeConfig = getLLMJudgeConfig;
|
|
5
|
+
exports.evaluateAssertions = evaluateAssertions;
|
|
6
|
+
exports.evaluateAssertionsAsync = evaluateAssertionsAsync;
|
|
7
|
+
const llm_judge_1 = require("../llm-judge");
|
|
8
|
+
// Global LLM judge config (can be set via CLI or env)
|
|
9
|
+
let globalLLMJudgeConfig;
|
|
10
|
+
function setLLMJudgeConfig(config) {
|
|
11
|
+
globalLLMJudgeConfig = config;
|
|
12
|
+
}
|
|
13
|
+
function getLLMJudgeConfig() {
|
|
14
|
+
return globalLLMJudgeConfig;
|
|
15
|
+
}
|
|
16
|
+
function evaluateAssertions(output, expect, metadata) {
|
|
17
|
+
const results = [];
|
|
18
|
+
// Synchronous assertions only — llm_judge handled separately in evaluateAssertionsAsync
|
|
19
|
+
if (expect.contains !== undefined) {
|
|
20
|
+
const passed = output.includes(expect.contains);
|
|
21
|
+
results.push({
|
|
22
|
+
assertion: 'contains',
|
|
23
|
+
passed,
|
|
24
|
+
message: passed ? `Output contains "${expect.contains}"` : `Output does not contain "${expect.contains}"`,
|
|
25
|
+
expected: expect.contains,
|
|
26
|
+
actual: passed ? 'found' : 'not found',
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (expect.not_contains !== undefined) {
|
|
30
|
+
const passed = !output.includes(expect.not_contains);
|
|
31
|
+
results.push({
|
|
32
|
+
assertion: 'not_contains',
|
|
33
|
+
passed,
|
|
34
|
+
message: passed ? `Output does not contain "${expect.not_contains}"` : `Output contains "${expect.not_contains}"`,
|
|
35
|
+
expected: `not "${expect.not_contains}"`,
|
|
36
|
+
actual: passed ? 'absent' : 'present',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (expect.contains_any !== undefined) {
|
|
40
|
+
const found = expect.contains_any.find(s => output.includes(s));
|
|
41
|
+
const passed = !!found;
|
|
42
|
+
results.push({
|
|
43
|
+
assertion: 'contains_any',
|
|
44
|
+
passed,
|
|
45
|
+
message: passed ? `Output contains one of: "${found}"` : `Output contains none of: ${JSON.stringify(expect.contains_any)}`,
|
|
46
|
+
expected: JSON.stringify(expect.contains_any),
|
|
47
|
+
actual: found || 'none matched',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (expect.contains_all !== undefined) {
|
|
51
|
+
const missing = expect.contains_all.filter(s => !output.includes(s));
|
|
52
|
+
const passed = missing.length === 0;
|
|
53
|
+
results.push({
|
|
54
|
+
assertion: 'contains_all',
|
|
55
|
+
passed,
|
|
56
|
+
message: passed ? `Output contains all required strings` : `Missing: ${missing.join(', ')}`,
|
|
57
|
+
expected: JSON.stringify(expect.contains_all),
|
|
58
|
+
actual: passed ? 'all found' : `missing: ${missing.join(', ')}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (expect.regex !== undefined) {
|
|
62
|
+
try {
|
|
63
|
+
const re = new RegExp(expect.regex);
|
|
64
|
+
const passed = re.test(output);
|
|
65
|
+
results.push({
|
|
66
|
+
assertion: 'regex',
|
|
67
|
+
passed,
|
|
68
|
+
message: passed ? `Output matches /${expect.regex}/` : `Output does not match /${expect.regex}/`,
|
|
69
|
+
expected: `/${expect.regex}/`,
|
|
70
|
+
actual: passed ? 'matched' : 'no match',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
results.push({
|
|
75
|
+
assertion: 'regex',
|
|
76
|
+
passed: false,
|
|
77
|
+
message: `Invalid regex: ${expect.regex}`,
|
|
78
|
+
expected: expect.regex,
|
|
79
|
+
actual: 'invalid pattern',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (expect.max_latency_ms !== undefined && metadata?.latencyMs !== undefined) {
|
|
84
|
+
const passed = metadata.latencyMs <= expect.max_latency_ms;
|
|
85
|
+
results.push({
|
|
86
|
+
assertion: 'max_latency_ms',
|
|
87
|
+
passed,
|
|
88
|
+
message: passed ? `Latency ${metadata.latencyMs}ms ≤ ${expect.max_latency_ms}ms` : `Latency ${metadata.latencyMs}ms > ${expect.max_latency_ms}ms`,
|
|
89
|
+
expected: `≤ ${expect.max_latency_ms}ms`,
|
|
90
|
+
actual: `${metadata.latencyMs}ms`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (expect.max_tokens !== undefined && metadata?.tokens !== undefined) {
|
|
94
|
+
const passed = metadata.tokens <= expect.max_tokens;
|
|
95
|
+
results.push({
|
|
96
|
+
assertion: 'max_tokens',
|
|
97
|
+
passed,
|
|
98
|
+
message: passed ? `Tokens ${metadata.tokens} ≤ ${expect.max_tokens}` : `Tokens ${metadata.tokens} > ${expect.max_tokens}`,
|
|
99
|
+
expected: `≤ ${expect.max_tokens}`,
|
|
100
|
+
actual: `${metadata.tokens}`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (expect.tool_called !== undefined && metadata?.toolsCalled !== undefined) {
|
|
104
|
+
const passed = metadata.toolsCalled.includes(expect.tool_called);
|
|
105
|
+
results.push({
|
|
106
|
+
assertion: 'tool_called',
|
|
107
|
+
passed,
|
|
108
|
+
message: passed ? `Tool "${expect.tool_called}" was called` : `Tool "${expect.tool_called}" was not called. Called: ${metadata.toolsCalled.join(', ') || 'none'}`,
|
|
109
|
+
expected: expect.tool_called,
|
|
110
|
+
actual: passed ? 'called' : `not called (called: ${metadata.toolsCalled.join(', ') || 'none'})`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// json_path assertion: extract value from JSON output and compare
|
|
114
|
+
if (expect.json_path !== undefined) {
|
|
115
|
+
try {
|
|
116
|
+
const json = JSON.parse(output);
|
|
117
|
+
const value = extractJsonPath(json, expect.json_path);
|
|
118
|
+
if (expect.json_value !== undefined) {
|
|
119
|
+
const passed = JSON.stringify(value) === JSON.stringify(expect.json_value);
|
|
120
|
+
results.push({
|
|
121
|
+
assertion: 'json_path',
|
|
122
|
+
passed,
|
|
123
|
+
message: passed ? `json_path "${expect.json_path}" equals expected value` : `json_path "${expect.json_path}" mismatch`,
|
|
124
|
+
expected: JSON.stringify(expect.json_value),
|
|
125
|
+
actual: JSON.stringify(value),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const passed = value !== undefined;
|
|
130
|
+
results.push({
|
|
131
|
+
assertion: 'json_path',
|
|
132
|
+
passed,
|
|
133
|
+
message: passed ? `json_path "${expect.json_path}" found` : `json_path "${expect.json_path}" not found`,
|
|
134
|
+
expected: expect.json_path,
|
|
135
|
+
actual: JSON.stringify(value),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
results.push({
|
|
141
|
+
assertion: 'json_path',
|
|
142
|
+
passed: false,
|
|
143
|
+
message: `Output is not valid JSON or path not found: ${e instanceof Error ? e.message : String(e)}`,
|
|
144
|
+
expected: expect.json_path,
|
|
145
|
+
actual: 'parse error',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// semantically_similar: check if output is semantically similar to expected (using simple word overlap as MVP)
|
|
150
|
+
if (expect.semantically_similar !== undefined) {
|
|
151
|
+
const similarity = wordSimilarity(output, expect.semantically_similar);
|
|
152
|
+
const threshold = expect.min_confidence ?? 0.6;
|
|
153
|
+
const passed = similarity >= threshold;
|
|
154
|
+
results.push({
|
|
155
|
+
assertion: 'semantically_similar',
|
|
156
|
+
passed,
|
|
157
|
+
message: passed ? `Similarity ${similarity.toFixed(2)} ≥ ${threshold}` : `Similarity ${similarity.toFixed(2)} < ${threshold}`,
|
|
158
|
+
expected: `≥ ${threshold} similarity to "${expect.semantically_similar}"`,
|
|
159
|
+
actual: similarity.toFixed(2),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
// Async version that supports llm_judge assertion
|
|
165
|
+
async function evaluateAssertionsAsync(output, expect, input, metadata) {
|
|
166
|
+
const results = evaluateAssertions(output, expect, metadata);
|
|
167
|
+
if (expect.llm_judge !== undefined) {
|
|
168
|
+
try {
|
|
169
|
+
const judgeResult = await (0, llm_judge_1.llmJudge)(input, output, expect.llm_judge, globalLLMJudgeConfig);
|
|
170
|
+
results.push({
|
|
171
|
+
assertion: 'llm_judge',
|
|
172
|
+
passed: judgeResult.passed,
|
|
173
|
+
message: judgeResult.passed
|
|
174
|
+
? `LLM judge passed (score: ${judgeResult.score.toFixed(2)}): ${judgeResult.reasoning}`
|
|
175
|
+
: `LLM judge failed (score: ${judgeResult.score.toFixed(2)}): ${judgeResult.reasoning}`,
|
|
176
|
+
expected: expect.llm_judge,
|
|
177
|
+
actual: `score: ${judgeResult.score.toFixed(2)}`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
results.push({
|
|
182
|
+
assertion: 'llm_judge',
|
|
183
|
+
passed: false,
|
|
184
|
+
message: `LLM judge error: ${e instanceof Error ? e.message : String(e)}`,
|
|
185
|
+
expected: expect.llm_judge,
|
|
186
|
+
actual: 'error',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return results;
|
|
191
|
+
}
|
|
192
|
+
function extractJsonPath(obj, path) {
|
|
193
|
+
const parts = path.split('.');
|
|
194
|
+
let current = obj;
|
|
195
|
+
for (const part of parts) {
|
|
196
|
+
if (current === null || current === undefined)
|
|
197
|
+
return undefined;
|
|
198
|
+
if (typeof current !== 'object')
|
|
199
|
+
return undefined;
|
|
200
|
+
current = current[part];
|
|
201
|
+
}
|
|
202
|
+
return current;
|
|
203
|
+
}
|
|
204
|
+
function wordSimilarity(a, b) {
|
|
205
|
+
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 2));
|
|
206
|
+
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 2));
|
|
207
|
+
if (wordsA.size === 0 || wordsB.size === 0)
|
|
208
|
+
return 0;
|
|
209
|
+
let intersection = 0;
|
|
210
|
+
for (const w of Array.from(wordsA)) {
|
|
211
|
+
if (wordsB.has(w))
|
|
212
|
+
intersection++;
|
|
213
|
+
}
|
|
214
|
+
return (2 * intersection) / (wordsA.size + wordsB.size);
|
|
215
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
const runner_1 = require("./runner");
|
|
38
|
+
const loader_1 = require("./loader");
|
|
39
|
+
const reporter_1 = require("./reporter");
|
|
40
|
+
const diff_1 = require("./diff");
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
async function main() {
|
|
43
|
+
const args = process.argv.slice(2);
|
|
44
|
+
const command = args[0];
|
|
45
|
+
if (command === 'run') {
|
|
46
|
+
let dir = 'tests';
|
|
47
|
+
let ci = false;
|
|
48
|
+
let json = false;
|
|
49
|
+
let junit = false;
|
|
50
|
+
let watch = false;
|
|
51
|
+
let testFilter = null;
|
|
52
|
+
let judgeEndpoint = null;
|
|
53
|
+
let judgeModel = null;
|
|
54
|
+
let agentEndpoint = null;
|
|
55
|
+
for (let i = 1; i < args.length; i++) {
|
|
56
|
+
const arg = args[i];
|
|
57
|
+
if (arg === '--dir')
|
|
58
|
+
dir = args[++i];
|
|
59
|
+
else if (arg === '--ci')
|
|
60
|
+
ci = true;
|
|
61
|
+
else if (arg === '--json')
|
|
62
|
+
json = true;
|
|
63
|
+
else if (arg === '--junit')
|
|
64
|
+
junit = true;
|
|
65
|
+
else if (arg === '--watch')
|
|
66
|
+
watch = true;
|
|
67
|
+
else if (arg === '--test')
|
|
68
|
+
testFilter = args[++i];
|
|
69
|
+
else if (arg === '--judge-endpoint')
|
|
70
|
+
judgeEndpoint = args[++i];
|
|
71
|
+
else if (arg === '--judge-model')
|
|
72
|
+
judgeModel = args[++i];
|
|
73
|
+
else if (arg === '--endpoint')
|
|
74
|
+
agentEndpoint = args[++i];
|
|
75
|
+
else if (arg === '--help' || arg === '-h') {
|
|
76
|
+
printHelp();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Use HTTP agent if endpoint specified, otherwise mock
|
|
81
|
+
const isSelfTest = dir === 'tests';
|
|
82
|
+
const agent = agentEndpoint
|
|
83
|
+
? new (require('./http-agent').HTTPAgent)({ endpoint: agentEndpoint })
|
|
84
|
+
: isSelfTest
|
|
85
|
+
? new runner_1.MockAgent({}, true)
|
|
86
|
+
: new runner_1.MockAgent({
|
|
87
|
+
default: "I'll help you with that. Let me check the system for you.",
|
|
88
|
+
"my token expired, what do I do?": "You need to refresh the token. Go to Settings > Security to get a new refresh token.",
|
|
89
|
+
"I want a refund": "I'll transfer you to the billing team. Let me connect you with our support team.",
|
|
90
|
+
"what can you do?": "I can help with auth, billing, and general support questions.",
|
|
91
|
+
"xyzzy random input 12345": "I'm not sure about that, but I can help you find what you need.",
|
|
92
|
+
});
|
|
93
|
+
// Configure LLM judge if specified (must be before running tests)
|
|
94
|
+
if (judgeEndpoint || judgeModel) {
|
|
95
|
+
const { setLLMJudgeConfig } = await Promise.resolve().then(() => __importStar(require('./assertions')));
|
|
96
|
+
setLLMJudgeConfig({
|
|
97
|
+
endpoint: judgeEndpoint || undefined,
|
|
98
|
+
model: judgeModel || undefined,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const result = await (0, runner_1.runAll)(dir, agent, testFilter);
|
|
102
|
+
// Compute behavior diffs
|
|
103
|
+
const diffs = (0, diff_1.computeDiffs)(result.results);
|
|
104
|
+
// Save current run for future diffs
|
|
105
|
+
(0, diff_1.saveRun)(result.results);
|
|
106
|
+
if (json) {
|
|
107
|
+
console.log((0, reporter_1.formatResultsJSON)(result));
|
|
108
|
+
}
|
|
109
|
+
else if (junit) {
|
|
110
|
+
console.log((0, reporter_1.formatResultsJUnit)(result));
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
console.log((0, reporter_1.formatResults)(result));
|
|
114
|
+
// Show diff report if there are changes
|
|
115
|
+
if (diffs.length > 0) {
|
|
116
|
+
console.log((0, diff_1.formatDiffs)(diffs));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (ci) {
|
|
120
|
+
process.exit(result.failed > 0 ? 1 : 0);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (command === 'init') {
|
|
124
|
+
console.log('Creating agentspec.yaml...');
|
|
125
|
+
const { writeFileSync } = await Promise.resolve().then(() => __importStar(require('fs')));
|
|
126
|
+
writeFileSync(path.join(process.cwd(), 'agentspec.yaml'), `name: "my-agent-tests"
|
|
127
|
+
description: "Tests for my AI agent"
|
|
128
|
+
tests:
|
|
129
|
+
- name: "handles greeting"
|
|
130
|
+
input: "hello"
|
|
131
|
+
expect:
|
|
132
|
+
contains: "help"
|
|
133
|
+
`);
|
|
134
|
+
console.log('Created agentspec.yaml');
|
|
135
|
+
}
|
|
136
|
+
else if (command === 'list') {
|
|
137
|
+
let dir = 'tests';
|
|
138
|
+
if (args[1] === '--dir')
|
|
139
|
+
dir = args[2];
|
|
140
|
+
const suites = (0, loader_1.findTestSuites)(dir);
|
|
141
|
+
if (suites.length === 0) {
|
|
142
|
+
console.log('No test suites found in', dir);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
console.log(`Found ${suites.length} test suite(s):`);
|
|
146
|
+
for (const s of suites) {
|
|
147
|
+
console.log(` ${s}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (command === 'version' || command === '--version' || command === '-v') {
|
|
151
|
+
console.log('agentspec v0.1.0');
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
printHelp();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function printHelp() {
|
|
158
|
+
console.log(`
|
|
159
|
+
AgentSpec — Testing framework for AI agents
|
|
160
|
+
|
|
161
|
+
Usage:
|
|
162
|
+
agentspec run [options] Run tests
|
|
163
|
+
agentspec init Create agentspec.yaml
|
|
164
|
+
agentspec list List test suites
|
|
165
|
+
agentspec version Show version
|
|
166
|
+
|
|
167
|
+
Options for run:
|
|
168
|
+
--dir <path> Test directory (default: tests)
|
|
169
|
+
--ci Exit with code 1 on failure
|
|
170
|
+
--json Output results as JSON
|
|
171
|
+
--junit Output JUnit XML
|
|
172
|
+
--watch Watch for changes and re-run
|
|
173
|
+
--test <pattern> Run only matching tests
|
|
174
|
+
--judge-endpoint <url> LLM judge endpoint (OpenAI-compatible, default: Ollama)
|
|
175
|
+
--judge-model <name> LLM judge model name (default: local small model)
|
|
176
|
+
`);
|
|
177
|
+
}
|
|
178
|
+
main().catch(err => {
|
|
179
|
+
console.error('Error:', err.message);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
});
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { TestResult } from './types';
|
|
2
|
+
import { StoredRun, DiffEntry } from './types/diff';
|
|
3
|
+
export declare function ensureStoreDir(): string;
|
|
4
|
+
export declare function saveRun(results: TestResult[]): void;
|
|
5
|
+
export declare function loadPreviousRun(suite: string, test: string): StoredRun | null;
|
|
6
|
+
export declare function computeDiffs(currentResults: TestResult[]): DiffEntry[];
|
|
7
|
+
export declare function formatDiffs(diffs: DiffEntry[]): string;
|