@crewhaus/eval-runner 0.1.4 → 0.1.5
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/aggregate.d.ts +15 -0
- package/dist/aggregate.js +35 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +6 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +216 -0
- package/dist/run-sample.d.ts +19 -0
- package/dist/run-sample.js +187 -0
- package/dist/semaphore.d.ts +15 -0
- package/dist/semaphore.js +39 -0
- package/dist/types.d.ts +97 -0
- package/dist/types.js +1 -0
- package/dist/wire-once.d.ts +42 -0
- package/dist/wire-once.js +150 -0
- package/package.json +37 -34
- package/src/aggregate.ts +0 -48
- package/src/errors.ts +0 -7
- package/src/index.default-invoker.test.ts +0 -172
- package/src/index.interrupt.test.ts +0 -99
- package/src/index.judge.test.ts +0 -167
- package/src/index.test.ts +0 -254
- package/src/index.ts +0 -264
- package/src/run-sample.eventlog.test.ts +0 -124
- package/src/run-sample.test.ts +0 -273
- package/src/run-sample.ts +0 -232
- package/src/semaphore.test.ts +0 -58
- package/src/semaphore.ts +0 -35
- package/src/tenant-roots.test.ts +0 -30
- package/src/types.ts +0 -93
- package/src/wire-once.test.ts +0 -445
- package/src/wire-once.ts +0 -204
package/src/semaphore.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Trivial async semaphore — no `p-limit` dep. Acquire returns a release fn;
|
|
3
|
-
* callers MUST call it (use try/finally). When the semaphore would otherwise
|
|
4
|
-
* be over-capacity, acquirers queue FIFO.
|
|
5
|
-
*/
|
|
6
|
-
export class Semaphore {
|
|
7
|
-
private inflight = 0;
|
|
8
|
-
private readonly waitQueue: Array<() => void> = [];
|
|
9
|
-
constructor(private readonly capacity: number) {
|
|
10
|
-
if (capacity < 1) throw new Error(`Semaphore capacity must be ≥ 1 (got ${capacity})`);
|
|
11
|
-
}
|
|
12
|
-
async acquire(): Promise<() => void> {
|
|
13
|
-
if (this.inflight < this.capacity) {
|
|
14
|
-
this.inflight += 1;
|
|
15
|
-
return () => this.release();
|
|
16
|
-
}
|
|
17
|
-
return new Promise<() => void>((resolve) => {
|
|
18
|
-
this.waitQueue.push(() => {
|
|
19
|
-
this.inflight += 1;
|
|
20
|
-
resolve(() => this.release());
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
private release(): void {
|
|
25
|
-
this.inflight -= 1;
|
|
26
|
-
const next = this.waitQueue.shift();
|
|
27
|
-
if (next) next();
|
|
28
|
-
}
|
|
29
|
-
get pending(): number {
|
|
30
|
-
return this.waitQueue.length;
|
|
31
|
-
}
|
|
32
|
-
get active(): number {
|
|
33
|
-
return this.inflight;
|
|
34
|
-
}
|
|
35
|
-
}
|
package/src/tenant-roots.test.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
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
|
-
});
|
package/src/types.ts
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import type { Sample } from "@crewhaus/eval-dataset";
|
|
2
|
-
import type { GradeResult, Grader } from "@crewhaus/eval-grader";
|
|
3
|
-
import type { Event as TranscriptEvent } from "@crewhaus/event-log";
|
|
4
|
-
import type { RunContext } from "@crewhaus/run-context";
|
|
5
|
-
import type { TraceEvent } from "@crewhaus/trace-event-bus";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The agent-invocation contract. The runner provides a per-sample
|
|
9
|
-
* `RunContext` (fresh `TraceEventBus`, fresh `sessionId`) and the
|
|
10
|
-
* destination directory for the event log. The invoker is responsible
|
|
11
|
-
* for actually running the chat loop and returning the assistant text.
|
|
12
|
-
*
|
|
13
|
-
* In production this is a thin wrapper around `runChatLoop`. In tests it's
|
|
14
|
-
* a deterministic stub that returns a canned answer per sample.
|
|
15
|
-
*/
|
|
16
|
-
export type AgentInvoker = (req: AgentInvokeRequest) => Promise<AgentInvokeResult>;
|
|
17
|
-
|
|
18
|
-
export type AgentInvokeRequest = {
|
|
19
|
-
readonly sample: Sample;
|
|
20
|
-
readonly runContext: RunContext;
|
|
21
|
-
/** Where the runtime should write the per-sample event-log JSONL. */
|
|
22
|
-
readonly sessionRootDir: string;
|
|
23
|
-
readonly seed?: number;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export type AgentInvokeResult = {
|
|
27
|
-
/** Final assistant text returned by the chat loop. */
|
|
28
|
-
readonly agentOutput: string;
|
|
29
|
-
/**
|
|
30
|
-
* If the invoker has out-of-band knowledge (e.g. a stub), it can supply
|
|
31
|
-
* the captured event-log entries here. The default invoker reads them
|
|
32
|
-
* from disk after `runChatLoop` returns.
|
|
33
|
-
*/
|
|
34
|
-
readonly transcript?: ReadonlyArray<TranscriptEvent>;
|
|
35
|
-
/** Same as transcript — invoker can short-circuit the bus subscription. */
|
|
36
|
-
readonly events?: ReadonlyArray<TraceEvent>;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export type SampleResult = {
|
|
40
|
-
readonly sampleId: string;
|
|
41
|
-
readonly sessionId: string;
|
|
42
|
-
readonly startedAt: string;
|
|
43
|
-
readonly endedAt: string;
|
|
44
|
-
readonly latencyMs: number;
|
|
45
|
-
readonly turns: number;
|
|
46
|
-
readonly tokens: { input: number; output: number };
|
|
47
|
-
readonly model: string;
|
|
48
|
-
readonly agentOutput: string;
|
|
49
|
-
readonly grades: { overall: GradeResult; perGrader: Array<{ name: string } & GradeResult> };
|
|
50
|
-
readonly error?: string;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
export type EvalRunSummary = {
|
|
54
|
-
readonly runId: string;
|
|
55
|
-
readonly startedAt: string;
|
|
56
|
-
readonly endedAt: string;
|
|
57
|
-
readonly samples: ReadonlyArray<SampleResult>;
|
|
58
|
-
readonly aggregates: {
|
|
59
|
-
readonly passRate: number;
|
|
60
|
-
readonly meanScore: number;
|
|
61
|
-
readonly p50Turns: number;
|
|
62
|
-
readonly p95Turns: number;
|
|
63
|
-
readonly p50LatencyMs: number;
|
|
64
|
-
readonly p95LatencyMs: number;
|
|
65
|
-
readonly totalTokens: { input: number; output: number };
|
|
66
|
-
readonly errorCount: number;
|
|
67
|
-
};
|
|
68
|
-
readonly config: {
|
|
69
|
-
readonly specHash: string;
|
|
70
|
-
readonly datasetName: string;
|
|
71
|
-
readonly graderNames: ReadonlyArray<string>;
|
|
72
|
-
readonly model: string;
|
|
73
|
-
readonly judgeModel?: string;
|
|
74
|
-
readonly concurrency: number;
|
|
75
|
-
readonly seed?: number;
|
|
76
|
-
};
|
|
77
|
-
readonly outDir: string;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
export type RunEvalOptions = {
|
|
81
|
-
readonly runId?: string;
|
|
82
|
-
readonly concurrency?: number;
|
|
83
|
-
readonly seed?: number;
|
|
84
|
-
readonly outDir?: string;
|
|
85
|
-
readonly judgeModel?: string;
|
|
86
|
-
readonly invoker?: AgentInvoker;
|
|
87
|
-
readonly cwd?: string;
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
export type GraderEntry = {
|
|
91
|
-
readonly name: string;
|
|
92
|
-
readonly grader: Grader;
|
|
93
|
-
};
|
package/src/wire-once.test.ts
DELETED
|
@@ -1,445 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Coverage for `wireRunOnce` and its helpers (`loadToolMap`,
|
|
3
|
-
* `applyToolConfigs`, `buildRuleSet`) in `wire-once.ts`.
|
|
4
|
-
*
|
|
5
|
-
* `wireRunOnce` is the "build the agent stack once per eval run" factory. In
|
|
6
|
-
* production it spins up real tools, an MCP host (which would launch stdio
|
|
7
|
-
* servers), scans the cwd for hooks/skills/slash-commands, and reads
|
|
8
|
-
* `.crewhaus/settings.json`. To keep these tests deterministic and free of
|
|
9
|
-
* network / child-process / cwd-scanning I/O we stub every heavy `@crewhaus/*`
|
|
10
|
-
* dependency with `mock.module`:
|
|
11
|
-
*
|
|
12
|
-
* - the six tool packages loaded dynamically by `loadToolMap`
|
|
13
|
-
* (`tool-fs/bash/todo/web/image/fetch`) become trivial in-memory tools;
|
|
14
|
-
* - `registerFetchConfig` / `registerWebFetchConfig` become spies so the
|
|
15
|
-
* `applyToolConfigs` branch is observable without touching real config;
|
|
16
|
-
* - `McpHost` / `registerMcpServer` become fakes that record `addServer`
|
|
17
|
-
* calls and register a namespaced tool into the passed catalog — no stdio
|
|
18
|
-
* server is spawned;
|
|
19
|
-
* - `loadHooks` / `discoverSkills` / `loadCommands` return canned data
|
|
20
|
-
* instead of scanning the filesystem; `createSkillTool` / `createTaskTool`
|
|
21
|
-
* return trivial tools.
|
|
22
|
-
*
|
|
23
|
-
* `@crewhaus/permission-engine` is left REAL (it is pure) except for
|
|
24
|
-
* `parsePermissionsConfig`, which a single test overrides — via a captured,
|
|
25
|
-
* non-recursing snapshot — to throw a NON-`PermissionConfigError` so the
|
|
26
|
-
* rethrow arm of `buildRuleSet` is exercised. `ToolCatalog` is also real
|
|
27
|
-
* (pure, in-memory).
|
|
28
|
-
*
|
|
29
|
-
* `.crewhaus/settings.json` is read from a per-test temp dir (hermetic,
|
|
30
|
-
* controlled fixture I/O), never the real project tree.
|
|
31
|
-
*
|
|
32
|
-
* Every `mock.module` override is reinstalled to the real module in
|
|
33
|
-
* `afterAll`; `mock.module` is process-global, so this file is the canonical
|
|
34
|
-
* place those overrides live and they must not leak.
|
|
35
|
-
*/
|
|
36
|
-
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
37
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
38
|
-
import { tmpdir } from "node:os";
|
|
39
|
-
import { join } from "node:path";
|
|
40
|
-
import type { HookDef } from "@crewhaus/hooks-engine";
|
|
41
|
-
import type { IrSubAgentDefinition, IrV0 } from "@crewhaus/ir";
|
|
42
|
-
import type { SkillRef } from "@crewhaus/skills-registry";
|
|
43
|
-
import type { SlashCommand } from "@crewhaus/slash-commands";
|
|
44
|
-
|
|
45
|
-
// ── Capture real modules up front (for restoration + non-recursing delegation).
|
|
46
|
-
// Each capture is a plain-object SNAPSHOT (`{ ...ns }`), not the namespace
|
|
47
|
-
// itself: an ESM namespace is a live view, so once mock.module patches a
|
|
48
|
-
// module the namespace's properties resolve to the stubs — restoring from it
|
|
49
|
-
// in afterAll would silently reinstall the stubs.
|
|
50
|
-
const realPermissionEngine = { ...(await import("@crewhaus/permission-engine")) };
|
|
51
|
-
const realParsePermissionsConfig = realPermissionEngine.parsePermissionsConfig;
|
|
52
|
-
const realMcpHost = { ...(await import("@crewhaus/mcp-host")) };
|
|
53
|
-
const realToolMcp = { ...(await import("@crewhaus/tool-mcp")) };
|
|
54
|
-
const realHooks = { ...(await import("@crewhaus/hooks-engine")) };
|
|
55
|
-
const realSkills = { ...(await import("@crewhaus/skills-registry")) };
|
|
56
|
-
const realSlash = { ...(await import("@crewhaus/slash-commands")) };
|
|
57
|
-
const realTaskTool = { ...(await import("@crewhaus/tool-task")) };
|
|
58
|
-
const realSpawner = { ...(await import("@crewhaus/sub-agent-spawner")) };
|
|
59
|
-
const realToolFs = { ...(await import("@crewhaus/tool-fs")) };
|
|
60
|
-
const realToolBash = { ...(await import("@crewhaus/tool-bash")) };
|
|
61
|
-
const realToolTodo = { ...(await import("@crewhaus/tool-todo")) };
|
|
62
|
-
const realToolWeb = { ...(await import("@crewhaus/tool-web")) };
|
|
63
|
-
const realToolImage = { ...(await import("@crewhaus/tool-image")) };
|
|
64
|
-
const realToolFetch = { ...(await import("@crewhaus/tool-fetch")) };
|
|
65
|
-
|
|
66
|
-
// ── Recording channels the stubs write to so tests can assert behaviour.
|
|
67
|
-
const calls = {
|
|
68
|
-
addServer: [] as Array<{ name: string }>,
|
|
69
|
-
registerMcpServer: [] as string[],
|
|
70
|
-
loadHooksCwd: [] as Array<string | undefined>,
|
|
71
|
-
discoverSkillsCwd: [] as Array<string | undefined>,
|
|
72
|
-
loadCommandsCwd: [] as Array<string | undefined>,
|
|
73
|
-
registerFetchConfig: [] as unknown[],
|
|
74
|
-
registerWebFetchConfig: [] as unknown[],
|
|
75
|
-
createSkillTool: [] as unknown[],
|
|
76
|
-
createTaskTool: [] as unknown[],
|
|
77
|
-
};
|
|
78
|
-
// Per-test knobs for what the cwd-scanning stubs return.
|
|
79
|
-
let hooksToReturn: unknown[] = [];
|
|
80
|
-
let skillsToReturn: unknown[] = [];
|
|
81
|
-
let commandsToReturn: Array<[string, unknown]> = [];
|
|
82
|
-
// "real" → delegate to the captured real parser; "throwGeneric" → throw a
|
|
83
|
-
// plain Error (NOT a PermissionConfigError) to hit the rethrow arm.
|
|
84
|
-
let parseMode: "real" | "throwGeneric" = "real";
|
|
85
|
-
|
|
86
|
-
/** Minimal structurally-complete RegisteredTool (only `.name` is read here). */
|
|
87
|
-
function fakeTool(name: string): never {
|
|
88
|
-
return {
|
|
89
|
-
name,
|
|
90
|
-
description: `${name} tool`,
|
|
91
|
-
inputSchema: { parse: (x: unknown) => x } as never,
|
|
92
|
-
execute: async () => ({ ok: true }) as never,
|
|
93
|
-
concurrencySafe: true,
|
|
94
|
-
readOnly: true,
|
|
95
|
-
destructive: false,
|
|
96
|
-
requiresSandbox: false,
|
|
97
|
-
classifyOutput: true,
|
|
98
|
-
scope: "internal",
|
|
99
|
-
requireJustification: false,
|
|
100
|
-
} as never;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ── Tool packages loaded dynamically by `loadToolMap`.
|
|
104
|
-
mock.module("@crewhaus/tool-fs", () => ({
|
|
105
|
-
read: fakeTool("read"),
|
|
106
|
-
write: fakeTool("write"),
|
|
107
|
-
edit: fakeTool("edit"),
|
|
108
|
-
glob: fakeTool("glob"),
|
|
109
|
-
grep: fakeTool("grep"),
|
|
110
|
-
}));
|
|
111
|
-
mock.module("@crewhaus/tool-bash", () => ({ bash: fakeTool("bash") }));
|
|
112
|
-
mock.module("@crewhaus/tool-todo", () => ({ todoWrite: fakeTool("todoWrite") }));
|
|
113
|
-
mock.module("@crewhaus/tool-web", () => ({
|
|
114
|
-
webFetch: fakeTool("webFetch"),
|
|
115
|
-
webSearch: fakeTool("webSearch"),
|
|
116
|
-
registerWebFetchConfig: (cfg: unknown) => {
|
|
117
|
-
calls.registerWebFetchConfig.push(cfg);
|
|
118
|
-
},
|
|
119
|
-
}));
|
|
120
|
-
mock.module("@crewhaus/tool-image", () => ({ readImage: fakeTool("readImage") }));
|
|
121
|
-
mock.module("@crewhaus/tool-fetch", () => ({
|
|
122
|
-
fetch: fakeTool("fetch"),
|
|
123
|
-
registerFetchConfig: (cfg: unknown) => {
|
|
124
|
-
calls.registerFetchConfig.push(cfg);
|
|
125
|
-
},
|
|
126
|
-
}));
|
|
127
|
-
|
|
128
|
-
// ── MCP host + registration (no real stdio servers).
|
|
129
|
-
class FakeMcpHost {
|
|
130
|
-
addServer(name: string, _cfg: unknown): void {
|
|
131
|
-
calls.addServer.push({ name });
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
mock.module("@crewhaus/mcp-host", () => ({ ...realMcpHost, McpHost: FakeMcpHost }));
|
|
135
|
-
mock.module("@crewhaus/tool-mcp", () => ({
|
|
136
|
-
...realToolMcp,
|
|
137
|
-
registerMcpServer: async (
|
|
138
|
-
_host: unknown,
|
|
139
|
-
name: string,
|
|
140
|
-
catalog: { register: (t: unknown) => void },
|
|
141
|
-
) => {
|
|
142
|
-
calls.registerMcpServer.push(name);
|
|
143
|
-
// Register a namespaced tool so `tempCatalog.list()` reflects MCP wiring.
|
|
144
|
-
catalog.register(fakeTool(`mcp__${name}__do`));
|
|
145
|
-
},
|
|
146
|
-
}));
|
|
147
|
-
|
|
148
|
-
// ── cwd-scanning loaders → canned data.
|
|
149
|
-
mock.module("@crewhaus/hooks-engine", () => ({
|
|
150
|
-
...realHooks,
|
|
151
|
-
loadHooks: async (opts: { cwd?: string } = {}) => {
|
|
152
|
-
calls.loadHooksCwd.push(opts.cwd);
|
|
153
|
-
return hooksToReturn;
|
|
154
|
-
},
|
|
155
|
-
}));
|
|
156
|
-
mock.module("@crewhaus/skills-registry", () => ({
|
|
157
|
-
...realSkills,
|
|
158
|
-
discoverSkills: async (opts: { cwd?: string } = {}) => {
|
|
159
|
-
calls.discoverSkillsCwd.push(opts.cwd);
|
|
160
|
-
return skillsToReturn;
|
|
161
|
-
},
|
|
162
|
-
createSkillTool: (skills: unknown) => {
|
|
163
|
-
calls.createSkillTool.push(skills);
|
|
164
|
-
return fakeTool("skill");
|
|
165
|
-
},
|
|
166
|
-
}));
|
|
167
|
-
mock.module("@crewhaus/slash-commands", () => ({
|
|
168
|
-
...realSlash,
|
|
169
|
-
loadCommands: async (opts: { cwd?: string } = {}) => {
|
|
170
|
-
calls.loadCommandsCwd.push(opts.cwd);
|
|
171
|
-
return new Map(commandsToReturn);
|
|
172
|
-
},
|
|
173
|
-
}));
|
|
174
|
-
mock.module("@crewhaus/tool-task", () => ({
|
|
175
|
-
...realTaskTool,
|
|
176
|
-
createTaskTool: (arg: unknown) => {
|
|
177
|
-
calls.createTaskTool.push(arg);
|
|
178
|
-
return fakeTool("task");
|
|
179
|
-
},
|
|
180
|
-
}));
|
|
181
|
-
|
|
182
|
-
// ── permission-engine: real, except a swappable parser (non-recursing snapshot).
|
|
183
|
-
mock.module("@crewhaus/permission-engine", () => ({
|
|
184
|
-
...realPermissionEngine,
|
|
185
|
-
parsePermissionsConfig: (raw: unknown, source: "yaml" | "settings") => {
|
|
186
|
-
if (parseMode === "throwGeneric") {
|
|
187
|
-
throw new TypeError("not a PermissionConfigError");
|
|
188
|
-
}
|
|
189
|
-
return realParsePermissionsConfig(raw, source);
|
|
190
|
-
},
|
|
191
|
-
}));
|
|
192
|
-
|
|
193
|
-
const { wireRunOnce } = await import("./wire-once");
|
|
194
|
-
|
|
195
|
-
// ── IR fixture builder ─────────────────────────────────────────────────────
|
|
196
|
-
function baseIr(overrides: Partial<IrV0> = {}): IrV0 {
|
|
197
|
-
return {
|
|
198
|
-
version: 0,
|
|
199
|
-
name: "wire-test",
|
|
200
|
-
target: "cli",
|
|
201
|
-
agent: { model: "claude-opus-4-7", instructions: "be helpful" },
|
|
202
|
-
tools: [],
|
|
203
|
-
toolConfigs: {},
|
|
204
|
-
mcp_servers: {},
|
|
205
|
-
permissions: { rules: [] },
|
|
206
|
-
subAgents: [],
|
|
207
|
-
compaction: {},
|
|
208
|
-
...overrides,
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function subAgent(overrides: Partial<IrSubAgentDefinition> = {}): IrSubAgentDefinition {
|
|
213
|
-
return {
|
|
214
|
-
name: "helper",
|
|
215
|
-
description: "a helper",
|
|
216
|
-
instructions: "help",
|
|
217
|
-
tools: ["read"],
|
|
218
|
-
permissions: "inherit",
|
|
219
|
-
inheritBypass: false,
|
|
220
|
-
...overrides,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const TMP_ROOTS: string[] = [];
|
|
225
|
-
/**
|
|
226
|
-
* Make a temp cwd. When `settings` is supplied it is written verbatim to
|
|
227
|
-
* `.crewhaus/settings.json` (pass already-stringified JSON, or deliberately
|
|
228
|
-
* malformed text, to drive the relevant `buildRuleSet` branch).
|
|
229
|
-
*/
|
|
230
|
-
function newCwd(settings?: string): string {
|
|
231
|
-
const dir = mkdtempSync(join(tmpdir(), "crewhaus-wire-"));
|
|
232
|
-
TMP_ROOTS.push(dir);
|
|
233
|
-
if (settings !== undefined) {
|
|
234
|
-
mkdirSync(join(dir, ".crewhaus"), { recursive: true });
|
|
235
|
-
writeFileSync(join(dir, ".crewhaus", "settings.json"), settings, "utf-8");
|
|
236
|
-
}
|
|
237
|
-
return dir;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
beforeEach(() => {
|
|
241
|
-
for (const k of Object.keys(calls) as Array<keyof typeof calls>) {
|
|
242
|
-
(calls[k] as unknown[]).length = 0;
|
|
243
|
-
}
|
|
244
|
-
hooksToReturn = [];
|
|
245
|
-
skillsToReturn = [];
|
|
246
|
-
commandsToReturn = [];
|
|
247
|
-
parseMode = "real";
|
|
248
|
-
});
|
|
249
|
-
afterEach(() => {
|
|
250
|
-
parseMode = "real";
|
|
251
|
-
});
|
|
252
|
-
afterAll(() => {
|
|
253
|
-
for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
|
|
254
|
-
// Reinstall the real modules so no override leaks into sibling suites.
|
|
255
|
-
mock.module("@crewhaus/permission-engine", () => realPermissionEngine);
|
|
256
|
-
mock.module("@crewhaus/mcp-host", () => realMcpHost);
|
|
257
|
-
mock.module("@crewhaus/tool-mcp", () => realToolMcp);
|
|
258
|
-
mock.module("@crewhaus/hooks-engine", () => realHooks);
|
|
259
|
-
mock.module("@crewhaus/skills-registry", () => realSkills);
|
|
260
|
-
mock.module("@crewhaus/slash-commands", () => realSlash);
|
|
261
|
-
mock.module("@crewhaus/tool-task", () => realTaskTool);
|
|
262
|
-
mock.module("@crewhaus/sub-agent-spawner", () => realSpawner);
|
|
263
|
-
mock.module("@crewhaus/tool-fs", () => realToolFs);
|
|
264
|
-
mock.module("@crewhaus/tool-bash", () => realToolBash);
|
|
265
|
-
mock.module("@crewhaus/tool-todo", () => realToolTodo);
|
|
266
|
-
mock.module("@crewhaus/tool-web", () => realToolWeb);
|
|
267
|
-
mock.module("@crewhaus/tool-image", () => realToolImage);
|
|
268
|
-
mock.module("@crewhaus/tool-fetch", () => realToolFetch);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
describe("wireRunOnce — tools", () => {
|
|
272
|
-
test("resolves named tools from the tool map (cwd defaults to process.cwd)", async () => {
|
|
273
|
-
const ir = baseIr({ tools: ["read", "bash", "grep"] });
|
|
274
|
-
// No opts.cwd → exercises the `process.cwd()` default.
|
|
275
|
-
const deps = await wireRunOnce(ir);
|
|
276
|
-
expect(deps.tools.map((t) => t.name)).toEqual(["read", "bash", "grep"]);
|
|
277
|
-
// Each cwd-scanning loader saw the default cwd (a string, not undefined).
|
|
278
|
-
expect(typeof calls.loadHooksCwd[0]).toBe("string");
|
|
279
|
-
expect(calls.discoverSkillsCwd[0]).toBe(calls.loadHooksCwd[0]);
|
|
280
|
-
expect(deps.model).toBe("claude-opus-4-7");
|
|
281
|
-
expect(deps.instructions).toBe("be helpful");
|
|
282
|
-
expect(deps.sessionName).toBe("wire-test");
|
|
283
|
-
expect(deps.sessionTarget).toBe("cli");
|
|
284
|
-
// No MCP / sub-agents → those optional keys are absent.
|
|
285
|
-
expect(deps.mcpHost).toBeUndefined();
|
|
286
|
-
expect(deps.subAgents).toBeUndefined();
|
|
287
|
-
expect(deps.spawnSubAgent).toBeUndefined();
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
test("throws RunnerError listing known tools for an unknown tool name", async () => {
|
|
291
|
-
const ir = baseIr({ tools: ["read", "nope"] });
|
|
292
|
-
await expect(wireRunOnce(ir, { cwd: newCwd() })).rejects.toThrow(
|
|
293
|
-
/unknown tool "nope" — known tools: /,
|
|
294
|
-
);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test("applies fetch + webFetch tool configs when present (registerXConfig)", async () => {
|
|
298
|
-
const ir = baseIr({
|
|
299
|
-
tools: ["fetch", "webFetch"],
|
|
300
|
-
toolConfigs: { fetch: { allow: ["example.com"] }, webFetch: { timeoutMs: 10 } },
|
|
301
|
-
});
|
|
302
|
-
await wireRunOnce(ir, { cwd: newCwd() });
|
|
303
|
-
expect(calls.registerFetchConfig).toEqual([{ allow: ["example.com"] }]);
|
|
304
|
-
expect(calls.registerWebFetchConfig).toEqual([{ timeoutMs: 10 }]);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
test("skips config registration when the tool is present but config absent", async () => {
|
|
308
|
-
const ir = baseIr({ tools: ["fetch", "webFetch"], toolConfigs: {} });
|
|
309
|
-
await wireRunOnce(ir, { cwd: newCwd() });
|
|
310
|
-
expect(calls.registerFetchConfig).toEqual([]);
|
|
311
|
-
expect(calls.registerWebFetchConfig).toEqual([]);
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
describe("wireRunOnce — MCP servers", () => {
|
|
316
|
-
test("builds a shared McpHost, adds each server, registers tools", async () => {
|
|
317
|
-
const ir = baseIr({
|
|
318
|
-
tools: ["read"],
|
|
319
|
-
mcp_servers: {
|
|
320
|
-
alpha: { transport: "stdio", command: "x", args: [] },
|
|
321
|
-
beta: { transport: "sse", url: "https://example.com" },
|
|
322
|
-
},
|
|
323
|
-
});
|
|
324
|
-
const deps = await wireRunOnce(ir, { cwd: newCwd() });
|
|
325
|
-
expect(deps.mcpHost).toBeInstanceOf(FakeMcpHost);
|
|
326
|
-
expect(calls.addServer.map((c) => c.name).sort()).toEqual(["alpha", "beta"]);
|
|
327
|
-
expect(calls.registerMcpServer.sort()).toEqual(["alpha", "beta"]);
|
|
328
|
-
// The pre-existing `read` tool plus the two namespaced MCP tools are present.
|
|
329
|
-
const names = deps.tools.map((t) => t.name).sort();
|
|
330
|
-
expect(names).toContain("read");
|
|
331
|
-
expect(names).toContain("mcp__alpha__do");
|
|
332
|
-
expect(names).toContain("mcp__beta__do");
|
|
333
|
-
});
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
describe("wireRunOnce — hooks / skills / slash-commands", () => {
|
|
337
|
-
test("threads loaded hooks/skills/commands and appends a skill tool", async () => {
|
|
338
|
-
hooksToReturn = [{ event: "PreToolUse" }];
|
|
339
|
-
skillsToReturn = [{ name: "skA" }];
|
|
340
|
-
commandsToReturn = [["greet", { name: "greet" }]];
|
|
341
|
-
const ir = baseIr({ tools: ["read"] });
|
|
342
|
-
const deps = await wireRunOnce(ir, { cwd: newCwd() });
|
|
343
|
-
expect(deps.hooks).toEqual(hooksToReturn as readonly HookDef[]);
|
|
344
|
-
expect(deps.skills).toEqual(skillsToReturn as readonly SkillRef[]);
|
|
345
|
-
expect(deps.slashCommands.get("greet")).toEqual({ name: "greet" } as unknown as SlashCommand);
|
|
346
|
-
// skills.length > 0 → a skill tool was created and pushed.
|
|
347
|
-
expect(calls.createSkillTool).toHaveLength(1);
|
|
348
|
-
expect(deps.tools.some((t) => t.name === "skill")).toBe(true);
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
test("does not append a skill tool when no skills are discovered", async () => {
|
|
352
|
-
skillsToReturn = [];
|
|
353
|
-
const ir = baseIr({ tools: ["read"] });
|
|
354
|
-
const deps = await wireRunOnce(ir, { cwd: newCwd() });
|
|
355
|
-
expect(calls.createSkillTool).toHaveLength(0);
|
|
356
|
-
expect(deps.tools.some((t) => t.name === "skill")).toBe(false);
|
|
357
|
-
});
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
describe("wireRunOnce — sub-agents", () => {
|
|
361
|
-
test("builds a sub-agent map, attaches spawnSubAgent, pushes a task tool", async () => {
|
|
362
|
-
const ir = baseIr({
|
|
363
|
-
tools: ["read"],
|
|
364
|
-
subAgents: [
|
|
365
|
-
// model present → the `...(model !== undefined)` spread branch.
|
|
366
|
-
subAgent({ name: "withModel", model: "claude-haiku", inheritBypass: true }),
|
|
367
|
-
// model absent → the spread is skipped.
|
|
368
|
-
subAgent({ name: "noModel", permissions: { allow: ["read"], deny: [] } }),
|
|
369
|
-
],
|
|
370
|
-
});
|
|
371
|
-
const deps = await wireRunOnce(ir, { cwd: newCwd() });
|
|
372
|
-
expect(deps.subAgents).toBeInstanceOf(Map);
|
|
373
|
-
expect(deps.subAgents?.get("withModel")?.model).toBe("claude-haiku");
|
|
374
|
-
expect(deps.subAgents?.get("withModel")?.inherit_bypass).toBe(true);
|
|
375
|
-
// model omitted ⇒ key not present on the definition.
|
|
376
|
-
expect("model" in (deps.subAgents?.get("noModel") ?? {})).toBe(false);
|
|
377
|
-
expect(deps.subAgents?.get("noModel")?.permissions).toEqual({ allow: ["read"], deny: [] });
|
|
378
|
-
expect(typeof deps.spawnSubAgent).toBe("function");
|
|
379
|
-
expect(deps.spawnSubAgent).toBe(realSpawner.spawnSubAgent);
|
|
380
|
-
expect(calls.createTaskTool).toHaveLength(1);
|
|
381
|
-
expect(deps.tools.some((t) => t.name === "task")).toBe(true);
|
|
382
|
-
});
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
describe("wireRunOnce — buildRuleSet (permission rules)", () => {
|
|
386
|
-
test("no settings.json → settings rules empty, yaml + builtin populated", async () => {
|
|
387
|
-
const ir = baseIr({
|
|
388
|
-
tools: ["read"],
|
|
389
|
-
permissions: { rules: [{ type: "alwaysAllow", pattern: "Bash(ls)" }] },
|
|
390
|
-
});
|
|
391
|
-
const deps = await wireRunOnce(ir, { cwd: newCwd() }); // tmp cwd, no settings file
|
|
392
|
-
expect(deps.permissionRules.settings).toEqual([]);
|
|
393
|
-
expect(deps.permissionRules.yaml).toEqual([
|
|
394
|
-
{ type: "alwaysAllow", pattern: "Bash(ls)", source: "yaml" },
|
|
395
|
-
]);
|
|
396
|
-
expect(deps.permissionRules.flag).toEqual([]);
|
|
397
|
-
expect(deps.permissionRules.hooks).toEqual([]);
|
|
398
|
-
expect(deps.permissionRules.builtin).toBe(realPermissionEngine.BUILTIN_DEFAULT_RULES);
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
test("settings.json with a permissions block → parsed + tagged 'settings'", async () => {
|
|
402
|
-
const settings = JSON.stringify({
|
|
403
|
-
permissions: { rules: [{ type: "alwaysDeny", pattern: "Bash(rm -rf /)" }] },
|
|
404
|
-
});
|
|
405
|
-
const ir = baseIr({ tools: ["read"] });
|
|
406
|
-
const deps = await wireRunOnce(ir, { cwd: newCwd(settings) });
|
|
407
|
-
expect(deps.permissionRules.settings).toEqual([
|
|
408
|
-
{ type: "alwaysDeny", pattern: "Bash(rm -rf /)", source: "settings" },
|
|
409
|
-
]);
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
test("settings.json present but without a permissions key → root undefined branch", async () => {
|
|
413
|
-
const settings = JSON.stringify({ somethingElse: true });
|
|
414
|
-
const ir = baseIr({ tools: ["read"] });
|
|
415
|
-
const deps = await wireRunOnce(ir, { cwd: newCwd(settings) });
|
|
416
|
-
expect(deps.permissionRules.settings).toEqual([]);
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
test("malformed settings.json → RunnerError mentioning the path", async () => {
|
|
420
|
-
const ir = baseIr({ tools: ["read"] });
|
|
421
|
-
await expect(wireRunOnce(ir, { cwd: newCwd("{ not json") })).rejects.toThrow(
|
|
422
|
-
/failed to parse .*settings\.json/,
|
|
423
|
-
);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
test("PermissionConfigError from a bad permissions block → wrapped RunnerError", async () => {
|
|
427
|
-
// `mode: bypass` is rejected by parsePermissionsConfig with a
|
|
428
|
-
// PermissionConfigError, which buildRuleSet rewraps as a RunnerError.
|
|
429
|
-
const settings = JSON.stringify({ permissions: { mode: "bypass", rules: [] } });
|
|
430
|
-
const ir = baseIr({ tools: ["read"] });
|
|
431
|
-
await expect(wireRunOnce(ir, { cwd: newCwd(settings) })).rejects.toThrow(
|
|
432
|
-
/eval-runner: .*bypass mode cannot be set/,
|
|
433
|
-
);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
test("a non-PermissionConfigError from the parser is rethrown verbatim", async () => {
|
|
437
|
-
parseMode = "throwGeneric";
|
|
438
|
-
const settings = JSON.stringify({ permissions: { rules: [] } });
|
|
439
|
-
const ir = baseIr({ tools: ["read"] });
|
|
440
|
-
// Not wrapped in RunnerError → the raw TypeError surfaces.
|
|
441
|
-
await expect(wireRunOnce(ir, { cwd: newCwd(settings) })).rejects.toThrow(
|
|
442
|
-
"not a PermissionConfigError",
|
|
443
|
-
);
|
|
444
|
-
});
|
|
445
|
-
});
|