@cursor/july 0.1.20 → 0.1.21
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/dist/bin/agent-serve.js +0 -0
- package/dist/channels/github/instrument.d.ts +20 -0
- package/dist/channels/github/instrument.d.ts.map +1 -0
- package/dist/channels/slack/api.d.ts +2 -0
- package/dist/channels/slack/api.d.ts.map +1 -1
- package/dist/channels/slack/api.js +3 -0
- package/dist/channels/slack/bot-mentions.d.ts +106 -0
- package/dist/channels/slack/bot-mentions.d.ts.map +1 -0
- package/dist/channels/slack/bot-mentions.js +243 -0
- package/dist/channels/slack/dispatch.d.ts +6 -0
- package/dist/channels/slack/dispatch.d.ts.map +1 -1
- package/dist/channels/slack/dispatch.js +24 -3
- package/dist/channels/slack/inbound.d.ts +10 -1
- package/dist/channels/slack/inbound.d.ts.map +1 -1
- package/dist/channels/slack/inbound.js +12 -4
- package/dist/channels/slack/index.d.ts +1 -0
- package/dist/channels/slack/index.d.ts.map +1 -1
- package/dist/channels/slack/index.js +1 -0
- package/dist/channels/slack/slack-channel.d.ts.map +1 -1
- package/dist/channels/slack/slack-channel.js +61 -22
- package/dist/channels/slack/types.d.ts +58 -0
- package/dist/channels/slack/types.d.ts.map +1 -1
- package/dist/docs/404.html +1 -1
- package/dist/docs/ab.html +2 -2
- package/dist/docs/assets/{app.T-5ImnM3.js → app.jDxLzWv4.js} +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.DoJHJjqF.js +1 -0
- package/dist/docs/assets/chunks/{VPLocalSearchBox.OMGMWH6H.js → VPLocalSearchBox.Y6bDR1-a.js} +1 -1
- package/dist/docs/assets/chunks/{theme.DLg4yB4D.js → theme.CLazCWlJ.js} +2 -2
- package/dist/docs/building-with-agents.html +2 -2
- package/dist/docs/concepts.html +2 -2
- package/dist/docs/deployment.html +2 -2
- package/dist/docs/evals.html +2 -2
- package/dist/docs/example-agents/approval-buddy.html +2 -2
- package/dist/docs/example-agents/benny.html +2 -2
- package/dist/docs/example-agents/bugbot.html +2 -2
- package/dist/docs/example-agents/codebase-wiki.html +2 -2
- package/dist/docs/example-agents/codeowners-review.html +2 -2
- package/dist/docs/example-agents/concierge.html +2 -2
- package/dist/docs/example-agents/fsd.html +2 -2
- package/dist/docs/example-agents/index.html +2 -2
- package/dist/docs/example-agents/knowledge-base.html +2 -2
- package/dist/docs/example-agents/oncall.html +2 -2
- package/dist/docs/example-agents/security-reviewer.html +2 -2
- package/dist/docs/example-agents/slack-agent.html +2 -2
- package/dist/docs/example-agents/weather-agent.html +2 -2
- package/dist/docs/guides/agent-to-agent.html +2 -2
- package/dist/docs/guides/cloud-runtime.html +2 -2
- package/dist/docs/guides/github.html +2 -2
- package/dist/docs/guides/human-in-the-loop.html +2 -2
- package/dist/docs/guides/mcp-oauth.html +2 -2
- package/dist/docs/guides/slack.html +2 -2
- package/dist/docs/guides/webhooks.html +2 -2
- package/dist/docs/hillclimbing.html +2 -2
- package/dist/docs/index.html +2 -2
- package/dist/docs/quickstart.html +2 -2
- package/dist/docs/reference/agent-config.html +2 -2
- package/dist/docs/reference/channels.html +2 -2
- package/dist/docs/reference/cli.html +2 -2
- package/dist/docs/reference/connections.html +2 -2
- package/dist/docs/reference/hooks.html +2 -2
- package/dist/docs/reference/http-api.html +2 -2
- package/dist/docs/reference/instructions.html +2 -2
- package/dist/docs/reference/playground.html +2 -2
- package/dist/docs/reference/project-layout.html +2 -2
- package/dist/docs/reference/prompt.html +2 -2
- package/dist/docs/reference/schedules.html +2 -2
- package/dist/docs/reference/sessions.html +2 -2
- package/dist/docs/reference/skills.html +2 -2
- package/dist/docs/reference/subagents.html +2 -2
- package/dist/docs/reference/tools.html +2 -2
- package/dist/docs/scaffolding-agents.html +2 -2
- package/dist/docs/storage.html +2 -2
- package/dist/docs/troubleshooting.html +2 -2
- package/dist/internal/json-dir-store.d.ts +32 -0
- package/dist/internal/json-dir-store.d.ts.map +1 -0
- package/dist/internal/json-dir-store.js +100 -0
- package/dist/internal/resolved-connections.d.ts +1 -1
- package/dist/internal/resolved-connections.d.ts.map +1 -1
- package/dist/internal/resolved-connections.js +10 -0
- package/dist/internal/session-engine.d.ts +4 -1
- package/dist/internal/session-engine.d.ts.map +1 -1
- package/dist/internal/session-engine.js +13 -2
- package/dist/playground/assets/index-CjOQ4hN9.css +1 -0
- package/dist/playground/assets/index-dshZQJCp.js +85 -0
- package/dist/playground/index.html +2 -2
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -0
- package/package.json +24 -24
- package/src/bin/agent-serve.version.test.ts +62 -0
- package/src/channels/github/api.test.ts +64 -0
- package/src/channels/github/auth.test.ts +105 -0
- package/src/channels/github/cursor-account.test.ts +204 -0
- package/src/channels/github/forward.test.ts +457 -0
- package/src/channels/github/github.test.ts +937 -0
- package/src/channels/github/replay.test.ts +179 -0
- package/src/channels/slack/api.post-message.test.ts +148 -0
- package/src/channels/slack/api.ts +5 -0
- package/src/channels/slack/approvals.test.ts +328 -0
- package/src/channels/slack/block-actions.test.ts +452 -0
- package/src/channels/slack/bot-mentions.test.ts +217 -0
- package/src/channels/slack/bot-mentions.ts +315 -0
- package/src/channels/slack/channel-watch.test.ts +363 -0
- package/src/channels/slack/cursor-account.test.ts +253 -0
- package/src/channels/slack/defaults.final-post.test.ts +182 -0
- package/src/channels/slack/dispatch.test.ts +795 -0
- package/src/channels/slack/dispatch.ts +27 -1
- package/src/channels/slack/eval-directive.test.ts +273 -0
- package/src/channels/slack/inbound.ts +25 -4
- package/src/channels/slack/index.ts +1 -0
- package/src/channels/slack/message-body.test.ts +54 -0
- package/src/channels/slack/nudge-store.test.ts +143 -0
- package/src/channels/slack/slack-channel.ts +66 -8
- package/src/channels/slack/slack.test.ts +391 -0
- package/src/channels/slack/stop.test.ts +23 -0
- package/src/channels/slack/thread-context.test.ts +202 -0
- package/src/channels/slack/types.ts +60 -0
- package/src/evals/assertions.test.ts +580 -0
- package/src/evals/expect.test.ts +144 -0
- package/src/evals/judge.test.ts +181 -0
- package/src/evals/loaders.test.ts +132 -0
- package/src/evals/matchers.test.ts +95 -0
- package/src/evals/reporters.test.ts +303 -0
- package/src/evals/run-facts.test.ts +259 -0
- package/src/internal/ab-snapshot.test.ts +325 -0
- package/src/internal/approval-gate.test.ts +49 -0
- package/src/internal/approvals.integration.test.ts +383 -0
- package/src/internal/authored-loaders.test.ts +31 -0
- package/src/internal/builtin-tools/reminders.test.ts +201 -0
- package/src/internal/channel-route-schema.test.ts +294 -0
- package/src/internal/chat-attach.test.ts +262 -0
- package/src/internal/cli-deploy.test.ts +1991 -0
- package/src/internal/cli-mcp.test.ts +789 -0
- package/src/internal/cli-skills.test.ts +133 -0
- package/src/internal/cli-slack.test.ts +1647 -0
- package/src/internal/cloud-merge.test.ts +74 -0
- package/src/internal/cron.test.ts +22 -0
- package/src/internal/cursor/account-mcp.test.ts +807 -0
- package/src/internal/cursor/backend-client.test.ts +591 -0
- package/src/internal/cursor/credentials.test.ts +351 -0
- package/src/internal/cursor/github-credentials.test.ts +136 -0
- package/src/internal/cursor-account-mcp-auth.test.ts +310 -0
- package/src/internal/cursor-account.integration.test.ts +441 -0
- package/src/internal/cursor-event-relay.test.ts +746 -0
- package/src/internal/cursor-github-credentials.integration.test.ts +271 -0
- package/src/internal/cursor-slack-relay.test.ts +525 -0
- package/src/internal/deploy-source.test.ts +111 -0
- package/src/internal/discovery.builtin-tools.test.ts +94 -0
- package/src/internal/discovery.concurrency.test.ts +60 -0
- package/src/internal/discovery.cursor-account.test.ts +133 -0
- package/src/internal/discovery.cwd.test.ts +83 -0
- package/src/internal/discovery.hosting.test.ts +80 -0
- package/src/internal/discovery.identity.test.ts +44 -0
- package/src/internal/docs-site.test.ts +66 -0
- package/src/internal/duration.test.ts +29 -0
- package/src/internal/eval-judge-model.test.ts +187 -0
- package/src/internal/eval-run-store.cancel.test.ts +142 -0
- package/src/internal/eval-run-store.storage.test.ts +211 -0
- package/src/internal/eval-runner.http.test.ts +403 -0
- package/src/internal/eval-runner.run.test.ts +928 -0
- package/src/internal/evals-client.test.ts +307 -0
- package/src/internal/event-mapper.test.ts +243 -0
- package/src/internal/github-fanout.test.ts +213 -0
- package/src/internal/handleAgentServeTrigger.test.ts +179 -0
- package/src/internal/host-kv.test.ts +82 -0
- package/src/internal/host-platforms.test.ts +126 -0
- package/src/internal/http-channel.test.ts +402 -0
- package/src/internal/init-project.test.ts +269 -0
- package/src/internal/install-cursor-skills.test.ts +262 -0
- package/src/internal/local-env.test.ts +100 -0
- package/src/internal/log-ring.test.ts +31 -0
- package/src/internal/logs-client.test.ts +350 -0
- package/src/internal/mcp-endpoint.test.ts +436 -0
- package/src/internal/mcp-host.test.ts +298 -0
- package/src/internal/mcp-oauth.test.ts +148 -0
- package/src/internal/net.test.ts +17 -0
- package/src/internal/peer-connections.test.ts +128 -0
- package/src/internal/peer-mcp.integration.test.ts +289 -0
- package/src/internal/playground/toolchain.test.ts +53 -0
- package/src/internal/playground-cli.test.ts +187 -0
- package/src/internal/playground-proxy.test.ts +376 -0
- package/src/internal/prompt-context.integration.test.ts +232 -0
- package/src/internal/prompt-context.test.ts +127 -0
- package/src/internal/reminder-runner.test.ts +390 -0
- package/src/internal/reminder-store.test.ts +53 -0
- package/src/internal/request-headers.test.ts +27 -0
- package/src/internal/resolve-prod-target.test.ts +787 -0
- package/src/internal/resolved-connections.test.ts +295 -0
- package/src/internal/resolved-connections.ts +18 -1
- package/src/internal/router.test.ts +57 -0
- package/src/internal/sdk-runner.test.ts +290 -0
- package/src/internal/session-engine.coalesce.test.ts +169 -0
- package/src/internal/session-engine.concurrency.test.ts +250 -0
- package/src/internal/session-engine.host-oauth-mcp.test.ts +110 -0
- package/src/internal/session-engine.interrupt.test.ts +577 -0
- package/src/internal/session-engine.storage.test.ts +547 -0
- package/src/internal/session-engine.ts +15 -1
- package/src/internal/session-urls.test.ts +28 -0
- package/src/internal/sessions-client.test.ts +518 -0
- package/src/internal/storage-coordinator.test.ts +517 -0
- package/src/internal/tool-call.test.ts +458 -0
- package/src/internal/tool-result.test.ts +52 -0
- package/src/internal/trajectory.approvals.test.ts +83 -0
- package/src/internal/trajectory.subagents.test.ts +198 -0
- package/src/internal/turn-governor.test.ts +137 -0
- package/src/internal/update-check.test.ts +485 -0
- package/src/internal/workspace.test.ts +81 -0
- package/src/storage-backends/cursor-hosted.test.ts +121 -0
- package/src/types.ts +12 -0
- package/dist/docs/assets/chunks/@localSearchIndexroot.CvejISXF.js +0 -1
- package/dist/playground/assets/index-CidizGZv.css +0 -1
- package/dist/playground/assets/index-CuiAKZaP.js +0 -85
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end coverage for the eval runner: discovery, the severity model,
|
|
3
|
+
* judges, verdicts, and reporters. `runSession` is mocked so a case can drive
|
|
4
|
+
* a scripted agent without a live model or an HTTP server.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { EvalJudgeUnavailableError } from "../evals/judge.js";
|
|
12
|
+
import { EVAL_ROOT_ENV } from "../evals/loaders.js";
|
|
13
|
+
import type {
|
|
14
|
+
EvalDefinition,
|
|
15
|
+
EvalReporter,
|
|
16
|
+
EvalRunResult,
|
|
17
|
+
EvalTestContext,
|
|
18
|
+
} from "../evals.js";
|
|
19
|
+
import { defineEval, defineEvalConfig, equals, similarity } from "../evals.js";
|
|
20
|
+
import type { SessionEvent } from "../types.js";
|
|
21
|
+
import * as evalRunner from "./eval-runner.js";
|
|
22
|
+
import * as trajectory from "./trajectory.js";
|
|
23
|
+
|
|
24
|
+
const runSession = vi.hoisted(() => vi.fn());
|
|
25
|
+
|
|
26
|
+
vi.mock("./run-client.js", async (importOriginal) => {
|
|
27
|
+
const actual = await importOriginal<typeof import("./run-client.js")>();
|
|
28
|
+
return { ...actual, runSession };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// `vi.mock` is hoisted above these imports, so the runner sees the scripted
|
|
32
|
+
// `runSession` even though the imports are static.
|
|
33
|
+
const { discoverEvals, expandEvalModule, runDiscoveredEvals } = evalRunner;
|
|
34
|
+
const { buildTrajectory } = trajectory;
|
|
35
|
+
|
|
36
|
+
let sequence = 0;
|
|
37
|
+
|
|
38
|
+
function event(type: string, data: unknown): SessionEvent {
|
|
39
|
+
sequence++;
|
|
40
|
+
return {
|
|
41
|
+
sessionId: "ses_1",
|
|
42
|
+
seq: sequence,
|
|
43
|
+
at: new Date(sequence * 1000).toISOString(),
|
|
44
|
+
turnId: "turn_1",
|
|
45
|
+
type,
|
|
46
|
+
data,
|
|
47
|
+
} as unknown as SessionEvent;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A settled turn that called `get_weather` and replied "Sunny, 72F". */
|
|
51
|
+
function weatherEvents(reply = "Sunny, 72F"): SessionEvent[] {
|
|
52
|
+
return [
|
|
53
|
+
event("message.received", { text: "weather?" }),
|
|
54
|
+
event("actions.requested", {
|
|
55
|
+
calls: [{ callId: "c1", toolName: "get_weather", args: { city: "NYC" } }],
|
|
56
|
+
}),
|
|
57
|
+
event("action.result", {
|
|
58
|
+
callId: "c1",
|
|
59
|
+
toolName: "get_weather",
|
|
60
|
+
output: { tempF: 72 },
|
|
61
|
+
isError: false,
|
|
62
|
+
}),
|
|
63
|
+
event("message.completed", { text: reply, finishReason: "stop" }),
|
|
64
|
+
event("turn.completed", {}),
|
|
65
|
+
event("session.waiting", {}),
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scriptTurn(events: SessionEvent[]): void {
|
|
70
|
+
runSession.mockImplementation(async () => {
|
|
71
|
+
const trajectory = buildTrajectory(events);
|
|
72
|
+
return {
|
|
73
|
+
sessionId: "ses_1",
|
|
74
|
+
continuationToken: "tok_1",
|
|
75
|
+
baseUrl: "http://127.0.0.1:3000",
|
|
76
|
+
events,
|
|
77
|
+
trajectory,
|
|
78
|
+
ok: trajectory.ok,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function runCase(
|
|
84
|
+
definition: EvalDefinition,
|
|
85
|
+
options: {
|
|
86
|
+
reporters?: EvalReporter[];
|
|
87
|
+
strict?: boolean;
|
|
88
|
+
judgeCaller?: (args: { prompt: string }) => Promise<string>;
|
|
89
|
+
judgeModel?: string;
|
|
90
|
+
skipReport?: boolean;
|
|
91
|
+
configReporters?: EvalReporter[];
|
|
92
|
+
} = {}
|
|
93
|
+
): Promise<EvalRunResult[]> {
|
|
94
|
+
const expanded = expandEvalModule("smoke", definition);
|
|
95
|
+
return runDiscoveredEvals({
|
|
96
|
+
projectRoot: "/unused",
|
|
97
|
+
baseUrl: "http://127.0.0.1:3000",
|
|
98
|
+
reporters: options.reporters,
|
|
99
|
+
skipReport: options.skipReport,
|
|
100
|
+
strict: options.strict,
|
|
101
|
+
judgeCaller: options.judgeCaller as never,
|
|
102
|
+
discovered: {
|
|
103
|
+
evals: expanded.map((e) => ({
|
|
104
|
+
id: e.id,
|
|
105
|
+
fileId: "smoke",
|
|
106
|
+
path: "/repo/evals/smoke.eval.ts",
|
|
107
|
+
definition: e.definition,
|
|
108
|
+
})),
|
|
109
|
+
config: {
|
|
110
|
+
maxConcurrency: 4,
|
|
111
|
+
judge:
|
|
112
|
+
options.judgeModel === undefined
|
|
113
|
+
? undefined
|
|
114
|
+
: { model: options.judgeModel },
|
|
115
|
+
reporters: options.configReporters,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
sequence = 0;
|
|
123
|
+
runSession.mockReset();
|
|
124
|
+
scriptTurn(weatherEvents());
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("verdicts", () => {
|
|
128
|
+
it("passes a case whose gates all pass", async () => {
|
|
129
|
+
const [result] = await runCase(
|
|
130
|
+
defineEval({
|
|
131
|
+
async test(t) {
|
|
132
|
+
await t.send("weather?");
|
|
133
|
+
t.succeeded();
|
|
134
|
+
t.calledTool("get_weather", { input: { city: "NYC" }, count: 1 });
|
|
135
|
+
t.messageIncludes("Sunny");
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
expect(result).toMatchObject({ verdict: "passed", ok: true });
|
|
140
|
+
expect(result?.assertions.every((a) => a.passed)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("fails a case with a missed gate and still records every assertion", async () => {
|
|
144
|
+
const [result] = await runCase(
|
|
145
|
+
defineEval({
|
|
146
|
+
async test(t) {
|
|
147
|
+
await t.send("weather?");
|
|
148
|
+
t.calledTool("send_email");
|
|
149
|
+
t.messageIncludes("Rainy");
|
|
150
|
+
t.succeeded();
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
expect(result).toMatchObject({ verdict: "failed", ok: false });
|
|
155
|
+
// Recorded assertions never throw, so one run reports all three outcomes.
|
|
156
|
+
expect(result?.assertions.map((a) => a.passed)).toEqual([
|
|
157
|
+
false,
|
|
158
|
+
false,
|
|
159
|
+
true,
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("scores a case that only misses a soft threshold, leaving ok true", async () => {
|
|
164
|
+
const [result] = await runCase(
|
|
165
|
+
defineEval({
|
|
166
|
+
async test(t) {
|
|
167
|
+
await t.send("weather?");
|
|
168
|
+
t.succeeded();
|
|
169
|
+
t.check(t.reply, similarity("Rainy and cold")).atLeast(0.9);
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
expect(result).toMatchObject({ verdict: "scored", ok: true });
|
|
174
|
+
expect(result?.assertions[1]).toMatchObject({
|
|
175
|
+
severity: "soft",
|
|
176
|
+
threshold: 0.9,
|
|
177
|
+
passed: false,
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("keeps the historical fallback when a case records no assertions", async () => {
|
|
182
|
+
const [passing] = await runCase(
|
|
183
|
+
defineEval({
|
|
184
|
+
async test(t) {
|
|
185
|
+
await t.send("weather?");
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
);
|
|
189
|
+
expect(passing?.verdict).toBe("passed");
|
|
190
|
+
|
|
191
|
+
scriptTurn([
|
|
192
|
+
event("message.received", { text: "hi" }),
|
|
193
|
+
event("turn.failed", { message: "model error" }),
|
|
194
|
+
]);
|
|
195
|
+
const [failing] = await runCase(
|
|
196
|
+
defineEval({
|
|
197
|
+
async test(t) {
|
|
198
|
+
await t.send("hi");
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
expect(failing?.verdict).toBe("failed");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("reports a thrown test body as a failed execution error", async () => {
|
|
206
|
+
const [result] = await runCase(
|
|
207
|
+
defineEval({
|
|
208
|
+
async test(t) {
|
|
209
|
+
await t.send("weather?");
|
|
210
|
+
t.succeeded();
|
|
211
|
+
throw new Error("helper blew up");
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
expect(result).toMatchObject({ verdict: "failed", ok: false });
|
|
216
|
+
expect(result?.error).toBe("helper blew up");
|
|
217
|
+
// Assertions recorded before the throw are still reported.
|
|
218
|
+
expect(result?.assertions).toHaveLength(1);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("t.skip and t.require", () => {
|
|
223
|
+
it("reports t.skip separately and keeps ok true", async () => {
|
|
224
|
+
const [result] = await runCase(
|
|
225
|
+
defineEval({
|
|
226
|
+
async test(t) {
|
|
227
|
+
t.skip("cloud runtime not available here");
|
|
228
|
+
await t.send("weather?");
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
expect(result).toMatchObject({
|
|
233
|
+
verdict: "skipped",
|
|
234
|
+
ok: true,
|
|
235
|
+
skipReason: "cloud runtime not available here",
|
|
236
|
+
});
|
|
237
|
+
expect(runSession).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("stops the body on a failed require without a duplicate execution error", async () => {
|
|
241
|
+
const seen: string[] = [];
|
|
242
|
+
const [result] = await runCase(
|
|
243
|
+
defineEval({
|
|
244
|
+
async test(t) {
|
|
245
|
+
await t.send("weather?");
|
|
246
|
+
await t.require(t.reply, equals("Rainy"));
|
|
247
|
+
seen.push("unreachable");
|
|
248
|
+
t.succeeded();
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
);
|
|
252
|
+
expect(seen).toEqual([]);
|
|
253
|
+
expect(result).toMatchObject({ verdict: "failed", ok: false });
|
|
254
|
+
// The recorded gate is the whole story; no duplicate execution error.
|
|
255
|
+
expect(result?.error).toBeUndefined();
|
|
256
|
+
expect(result?.assertions).toHaveLength(1);
|
|
257
|
+
expect(result?.assertions[0]).toMatchObject({
|
|
258
|
+
name: "require(equals)",
|
|
259
|
+
passed: false,
|
|
260
|
+
detail: "got Sunny, 72F",
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("returns the value and continues when require passes", async () => {
|
|
265
|
+
const [result] = await runCase(
|
|
266
|
+
defineEval({
|
|
267
|
+
async test(t) {
|
|
268
|
+
await t.send("weather?");
|
|
269
|
+
const reply = await t.require(t.reply, equals("Sunny, 72F"));
|
|
270
|
+
t.check(reply, equals("Sunny, 72F"));
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
);
|
|
274
|
+
expect(result?.verdict).toBe("passed");
|
|
275
|
+
expect(result?.assertions).toHaveLength(2);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("turn scoping", () => {
|
|
280
|
+
it("grades an intermediate turn before the next send overwrites the reply", async () => {
|
|
281
|
+
const first = weatherEvents("Draft: Best regards");
|
|
282
|
+
const second = [
|
|
283
|
+
event("message.received", { text: "send it" }),
|
|
284
|
+
event("actions.requested", {
|
|
285
|
+
calls: [{ callId: "c2", toolName: "send_email", args: {} }],
|
|
286
|
+
}),
|
|
287
|
+
event("action.result", {
|
|
288
|
+
callId: "c2",
|
|
289
|
+
toolName: "send_email",
|
|
290
|
+
output: "sent",
|
|
291
|
+
isError: false,
|
|
292
|
+
}),
|
|
293
|
+
event("message.completed", { text: "Sent.", finishReason: "stop" }),
|
|
294
|
+
event("turn.completed", {}),
|
|
295
|
+
];
|
|
296
|
+
const scripted = [first, second];
|
|
297
|
+
runSession.mockImplementation(async () => {
|
|
298
|
+
const events = scripted.shift() ?? [];
|
|
299
|
+
const trajectory = buildTrajectory(events);
|
|
300
|
+
return {
|
|
301
|
+
sessionId: "ses_1",
|
|
302
|
+
continuationToken: "tok",
|
|
303
|
+
baseUrl: "http://127.0.0.1:3000",
|
|
304
|
+
events,
|
|
305
|
+
trajectory,
|
|
306
|
+
ok: trajectory.ok,
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const [result] = await runCase(
|
|
311
|
+
defineEval({
|
|
312
|
+
async test(t) {
|
|
313
|
+
const draft = await t.send("draft it");
|
|
314
|
+
draft.messageIncludes("Best regards");
|
|
315
|
+
draft.notCalledTool("send_email");
|
|
316
|
+
|
|
317
|
+
await t.send("send it");
|
|
318
|
+
t.calledTool("send_email");
|
|
319
|
+
expect(t.turns).toHaveLength(2);
|
|
320
|
+
expect(t.reply).toBe("Sent.");
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
);
|
|
324
|
+
expect(result?.verdict).toBe("passed");
|
|
325
|
+
expect(result?.assertions.map((a) => a.name)).toEqual([
|
|
326
|
+
"turn[1].messageIncludes(Best regards)",
|
|
327
|
+
"turn[1].notCalledTool(send_email)",
|
|
328
|
+
"calledTool(send_email)",
|
|
329
|
+
]);
|
|
330
|
+
expect(result?.inputs).toEqual(["draft it", "send it"]);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("judges", () => {
|
|
335
|
+
const judgeReply = "CHOICE: Y\nREASON: it names a source";
|
|
336
|
+
|
|
337
|
+
it("records a judge score as a soft assertion", async () => {
|
|
338
|
+
const judgeCaller = vi.fn(async () => judgeReply);
|
|
339
|
+
const [result] = await runCase(
|
|
340
|
+
defineEval({
|
|
341
|
+
async test(t) {
|
|
342
|
+
await t.send("weather?");
|
|
343
|
+
t.judge.closedQA("mentions the temperature");
|
|
344
|
+
},
|
|
345
|
+
}),
|
|
346
|
+
{ judgeCaller, judgeModel: "gpt-5.4-mini" }
|
|
347
|
+
);
|
|
348
|
+
expect(result?.verdict).toBe("passed");
|
|
349
|
+
expect(result?.assertions[0]).toMatchObject({
|
|
350
|
+
name: "judge.closedQA(mentions the temperature)",
|
|
351
|
+
severity: "soft",
|
|
352
|
+
score: 1,
|
|
353
|
+
passed: true,
|
|
354
|
+
detail: "Y (1.00) — it names a source",
|
|
355
|
+
});
|
|
356
|
+
expect(judgeCaller).toHaveBeenCalledTimes(1);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("grades t.reply by default and an explicit value with `on`", async () => {
|
|
360
|
+
const prompts: string[] = [];
|
|
361
|
+
await runCase(
|
|
362
|
+
defineEval({
|
|
363
|
+
async test(t) {
|
|
364
|
+
await t.send("weather?");
|
|
365
|
+
t.judge.closedQA("criterion");
|
|
366
|
+
t.judge.closedQA("criterion", { on: "a different value" });
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
{
|
|
370
|
+
judgeModel: "gpt-5.4-mini",
|
|
371
|
+
judgeCaller: async ({ prompt }) => {
|
|
372
|
+
prompts.push(prompt);
|
|
373
|
+
return judgeReply;
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
);
|
|
377
|
+
expect(prompts[0]).toContain("Sunny, 72F");
|
|
378
|
+
expect(prompts[1]).toContain("a different value");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("marks the eval scored when a judge misses its bar", async () => {
|
|
382
|
+
const [result] = await runCase(
|
|
383
|
+
defineEval({
|
|
384
|
+
async test(t) {
|
|
385
|
+
await t.send("weather?");
|
|
386
|
+
t.succeeded();
|
|
387
|
+
t.judge.factuality("Rainy and 40F").atLeast(0.8);
|
|
388
|
+
},
|
|
389
|
+
}),
|
|
390
|
+
{
|
|
391
|
+
judgeModel: "gpt-5.4-mini",
|
|
392
|
+
judgeCaller: async () => "CHOICE: D\nREASON: the values disagree",
|
|
393
|
+
}
|
|
394
|
+
);
|
|
395
|
+
expect(result?.verdict).toBe("scored");
|
|
396
|
+
expect(result?.assertions[1]).toMatchObject({ score: 0, threshold: 0.8 });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("fails the eval when a judge is promoted to a gate and misses", async () => {
|
|
400
|
+
const [result] = await runCase(
|
|
401
|
+
defineEval({
|
|
402
|
+
async test(t) {
|
|
403
|
+
await t.send("weather?");
|
|
404
|
+
t.judge.factuality("Rainy and 40F").gate(0.8);
|
|
405
|
+
},
|
|
406
|
+
}),
|
|
407
|
+
{ judgeModel: "gpt-5.4-mini", judgeCaller: async () => "CHOICE: D" }
|
|
408
|
+
);
|
|
409
|
+
expect(result?.verdict).toBe("failed");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("records a failed gate when no judge model is configured anywhere", async () => {
|
|
413
|
+
const judgeCaller = vi.fn(async () => judgeReply);
|
|
414
|
+
const [result] = await runCase(
|
|
415
|
+
defineEval({
|
|
416
|
+
async test(t) {
|
|
417
|
+
await t.send("weather?");
|
|
418
|
+
t.judge.closedQA("criterion");
|
|
419
|
+
},
|
|
420
|
+
}),
|
|
421
|
+
{ judgeCaller }
|
|
422
|
+
);
|
|
423
|
+
expect(result?.verdict).toBe("failed");
|
|
424
|
+
expect(result?.assertions[0]).toMatchObject({
|
|
425
|
+
passed: false,
|
|
426
|
+
detail: expect.stringContaining("no judge model configured"),
|
|
427
|
+
});
|
|
428
|
+
expect(judgeCaller).not.toHaveBeenCalled();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("prefers the per-eval judge model over the config default", async () => {
|
|
432
|
+
const models: unknown[] = [];
|
|
433
|
+
await runCase(
|
|
434
|
+
defineEval({
|
|
435
|
+
judge: { model: "claude-opus-4.8" },
|
|
436
|
+
async test(t) {
|
|
437
|
+
await t.send("weather?");
|
|
438
|
+
t.judge.closedQA("criterion");
|
|
439
|
+
t.judge.closedQA("criterion", { model: "gpt-5.4-mini" });
|
|
440
|
+
},
|
|
441
|
+
}),
|
|
442
|
+
{
|
|
443
|
+
judgeModel: "config-default",
|
|
444
|
+
judgeCaller: async (args: { prompt: string; model?: unknown }) => {
|
|
445
|
+
models.push(args.model);
|
|
446
|
+
return judgeReply;
|
|
447
|
+
},
|
|
448
|
+
}
|
|
449
|
+
);
|
|
450
|
+
expect(models).toEqual(["claude-opus-4.8", "gpt-5.4-mini"]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("skips the eval visibly when the judge has no credentials", async () => {
|
|
454
|
+
const [result] = await runCase(
|
|
455
|
+
defineEval({
|
|
456
|
+
async test(t) {
|
|
457
|
+
await t.send("weather?");
|
|
458
|
+
t.succeeded();
|
|
459
|
+
t.judge.closedQA("criterion");
|
|
460
|
+
},
|
|
461
|
+
}),
|
|
462
|
+
{
|
|
463
|
+
judgeModel: "gpt-5.4-mini",
|
|
464
|
+
judgeCaller: async () => {
|
|
465
|
+
throw new EvalJudgeUnavailableError("no Cursor credentials");
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
);
|
|
469
|
+
// A missing credential is an environment gap, not a quality regression.
|
|
470
|
+
expect(result).toMatchObject({ verdict: "skipped", ok: true });
|
|
471
|
+
expect(result?.skipReason).toContain("no Cursor credentials");
|
|
472
|
+
expect(result?.logs).toContain("judge skipped: no Cursor credentials");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("still reports a failed gate when the judge is unavailable", async () => {
|
|
476
|
+
// Otherwise a machine without judge credentials would turn a genuinely red
|
|
477
|
+
// eval green: the skip would bury the deterministic failure.
|
|
478
|
+
const [result] = await runCase(
|
|
479
|
+
defineEval({
|
|
480
|
+
async test(t) {
|
|
481
|
+
await t.send("weather?");
|
|
482
|
+
t.calledTool("send_email");
|
|
483
|
+
t.judge.closedQA("criterion");
|
|
484
|
+
},
|
|
485
|
+
}),
|
|
486
|
+
{
|
|
487
|
+
judgeModel: "gpt-5.4-mini",
|
|
488
|
+
judgeCaller: async () => {
|
|
489
|
+
throw new EvalJudgeUnavailableError("no Cursor credentials");
|
|
490
|
+
},
|
|
491
|
+
}
|
|
492
|
+
);
|
|
493
|
+
expect(result).toMatchObject({ verdict: "failed", ok: false });
|
|
494
|
+
expect(result?.skipReason).toBeUndefined();
|
|
495
|
+
expect(result?.logs.join("\n")).toContain("a gate already failed");
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("skips rather than scoring when only soft judge signal is missing", async () => {
|
|
499
|
+
const [result] = await runCase(
|
|
500
|
+
defineEval({
|
|
501
|
+
async test(t) {
|
|
502
|
+
await t.send("weather?");
|
|
503
|
+
t.succeeded();
|
|
504
|
+
t.judge.closedQA("criterion").atLeast(0.6);
|
|
505
|
+
},
|
|
506
|
+
}),
|
|
507
|
+
{
|
|
508
|
+
judgeModel: "gpt-5.4-mini",
|
|
509
|
+
judgeCaller: async () => {
|
|
510
|
+
throw new EvalJudgeUnavailableError("no Cursor credentials");
|
|
511
|
+
},
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
expect(result).toMatchObject({ verdict: "skipped", ok: true });
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("fails the assertion when the judge reply is unparseable", async () => {
|
|
518
|
+
const [result] = await runCase(
|
|
519
|
+
defineEval({
|
|
520
|
+
async test(t) {
|
|
521
|
+
await t.send("weather?");
|
|
522
|
+
t.judge.closedQA("criterion");
|
|
523
|
+
},
|
|
524
|
+
}),
|
|
525
|
+
{ judgeModel: "gpt-5.4-mini", judgeCaller: async () => "I refuse" }
|
|
526
|
+
);
|
|
527
|
+
expect(result?.verdict).toBe("failed");
|
|
528
|
+
expect(result?.assertions[0]?.detail).toMatch(/had no choice/);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("runs several judges concurrently and waits for all of them", async () => {
|
|
532
|
+
let inFlight = 0;
|
|
533
|
+
let maxInFlight = 0;
|
|
534
|
+
const [result] = await runCase(
|
|
535
|
+
defineEval({
|
|
536
|
+
async test(t) {
|
|
537
|
+
await t.send("weather?");
|
|
538
|
+
t.judge.closedQA("first");
|
|
539
|
+
t.judge.closedQA("second");
|
|
540
|
+
t.judge.closedQA("third");
|
|
541
|
+
},
|
|
542
|
+
}),
|
|
543
|
+
{
|
|
544
|
+
judgeModel: "gpt-5.4-mini",
|
|
545
|
+
judgeCaller: async () => {
|
|
546
|
+
inFlight++;
|
|
547
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
548
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
549
|
+
inFlight--;
|
|
550
|
+
return judgeReply;
|
|
551
|
+
},
|
|
552
|
+
}
|
|
553
|
+
);
|
|
554
|
+
expect(maxInFlight).toBeGreaterThan(1);
|
|
555
|
+
expect(result?.assertions.map((a) => a.score)).toEqual([1, 1, 1]);
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
describe("t.judge.model", () => {
|
|
560
|
+
it("gives a custom judge the configured model and credentials", async () => {
|
|
561
|
+
const seen: Array<{ prompt: string; model?: unknown }> = [];
|
|
562
|
+
const [result] = await runCase(
|
|
563
|
+
defineEval({
|
|
564
|
+
async test(t) {
|
|
565
|
+
await t.send("weather?");
|
|
566
|
+
// The shape a domain judge uses: one raw call, then score the parse.
|
|
567
|
+
const reply = await t.judge.model("Grade these findings: [...]");
|
|
568
|
+
t.score("recall", JSON.parse(reply).recall as number).atLeast(0.6);
|
|
569
|
+
},
|
|
570
|
+
}),
|
|
571
|
+
{
|
|
572
|
+
judgeModel: "gpt-5.4-mini",
|
|
573
|
+
judgeCaller: async (args: { prompt: string; model?: unknown }) => {
|
|
574
|
+
seen.push(args);
|
|
575
|
+
return '{"recall":0.8}';
|
|
576
|
+
},
|
|
577
|
+
}
|
|
578
|
+
);
|
|
579
|
+
expect(seen).toEqual([
|
|
580
|
+
{ prompt: "Grade these findings: [...]", model: "gpt-5.4-mini" },
|
|
581
|
+
]);
|
|
582
|
+
expect(result?.verdict).toBe("passed");
|
|
583
|
+
expect(result?.assertions[0]).toMatchObject({
|
|
584
|
+
name: "score(recall)",
|
|
585
|
+
score: 0.8,
|
|
586
|
+
threshold: 0.6,
|
|
587
|
+
passed: true,
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("throws into the test body when no judge model is configured", async () => {
|
|
592
|
+
const [result] = await runCase(
|
|
593
|
+
defineEval({
|
|
594
|
+
async test(t) {
|
|
595
|
+
await t.send("weather?");
|
|
596
|
+
await t.judge.model("grade this");
|
|
597
|
+
},
|
|
598
|
+
})
|
|
599
|
+
);
|
|
600
|
+
expect(result?.verdict).toBe("failed");
|
|
601
|
+
expect(result?.error).toMatch(/no judge model configured/);
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
describe("reporters", () => {
|
|
606
|
+
function recordingReporter(): {
|
|
607
|
+
reporter: EvalReporter;
|
|
608
|
+
calls: string[];
|
|
609
|
+
} {
|
|
610
|
+
const calls: string[] = [];
|
|
611
|
+
return {
|
|
612
|
+
calls,
|
|
613
|
+
reporter: {
|
|
614
|
+
onRunStart: (evals, target) => {
|
|
615
|
+
calls.push(
|
|
616
|
+
`start:${evals.map((e) => e.id).join(",")}:${target.mode}`
|
|
617
|
+
);
|
|
618
|
+
},
|
|
619
|
+
onEvalComplete: (result) => {
|
|
620
|
+
calls.push(`case:${result.id}:${result.verdict}`);
|
|
621
|
+
},
|
|
622
|
+
onRunComplete: (summary) => {
|
|
623
|
+
calls.push(
|
|
624
|
+
`complete:${summary.passed}p/${summary.failed}f/${summary.scored}s`
|
|
625
|
+
);
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const twoCases = defineEval({
|
|
632
|
+
cases: [
|
|
633
|
+
{
|
|
634
|
+
id: "good",
|
|
635
|
+
async test(t) {
|
|
636
|
+
await t.send("weather?");
|
|
637
|
+
t.succeeded();
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
id: "bad",
|
|
642
|
+
async test(t) {
|
|
643
|
+
await t.send("weather?");
|
|
644
|
+
t.calledTool("missing");
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
],
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("drives the full lifecycle for a CLI reporter", async () => {
|
|
651
|
+
const { reporter, calls } = recordingReporter();
|
|
652
|
+
await runCase(twoCases, { reporters: [reporter] });
|
|
653
|
+
expect(calls).toEqual([
|
|
654
|
+
"start:smoke/good,smoke/bad:local",
|
|
655
|
+
"case:smoke/good:passed",
|
|
656
|
+
"case:smoke/bad:failed",
|
|
657
|
+
"complete:1p/1f/0s",
|
|
658
|
+
]);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("observes every case from a config reporter", async () => {
|
|
662
|
+
const { reporter, calls } = recordingReporter();
|
|
663
|
+
await runCase(twoCases, { configReporters: [reporter] });
|
|
664
|
+
expect(calls.filter((c) => c.startsWith("case:"))).toEqual([
|
|
665
|
+
"case:smoke/good:passed",
|
|
666
|
+
"case:smoke/bad:failed",
|
|
667
|
+
]);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("scopes a per-eval reporter to its own case", async () => {
|
|
671
|
+
const { reporter, calls } = recordingReporter();
|
|
672
|
+
await runCase(
|
|
673
|
+
defineEval({
|
|
674
|
+
cases: [
|
|
675
|
+
{
|
|
676
|
+
id: "watched",
|
|
677
|
+
reporters: [reporter],
|
|
678
|
+
async test(t) {
|
|
679
|
+
await t.send("weather?");
|
|
680
|
+
t.succeeded();
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
id: "unwatched",
|
|
685
|
+
async test(t) {
|
|
686
|
+
await t.send("weather?");
|
|
687
|
+
t.succeeded();
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
],
|
|
691
|
+
})
|
|
692
|
+
);
|
|
693
|
+
// Run hooks still fire, but only the referencing case reports a result.
|
|
694
|
+
expect(calls).toEqual([
|
|
695
|
+
"start:smoke/watched,smoke/unwatched:local",
|
|
696
|
+
"case:smoke/watched:passed",
|
|
697
|
+
"complete:2p/0f/0s",
|
|
698
|
+
]);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("suppresses config and per-eval reporters under skipReport", async () => {
|
|
702
|
+
const config = recordingReporter();
|
|
703
|
+
const perEval = recordingReporter();
|
|
704
|
+
const cli = recordingReporter();
|
|
705
|
+
await runCase(
|
|
706
|
+
defineEval({
|
|
707
|
+
reporters: [perEval.reporter],
|
|
708
|
+
async test(t) {
|
|
709
|
+
await t.send("weather?");
|
|
710
|
+
t.succeeded();
|
|
711
|
+
},
|
|
712
|
+
}),
|
|
713
|
+
{
|
|
714
|
+
configReporters: [config.reporter],
|
|
715
|
+
reporters: [cli.reporter],
|
|
716
|
+
skipReport: true,
|
|
717
|
+
}
|
|
718
|
+
);
|
|
719
|
+
expect(config.calls).toEqual([]);
|
|
720
|
+
expect(perEval.calls).toEqual([]);
|
|
721
|
+
// CLI-attached reporters are explicit, so they still run.
|
|
722
|
+
expect(cli.calls).toContain("case:smoke:passed");
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("reports strict mode on the summary so reporters can gate on it", async () => {
|
|
726
|
+
let sawStrict: boolean | undefined;
|
|
727
|
+
await runCase(
|
|
728
|
+
defineEval({
|
|
729
|
+
async test(t) {
|
|
730
|
+
await t.send("weather?");
|
|
731
|
+
t.succeeded();
|
|
732
|
+
},
|
|
733
|
+
}),
|
|
734
|
+
{
|
|
735
|
+
strict: true,
|
|
736
|
+
reporters: [
|
|
737
|
+
{
|
|
738
|
+
onRunComplete: (summary) => {
|
|
739
|
+
sawStrict = summary.strict;
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
}
|
|
744
|
+
);
|
|
745
|
+
expect(sawStrict).toBe(true);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("keeps running when a reporter throws", async () => {
|
|
749
|
+
const results = await runCase(twoCases, {
|
|
750
|
+
reporters: [
|
|
751
|
+
{
|
|
752
|
+
onEvalComplete: () => {
|
|
753
|
+
throw new Error("upload failed");
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
],
|
|
757
|
+
});
|
|
758
|
+
expect(results.map((r) => r.verdict)).toEqual(["passed", "failed"]);
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
describe("metadata and metrics", () => {
|
|
763
|
+
it("carries eval metadata and tags onto the result", async () => {
|
|
764
|
+
const [result] = await runCase(
|
|
765
|
+
defineEval({
|
|
766
|
+
tags: ["smoke"],
|
|
767
|
+
metadata: { owner: "agents", suite: "weather" },
|
|
768
|
+
async test(t) {
|
|
769
|
+
await t.send("weather?");
|
|
770
|
+
t.succeeded();
|
|
771
|
+
t.metric("recall", "40.0% (4/10)");
|
|
772
|
+
},
|
|
773
|
+
})
|
|
774
|
+
);
|
|
775
|
+
expect(result).toMatchObject({
|
|
776
|
+
tags: ["smoke"],
|
|
777
|
+
metadata: { owner: "agents", suite: "weather" },
|
|
778
|
+
metrics: { recall: "40.0% (4/10)" },
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("lets a case-level metadata key override the file-level one", async () => {
|
|
783
|
+
const [result] = await runCase(
|
|
784
|
+
defineEval({
|
|
785
|
+
metadata: { owner: "agents", suite: "weather" },
|
|
786
|
+
cases: [
|
|
787
|
+
{
|
|
788
|
+
id: "override",
|
|
789
|
+
metadata: { owner: "security" },
|
|
790
|
+
async test(t) {
|
|
791
|
+
await t.send("weather?");
|
|
792
|
+
t.succeeded();
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
],
|
|
796
|
+
})
|
|
797
|
+
);
|
|
798
|
+
expect(result?.metadata).toEqual({ owner: "security", suite: "weather" });
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
describe("expandEvalModule", () => {
|
|
803
|
+
it("expands a single defineEval to the file id", () => {
|
|
804
|
+
const expanded = expandEvalModule(
|
|
805
|
+
"weather",
|
|
806
|
+
defineEval({ test: async () => undefined })
|
|
807
|
+
);
|
|
808
|
+
expect(expanded.map((e) => e.id)).toEqual(["weather"]);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it("fans a default-exported array out with zero-padded ids", () => {
|
|
812
|
+
const rows = ["a", "b", "c"];
|
|
813
|
+
const expanded = expandEvalModule(
|
|
814
|
+
"sql",
|
|
815
|
+
rows.map((row) =>
|
|
816
|
+
defineEval({ description: row, test: async () => undefined })
|
|
817
|
+
) as never
|
|
818
|
+
);
|
|
819
|
+
expect(expanded.map((e) => e.id)).toEqual([
|
|
820
|
+
"sql/0000",
|
|
821
|
+
"sql/0001",
|
|
822
|
+
"sql/0002",
|
|
823
|
+
]);
|
|
824
|
+
expect(expanded[1]?.definition.description).toBe("b");
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it("rejects a non-eval default export", () => {
|
|
828
|
+
expect(() => expandEvalModule("x", { nope: true })).toThrow(
|
|
829
|
+
/must be defineEval\(\{ \.\.\. \}\) or an array of them/
|
|
830
|
+
);
|
|
831
|
+
expect(() => expandEvalModule("x", [])).toThrow(/array is empty/);
|
|
832
|
+
expect(() => expandEvalModule("x", [{ nope: true }])).toThrow(
|
|
833
|
+
/array entry 0 must be defineEval/
|
|
834
|
+
);
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
describe("discoverEvals", () => {
|
|
839
|
+
let projectRoot: string;
|
|
840
|
+
|
|
841
|
+
beforeEach(async () => {
|
|
842
|
+
projectRoot = await mkdtemp(join(tmpdir(), "agentkit-eval-discovery-"));
|
|
843
|
+
await mkdir(join(projectRoot, "evals", "weather"), { recursive: true });
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
afterEach(async () => {
|
|
847
|
+
await rm(projectRoot, { recursive: true, force: true });
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
/** Map on-disk eval paths to in-memory definitions. */
|
|
851
|
+
function importer(
|
|
852
|
+
modules: Record<string, unknown>
|
|
853
|
+
): (path: string) => Promise<Record<string, unknown>> {
|
|
854
|
+
return async (absolutePath: string) => {
|
|
855
|
+
for (const [suffix, mod] of Object.entries(modules)) {
|
|
856
|
+
if (absolutePath.endsWith(suffix)) {
|
|
857
|
+
return { default: mod };
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
throw new Error(`unexpected import: ${absolutePath}`);
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
it("derives ids from paths and loads the config", async () => {
|
|
865
|
+
await writeFile(join(projectRoot, "evals/evals.config.ts"), "", "utf8");
|
|
866
|
+
await writeFile(join(projectRoot, "evals/smoke.eval.ts"), "", "utf8");
|
|
867
|
+
await writeFile(join(projectRoot, "evals/weather/nyc.eval.ts"), "", "utf8");
|
|
868
|
+
// A sibling helper that is not an eval file must be ignored.
|
|
869
|
+
await writeFile(join(projectRoot, "evals/weather/shared.ts"), "", "utf8");
|
|
870
|
+
|
|
871
|
+
const { evals, config } = await discoverEvals(
|
|
872
|
+
projectRoot,
|
|
873
|
+
importer({
|
|
874
|
+
"evals.config.ts": defineEvalConfig({ maxConcurrency: 7 }),
|
|
875
|
+
"smoke.eval.ts": defineEval({ test: async () => undefined }),
|
|
876
|
+
"nyc.eval.ts": defineEval({ test: async () => undefined }),
|
|
877
|
+
})
|
|
878
|
+
);
|
|
879
|
+
expect(evals.map((e) => e.id)).toEqual(["smoke", "weather/nyc"]);
|
|
880
|
+
expect(config.maxConcurrency).toBe(7);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it("points fixture loaders at the app root", async () => {
|
|
884
|
+
await writeFile(join(projectRoot, "evals/evals.config.ts"), "", "utf8");
|
|
885
|
+
await discoverEvals(
|
|
886
|
+
projectRoot,
|
|
887
|
+
importer({ "evals.config.ts": defineEvalConfig({ maxConcurrency: 1 }) })
|
|
888
|
+
);
|
|
889
|
+
expect(process.env[EVAL_ROOT_ENV]).toBe(projectRoot);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("prefixes the failing file on a bad default export", async () => {
|
|
893
|
+
await writeFile(join(projectRoot, "evals/broken.eval.ts"), "", "utf8");
|
|
894
|
+
await expect(
|
|
895
|
+
discoverEvals(projectRoot, importer({ "broken.eval.ts": { nope: true } }))
|
|
896
|
+
).rejects.toThrow(/broken\.eval\.ts: default export must be defineEval/);
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
describe("cancellation", () => {
|
|
901
|
+
it("returns a cancelled result without driving the agent", async () => {
|
|
902
|
+
const controller = new AbortController();
|
|
903
|
+
controller.abort();
|
|
904
|
+
const results = await runDiscoveredEvals({
|
|
905
|
+
projectRoot: "/unused",
|
|
906
|
+
baseUrl: "http://127.0.0.1:3000",
|
|
907
|
+
signal: controller.signal,
|
|
908
|
+
discovered: {
|
|
909
|
+
evals: expandEvalModule(
|
|
910
|
+
"smoke",
|
|
911
|
+
defineEval({
|
|
912
|
+
async test(t: EvalTestContext) {
|
|
913
|
+
await t.send("weather?");
|
|
914
|
+
},
|
|
915
|
+
})
|
|
916
|
+
).map((e) => ({
|
|
917
|
+
id: e.id,
|
|
918
|
+
fileId: "smoke",
|
|
919
|
+
path: "/repo/evals/smoke.eval.ts",
|
|
920
|
+
definition: e.definition,
|
|
921
|
+
})),
|
|
922
|
+
config: { maxConcurrency: 1 },
|
|
923
|
+
},
|
|
924
|
+
});
|
|
925
|
+
expect(results).toEqual([]);
|
|
926
|
+
expect(runSession).not.toHaveBeenCalled();
|
|
927
|
+
});
|
|
928
|
+
});
|