@crewhaus/eval-runner 0.1.0 → 0.1.2

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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Isolated tests for the `readTranscript` catch arm in `runSample`.
3
+ *
4
+ * `readTranscript` opens the per-sample event-log to reconstruct the
5
+ * transcript when the invoker didn't supply one. A missing log is tolerated
6
+ * only when it surfaces as an "invalid sessionId" error (test stubs that skip
7
+ * persistence); any other failure is rethrown as a `RunnerError`. Both arms
8
+ * require `openEventLog` to throw, so we stub `@crewhaus/event-log`.
9
+ *
10
+ * `mock.module` is process-global and Bun evaluates every test file's body in
11
+ * one up-front load phase, so this stub can leak into sibling suites whose
12
+ * real `runSample` legitimately opens an event-log. The stub must therefore be
13
+ * BOTH deterministic for this file and transparent when leaked:
14
+ *
15
+ * - Its throwing modes ("invalid"/"generic") are armed only for the single
16
+ * test that needs them and disarmed again in `afterEach`. In its default
17
+ * "off" state it is a pure pass-through to the real implementation, so any
18
+ * leak into a sibling suite behaves exactly like the real module.
19
+ * - The pass-through calls a *snapshot* of the real `openEventLog`
20
+ * (`realOpenEventLog`), captured into a local before `mock.module` runs —
21
+ * NOT `realEventLog.openEventLog`. Reading the property at call time would
22
+ * self-recurse, because `mock.module` redirects the captured namespace's
23
+ * own `openEventLog` binding back into this stub (the `RangeError:
24
+ * Maximum call stack size exceeded` previously seen in sibling suites). A
25
+ * plain function value captured pre-mock is immune to that redirection.
26
+ *
27
+ * `throwMode` is disarmed in `afterEach` and the real module is reinstalled in
28
+ * `afterAll` so the override is fully inert once this file's tests finish.
29
+ */
30
+ import { afterAll, afterEach, describe, expect, mock, test } from "bun:test";
31
+ import { mkdtempSync, rmSync } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { join } from "node:path";
34
+ import type { Sample } from "@crewhaus/eval-dataset";
35
+ import { parseGradersConfig } from "@crewhaus/eval-grader";
36
+
37
+ // Capture the real module (for `afterAll` restoration) AND snapshot the real
38
+ // `openEventLog` function value *before* installing the override, so the
39
+ // pass-through can call it without re-entering this stub. The module capture
40
+ // is a plain-object SNAPSHOT (`{ ...ns }`): an ESM namespace is a live view
41
+ // that resolves to the stub once mock.module patches the module, so restoring
42
+ // from the namespace itself would silently reinstall the stub.
43
+ const realEventLog = { ...(await import("@crewhaus/event-log")) };
44
+ const realOpenEventLog = realEventLog.openEventLog;
45
+
46
+ // "off" → transparent pass-through to the real log (safe for any leaked call).
47
+ let throwMode: "off" | "invalid" | "generic" = "off";
48
+
49
+ mock.module("@crewhaus/event-log", () => ({
50
+ ...realEventLog,
51
+ openEventLog: async (sessionId: string, opts?: { rootDir?: string }) => {
52
+ if (throwMode === "invalid") {
53
+ // Tolerated: matches the /invalid sessionId/ guard in readTranscript.
54
+ throw new Error('event-log: invalid sessionId "nope" — expected sess_<16 hex>');
55
+ }
56
+ if (throwMode === "generic") {
57
+ // Rethrown as RunnerError.
58
+ throw new Error("disk on fire");
59
+ }
60
+ // Default: real behaviour against the (possibly empty) on-disk log.
61
+ return realOpenEventLog(sessionId, opts ?? {});
62
+ },
63
+ }));
64
+
65
+ const { runSample } = await import("./run-sample");
66
+
67
+ const SAMPLE: Sample = { id: "s1", input: "hi", expected_output: "ok" };
68
+ const EXACT = (() => {
69
+ const { compiled } = parseGradersConfig("graders:\n - name: m\n type: exact_match\n");
70
+ return compiled.map((g) => ({ name: g.name, grader: g.grader }));
71
+ })();
72
+
73
+ const TMP_ROOTS: string[] = [];
74
+ function newTempRoot(): string {
75
+ const dir = mkdtempSync(join(tmpdir(), "crewhaus-rs-eventlog-"));
76
+ TMP_ROOTS.push(dir);
77
+ return dir;
78
+ }
79
+ afterEach(() => {
80
+ // Disarm immediately so the stub is inert (returns an empty log) outside the
81
+ // test that armed a throwing mode. The stub itself stays installed across
82
+ // this file's tests — both of them rely on it — but in its "off" state it is
83
+ // behaviourally identical to the real module against a not-yet-written log,
84
+ // so any leak into a sibling suite is harmless.
85
+ throwMode = "off";
86
+ });
87
+ afterAll(() => {
88
+ for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
89
+ mock.module("@crewhaus/event-log", () => realEventLog);
90
+ });
91
+
92
+ describe("runSample — readTranscript failure handling", () => {
93
+ test("tolerates an 'invalid sessionId' error (missing-log stub case)", async () => {
94
+ throwMode = "invalid";
95
+ const outDir = newTempRoot();
96
+ // Invoker supplies no transcript/events and no error → readTranscript runs.
97
+ const invoker = async () => ({ agentOutput: "ok" });
98
+ const result = await runSample({
99
+ sample: SAMPLE,
100
+ invoker,
101
+ graders: EXACT,
102
+ outDir,
103
+ model: "claude-test",
104
+ });
105
+ // Tolerated: transcript stays empty, sample still grades.
106
+ expect(result.turns).toBe(0);
107
+ expect(result.grades.overall.passed).toBe(true);
108
+ });
109
+
110
+ test("rethrows any other open failure as a RunnerError", async () => {
111
+ throwMode = "generic";
112
+ const outDir = newTempRoot();
113
+ const invoker = async () => ({ agentOutput: "ok" });
114
+ await expect(
115
+ runSample({
116
+ sample: SAMPLE,
117
+ invoker,
118
+ graders: EXACT,
119
+ outDir,
120
+ model: "claude-test",
121
+ }),
122
+ ).rejects.toThrow(/failed to read transcript for/);
123
+ });
124
+ });
@@ -0,0 +1,273 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { Sample } from "@crewhaus/eval-dataset";
6
+ import { parseGradersConfig } from "@crewhaus/eval-grader";
7
+ import type {
8
+ ModelResponseEvent,
9
+ ToolCallEndEvent,
10
+ ToolCallStartEvent,
11
+ TraceEvent,
12
+ } from "@crewhaus/trace-event-bus";
13
+ import { combineGraderEntries, runSample } from "./run-sample";
14
+ import type { AgentInvoker, GraderEntry } from "./types";
15
+
16
+ const TMP_ROOTS: string[] = [];
17
+ function newTempRoot(): string {
18
+ const dir = mkdtempSync(join(tmpdir(), "crewhaus-run-sample-"));
19
+ TMP_ROOTS.push(dir);
20
+ return dir;
21
+ }
22
+ afterAll(() => {
23
+ for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ const SAMPLE: Sample = { id: "s1", input: "hi", expected_output: "ok" };
27
+
28
+ /** Build a fully-formed envelope so published events validate as TraceEvents. */
29
+ function envelope(timestamp: string) {
30
+ return {
31
+ runId: "run_test",
32
+ sessionId: "sess_0000000000000000",
33
+ turnNumber: 1,
34
+ traceId: "0".repeat(32),
35
+ spanId: "0".repeat(16),
36
+ timestamp,
37
+ };
38
+ }
39
+
40
+ function toolStart(toolUseId: string, toolName: string, ts: string): ToolCallStartEvent {
41
+ return { ...envelope(ts), kind: "tool_call_start", toolUseId, toolName, inputBytes: 1 };
42
+ }
43
+ function toolEnd(
44
+ toolUseId: string,
45
+ toolName: string,
46
+ ts: string,
47
+ isError = false,
48
+ ): ToolCallEndEvent {
49
+ return {
50
+ ...envelope(ts),
51
+ kind: "tool_call_end",
52
+ toolUseId,
53
+ toolName,
54
+ isError,
55
+ outputBytes: 1,
56
+ durationMs: 1,
57
+ };
58
+ }
59
+ function modelResponse(input: number, output: number, ts: string): ModelResponseEvent {
60
+ return {
61
+ ...envelope(ts),
62
+ kind: "model_response",
63
+ model: "claude-test",
64
+ stopReason: "end_turn",
65
+ usage: { input, output },
66
+ durationMs: 1,
67
+ };
68
+ }
69
+
70
+ const EXACT: GraderEntry[] = (() => {
71
+ const { compiled } = parseGradersConfig("graders:\n - name: m\n type: exact_match\n");
72
+ return compiled.map((g) => ({ name: g.name, grader: g.grader }));
73
+ })();
74
+
75
+ describe("runSample — bus capture, token/tool extraction", () => {
76
+ test("captures bus events, sums tokens, extracts ordered tool calls", async () => {
77
+ const outDir = newTempRoot();
78
+ // Invoker that publishes trace events through the per-sample bus and does
79
+ // NOT supply `events` (so runSample falls back to the captured array,
80
+ // exercising the subscribe callback + extractToolCalls + sumTokens).
81
+ const invoker: AgentInvoker = async ({ runContext }) => {
82
+ const bus = runContext.eventBus;
83
+ // Emit tool calls out of timestamp order to prove the sort in
84
+ // extractToolCalls; the "later" start has an earlier timestamp.
85
+ bus.publish(toolStart("u2", "bash", "2026-01-01T00:00:02.000Z"));
86
+ bus.publish(toolStart("u1", "read", "2026-01-01T00:00:01.000Z"));
87
+ bus.publish(toolEnd("u2", "bash", "2026-01-01T00:00:03.000Z", true));
88
+ bus.publish(toolEnd("u1", "read", "2026-01-01T00:00:04.000Z"));
89
+ // A tool_call_end with no matching start → falls back to its own ts.
90
+ bus.publish(toolEnd("orphan", "glob", "2026-01-01T00:00:00.500Z"));
91
+ bus.publish(modelResponse(10, 5, "2026-01-01T00:00:05.000Z"));
92
+ bus.publish(modelResponse(3, 2, "2026-01-01T00:00:06.000Z"));
93
+ return { agentOutput: "ok" };
94
+ };
95
+
96
+ const result = await runSample({
97
+ sample: SAMPLE,
98
+ invoker,
99
+ graders: EXACT,
100
+ outDir,
101
+ model: "claude-test",
102
+ });
103
+
104
+ expect(result.tokens).toEqual({ input: 13, output: 7 });
105
+ // events.jsonl persisted with the captured events.
106
+ const eventsRaw = readFileSync(join(outDir, "s1", "events.jsonl"), "utf-8");
107
+ const lines = eventsRaw
108
+ .trim()
109
+ .split("\n")
110
+ .map((l) => JSON.parse(l) as TraceEvent);
111
+ expect(lines).toHaveLength(7);
112
+ // grades.json + meta.json written.
113
+ expect(existsSync(join(outDir, "s1", "grades.json"))).toBe(true);
114
+ const meta = JSON.parse(readFileSync(join(outDir, "s1", "meta.json"), "utf-8"));
115
+ expect(meta.tokens).toEqual({ input: 13, output: 7 });
116
+ expect(result.grades.overall.passed).toBe(true);
117
+ });
118
+
119
+ test("stub-supplied transcript is written verbatim and turns counted", async () => {
120
+ const outDir = newTempRoot();
121
+ const transcript = [
122
+ { ts: 1, version: 1 as const, kind: "user_message" as const, payload: { text: "hi" } },
123
+ {
124
+ ts: 2,
125
+ version: 1 as const,
126
+ kind: "assistant_message" as const,
127
+ payload: { text: "ok" },
128
+ },
129
+ {
130
+ ts: 3,
131
+ version: 1 as const,
132
+ kind: "assistant_message" as const,
133
+ payload: { text: "more" },
134
+ },
135
+ ];
136
+ const invoker: AgentInvoker = async () => ({ agentOutput: "ok", transcript, events: [] });
137
+ const result = await runSample({
138
+ sample: SAMPLE,
139
+ invoker,
140
+ graders: EXACT,
141
+ outDir,
142
+ model: "claude-test",
143
+ });
144
+ // transcript.jsonl written by runSample (stub branch).
145
+ const tRaw = readFileSync(join(outDir, "s1", "transcript.jsonl"), "utf-8");
146
+ expect(tRaw.trim().split("\n")).toHaveLength(3);
147
+ expect(result.turns).toBe(2); // two assistant_message events
148
+ });
149
+
150
+ test("renames an invoker-written auto-named event-log file to transcript.jsonl", async () => {
151
+ const outDir = newTempRoot();
152
+ // Invoker writes <sessionId>.jsonl into sessionRootDir (mimics runChatLoop).
153
+ const invoker: AgentInvoker = async ({ runContext, sessionRootDir }) => {
154
+ const auto = join(sessionRootDir, `${runContext.sessionId}.jsonl`);
155
+ writeFileSync(
156
+ auto,
157
+ `${JSON.stringify({ ts: 1, version: 1, kind: "assistant_message", payload: {} })}\n`,
158
+ );
159
+ return { agentOutput: "ok" };
160
+ };
161
+ const result = await runSample({
162
+ sample: SAMPLE,
163
+ invoker,
164
+ graders: EXACT,
165
+ outDir,
166
+ model: "claude-test",
167
+ });
168
+ expect(existsSync(join(outDir, "s1", "transcript.jsonl"))).toBe(true);
169
+ expect(existsSync(join(outDir, "s1", `${result.sessionId}.jsonl`))).toBe(false);
170
+ expect(result.turns).toBe(1);
171
+ });
172
+
173
+ test("an empty captured run writes an empty transcript.jsonl", async () => {
174
+ const outDir = newTempRoot();
175
+ // No transcript, no events, no on-disk log → final else branch writes "".
176
+ const invoker: AgentInvoker = async () => ({ agentOutput: "ok", events: [] });
177
+ await runSample({
178
+ sample: SAMPLE,
179
+ invoker,
180
+ graders: EXACT,
181
+ outDir,
182
+ model: "claude-test",
183
+ });
184
+ expect(readFileSync(join(outDir, "s1", "transcript.jsonl"), "utf-8")).toBe("");
185
+ expect(readFileSync(join(outDir, "s1", "events.jsonl"), "utf-8")).toBe("");
186
+ });
187
+
188
+ test("a grader that throws is captured as a failed perGrader entry", async () => {
189
+ const outDir = newTempRoot();
190
+ const graders: GraderEntry[] = [
191
+ {
192
+ name: "boom",
193
+ grader: async () => {
194
+ throw new Error("grader kaboom");
195
+ },
196
+ },
197
+ {
198
+ name: "nonError",
199
+ grader: async () => {
200
+ // Throw a non-Error to exercise the String(err) branch.
201
+ throw "stringly-typed";
202
+ },
203
+ },
204
+ ];
205
+ const invoker: AgentInvoker = async () => ({ agentOutput: "x", events: [] });
206
+ const result = await runSample({
207
+ sample: SAMPLE,
208
+ invoker,
209
+ graders,
210
+ outDir,
211
+ model: "claude-test",
212
+ });
213
+ const boom = result.grades.perGrader.find((g) => g.name === "boom");
214
+ expect(boom?.passed).toBe(false);
215
+ expect(boom?.rationale).toContain("grader threw: grader kaboom");
216
+ const nonError = result.grades.perGrader.find((g) => g.name === "nonError");
217
+ expect(nonError?.rationale).toContain("stringly-typed");
218
+ expect(result.grades.overall.passed).toBe(false);
219
+ });
220
+
221
+ test("sanitizes path-separator-laden sample ids into a flat dir", async () => {
222
+ const outDir = newTempRoot();
223
+ const invoker: AgentInvoker = async () => ({ agentOutput: "ok", events: [] });
224
+ const result = await runSample({
225
+ sample: { id: "a/b:c d", input: "x", expected_output: "y" },
226
+ invoker,
227
+ graders: EXACT,
228
+ outDir,
229
+ model: "claude-test",
230
+ });
231
+ // sampleId is preserved in the result, but the dir name is sanitized.
232
+ expect(result.sampleId).toBe("a/b:c d");
233
+ expect(existsSync(join(outDir, "a_b_c_d", "meta.json"))).toBe(true);
234
+ });
235
+
236
+ test("seed is threaded into the request and persisted in meta.json", async () => {
237
+ const outDir = newTempRoot();
238
+ let seenSeed: number | undefined;
239
+ const invoker: AgentInvoker = async ({ seed }) => {
240
+ seenSeed = seed;
241
+ return { agentOutput: "ok", events: [] };
242
+ };
243
+ await runSample({
244
+ sample: SAMPLE,
245
+ invoker,
246
+ graders: EXACT,
247
+ outDir,
248
+ model: "claude-test",
249
+ seed: 1234,
250
+ });
251
+ expect(seenSeed).toBe(1234);
252
+ const meta = JSON.parse(readFileSync(join(outDir, "s1", "meta.json"), "utf-8"));
253
+ expect(meta.seed).toBe(1234);
254
+ });
255
+ });
256
+
257
+ describe("combineGraderEntries", () => {
258
+ test("combines compiled graders into a single Grader", async () => {
259
+ const { compiled } = parseGradersConfig(
260
+ "graders:\n - name: a\n type: contains\n substring: ok\n",
261
+ );
262
+ const combined = combineGraderEntries(compiled);
263
+ const res = await combined(SAMPLE, {
264
+ agentOutput: "this is ok",
265
+ events: [],
266
+ transcript: [],
267
+ toolCalls: [],
268
+ turns: 1,
269
+ latencyMs: 0,
270
+ });
271
+ expect(res.passed).toBe(true);
272
+ });
273
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Semaphore } from "./semaphore";
3
+
4
+ describe("Semaphore — queueing and introspection", () => {
5
+ test("queues over-capacity acquirers and wakes them FIFO on release", async () => {
6
+ const sem = new Semaphore(1);
7
+ const order: number[] = [];
8
+
9
+ const r0 = await sem.acquire(); // takes the only slot
10
+ expect(sem.active).toBe(1);
11
+ expect(sem.pending).toBe(0);
12
+
13
+ // Two more acquirers must queue.
14
+ const p1 = sem.acquire().then((release) => {
15
+ order.push(1);
16
+ return release;
17
+ });
18
+ const p2 = sem.acquire().then((release) => {
19
+ order.push(2);
20
+ return release;
21
+ });
22
+
23
+ // Let the microtask queue settle; both are still waiting.
24
+ await Promise.resolve();
25
+ expect(sem.pending).toBe(2);
26
+ expect(sem.active).toBe(1);
27
+
28
+ r0(); // releasing wakes the first waiter (FIFO)
29
+ const r1 = await p1;
30
+ expect(order).toEqual([1]);
31
+ expect(sem.active).toBe(1);
32
+ expect(sem.pending).toBe(1);
33
+
34
+ r1(); // wakes the second waiter
35
+ const r2 = await p2;
36
+ expect(order).toEqual([1, 2]);
37
+ expect(sem.pending).toBe(0);
38
+
39
+ r2();
40
+ expect(sem.active).toBe(0);
41
+ expect(sem.pending).toBe(0);
42
+ });
43
+
44
+ test("release with an empty queue simply frees a slot", async () => {
45
+ const sem = new Semaphore(2);
46
+ const a = await sem.acquire();
47
+ const b = await sem.acquire();
48
+ expect(sem.active).toBe(2);
49
+ a();
50
+ expect(sem.active).toBe(1);
51
+ b();
52
+ expect(sem.active).toBe(0);
53
+ // A subsequent acquire takes the fast path (inflight < capacity).
54
+ const c = await sem.acquire();
55
+ expect(sem.active).toBe(1);
56
+ c();
57
+ });
58
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildTenant, withTenant } from "@crewhaus/tenancy";
3
+ import { resolveEvalOutDir } from "./index";
4
+
5
+ // Regression — issue #150 (CWE-1230). Eval artifacts must land under the active
6
+ // tenant's evalRoot so one tenant's eval data never shares a directory with
7
+ // another's.
8
+ describe("resolveEvalOutDir — tenant isolation (#150)", () => {
9
+ test("each tenant resolves under its own evalRoot, disjoint", () => {
10
+ const a = buildTenant("tenant-a", { tenantsRoot: "/tmp/ch-eval-test" });
11
+ const b = buildTenant("tenant-b", { tenantsRoot: "/tmp/ch-eval-test" });
12
+ const outA = withTenant(a, () => resolveEvalOutDir("run1")) as string;
13
+ const outB = withTenant(b, () => resolveEvalOutDir("run1")) as string;
14
+ expect(outA.startsWith(`${a.evalRoot}/`)).toBe(true);
15
+ expect(outB.startsWith(`${b.evalRoot}/`)).toBe(true);
16
+ expect(outA).not.toBe(outB);
17
+ });
18
+
19
+ test("outside a tenant scope, uses the global default", () => {
20
+ const out = resolveEvalOutDir("runX");
21
+ expect(out).toContain(".crewhaus");
22
+ expect(out).toContain("runX");
23
+ });
24
+
25
+ test("an explicit outDir always wins (trusted caller override)", () => {
26
+ const a = buildTenant("tenant-a", { tenantsRoot: "/tmp/ch-eval-test" });
27
+ const out = withTenant(a, () => resolveEvalOutDir("runX", "/tmp/explicit-out")) as string;
28
+ expect(out).toBe("/tmp/explicit-out");
29
+ });
30
+ });