@sassywares/agents 0.0.0-stage → 0.1.1

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,264 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Checks agent discovery and profile resolution without running a subagent.
4
+ *
5
+ * Discovery and profile parsing live in the `subagent` extension of
6
+ * `@sassywares/pi-extensions`, so this script needs that checkout (or an
7
+ * installed copy) as well as a pi install for the peer packages. It makes a
8
+ * temporary project, links the pi install's node_modules into it, copies
9
+ * agents.ts and the real roster there, and exercises the parsers.
10
+ *
11
+ * ./scripts/test-profiles.mjs
12
+ * PI_EXTENSIONS_DIR=/path/to/sassy-pi-extensions ./scripts/test-profiles.mjs
13
+ * PI_PEER_MODULES=/path/to/node_modules ./scripts/test-profiles.mjs
14
+ *
15
+ * Exits non-zero on the first failing check group.
16
+ */
17
+
18
+ import * as fs from "node:fs";
19
+ import * as os from "node:os";
20
+ import * as path from "node:path";
21
+ import { fileURLToPath, pathToFileURL } from "node:url";
22
+
23
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
24
+ const AGENTS_DIR = path.join(REPO, "pi");
25
+
26
+ function findPeerModules() {
27
+ const configured = process.env.PI_PEER_MODULES?.trim();
28
+ if (configured) return configured;
29
+
30
+ // Follow the `pi` binary on PATH to its package, then up to node_modules.
31
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
32
+ if (!dir) continue;
33
+ const candidate = path.join(dir, "pi");
34
+ try {
35
+ const real = fs.realpathSync(candidate);
36
+ const index = real.lastIndexOf(`${path.sep}node_modules${path.sep}`);
37
+ if (index !== -1) return real.slice(0, index + `${path.sep}node_modules`.length);
38
+ } catch {
39
+ // Not here; keep looking.
40
+ }
41
+ }
42
+
43
+ const managed = managedInstallModules();
44
+ if (managed) return managed;
45
+
46
+ throw new Error("Could not find the pi install. Set PI_PEER_MODULES to its node_modules directory.");
47
+ }
48
+
49
+ /** Fall back to the managed install layout: <agent dir>/install/releases/<version>/node_modules. */
50
+ function managedInstallModules() {
51
+ const agentDir = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi/agent");
52
+ try {
53
+ const version = fs.readFileSync(path.join(agentDir, "install/current-version"), "utf-8").trim();
54
+ if (!version) return undefined;
55
+ const modules = path.join(agentDir, "install/releases", version, "node_modules");
56
+ return fs.existsSync(path.join(modules, "@earendil-works/pi-coding-agent")) ? modules : undefined;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ /** agents.ts is the source of truth for parsing; find it in a checkout or an install. */
63
+ function findAgentsModule(peers) {
64
+ const candidates = [];
65
+ const configured = process.env.PI_EXTENSIONS_DIR?.trim();
66
+ if (configured) candidates.push(path.join(configured, "extensions/subagent/agents.ts"));
67
+ candidates.push(path.join(REPO, "..", "sassy-pi-extensions/extensions/subagent/agents.ts"));
68
+ candidates.push(path.join(peers, "@sassywares/pi-extensions/extensions/subagent/agents.ts"));
69
+
70
+ for (const candidate of candidates) {
71
+ if (fs.existsSync(candidate)) return candidate;
72
+ }
73
+
74
+ throw new Error(
75
+ "Could not find extensions/subagent/agents.ts. Set PI_EXTENSIONS_DIR to a sassy-pi-extensions checkout, " +
76
+ "or clone it next to this repo.",
77
+ );
78
+ }
79
+
80
+ const peers = findPeerModules();
81
+ const sourceAgentsModule = findAgentsModule(peers);
82
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "sassy-profiles-"));
83
+
84
+ function cleanups() {
85
+ const nodeModules = path.join(workspace, "node_modules");
86
+ fs.mkdirSync(nodeModules, { recursive: true });
87
+ for (const pkg of ["@earendil-works", "typebox"]) {
88
+ fs.symlinkSync(path.join(peers, pkg), path.join(nodeModules, pkg));
89
+ }
90
+ }
91
+
92
+ cleanups();
93
+ fs.copyFileSync(sourceAgentsModule, path.join(workspace, "agents.ts"));
94
+
95
+ const agents = await import(pathToFileURL(path.join(workspace, "agents.ts")).href);
96
+
97
+ let failures = 0;
98
+
99
+ function check(name, fn) {
100
+ try {
101
+ fn();
102
+ console.log(`ok ${name}`);
103
+ } catch (error) {
104
+ failures++;
105
+ console.log(`FAIL ${name}: ${error.message}`);
106
+ }
107
+ }
108
+
109
+ function assert(condition, message) {
110
+ if (!condition) throw new Error(message);
111
+ }
112
+
113
+ const agentDir = path.join(workspace, ".pi/agents");
114
+ fs.mkdirSync(agentDir, { recursive: true });
115
+
116
+ const shipped = fs.readdirSync(AGENTS_DIR).filter((file) => file.endsWith(".md"));
117
+ if (shipped.length === 0) {
118
+ console.log(`FAIL no agents found under ${AGENTS_DIR}`);
119
+ process.exit(1);
120
+ }
121
+
122
+ for (const file of shipped) {
123
+ fs.copyFileSync(path.join(AGENTS_DIR, file), path.join(agentDir, file));
124
+ }
125
+
126
+ function fixture(name, frontmatter) {
127
+ fs.writeFileSync(path.join(agentDir, `${name}.md`), `---\n${frontmatter}\n---\n\nbody\n`);
128
+ }
129
+
130
+ fixture("pinned", "name: pinned\ndescription: pinned single model\nmodel: openai/gpt-4o\nthinking: low");
131
+ fixture("inherit", "name: inherit\ndescription: inherits the dispatcher");
132
+ fixture(
133
+ "keyed",
134
+ "name: keyed\ndescription: single-key-map shape\nprofiles:\n - deepseek:\n model: deepseek/deepseek-flash",
135
+ );
136
+ fixture(
137
+ "both",
138
+ "name: both\ndescription: model and profiles\nmodel: openai/gpt-4o\nprofiles:\n - name: deepseek\n model: deepseek/deepseek-flash",
139
+ );
140
+ fixture(
141
+ "badthinking",
142
+ "name: badthinking\ndescription: bad level\nprofiles:\n - name: deepseek\n model: deepseek/deepseek-flash\n thinking: veryhigh",
143
+ );
144
+ fixture(
145
+ "badmodel",
146
+ "name: badmodel\ndescription: bare model id\nprofiles:\n - name: deepseek\n model: deepseek-flash",
147
+ );
148
+ fixture(
149
+ "dup",
150
+ "name: dup\ndescription: duplicate profiles\nprofiles:\n - name: deepseek\n model: deepseek/deepseek-flash\n - name: deepseek\n model: anthropic/claude-haiku-4-5",
151
+ );
152
+
153
+ let discovery;
154
+ try {
155
+ discovery = agents.discoverAgents(workspace, "project");
156
+ } catch (error) {
157
+ console.log(`FAIL discovery threw: ${error.message}`);
158
+ process.exit(1);
159
+ }
160
+ const byName = new Map(discovery.agents.map((a) => [a.name, a]));
161
+
162
+ check("every shipped agent parses", () => {
163
+ for (const file of shipped) {
164
+ const name = file.replace(/\.md$/, "");
165
+ const agent = byName.get(name);
166
+ assert(agent, `${name} was not discovered`);
167
+ assert(!agent.error, `${name}: ${agent.error}`);
168
+ }
169
+ });
170
+
171
+ check("every shipped agent offers the default profile", () => {
172
+ for (const agent of discovery.agents) {
173
+ if (agent.source !== "project" || agent.error) continue;
174
+ if (!agent.profiles) continue;
175
+ assert(
176
+ agent.profiles.some((p) => p.name === agents.DEFAULT_PROFILE),
177
+ `${agent.name} has no "${agents.DEFAULT_PROFILE}" profile`,
178
+ );
179
+ }
180
+ });
181
+
182
+ check("a profile resolves its model and thinking", () => {
183
+ const resolved = agents.resolveAgentModel(byName.get("reviewer"), { name: "anthropic", source: "settings" }, {});
184
+ assert(resolved.model === "anthropic/claude-opus-4-5", `model ${resolved.model}`);
185
+ assert(resolved.thinking === "high", `thinking ${resolved.thinking}`);
186
+ assert(resolved.profile === "anthropic", `profile ${resolved.profile}`);
187
+ });
188
+
189
+ check("a missing profile is an error naming the alternatives", () => {
190
+ const resolved = agents.resolveAgentModel(byName.get("reviewer"), { name: "openai", source: "settings" }, {});
191
+ assert(resolved.error?.includes('no "openai" profile'), resolved.error);
192
+ assert(resolved.error?.includes("deepseek, anthropic"), resolved.error);
193
+ });
194
+
195
+ check("a pinned agent ignores profile selection", () => {
196
+ const resolved = agents.resolveAgentModel(byName.get("pinned"), { name: "anthropic", source: "settings" }, {});
197
+ assert(resolved.model === "openai/gpt-4o", resolved.model);
198
+ assert(resolved.profile === undefined, `profile ${resolved.profile}`);
199
+ });
200
+
201
+ check("an agent with neither inherits the dispatcher", () => {
202
+ const resolved = agents.resolveAgentModel(byName.get("inherit"), { name: "anthropic", source: "settings" }, {
203
+ model: "anthropic/claude-sonnet-4-5",
204
+ thinkingLevel: "medium",
205
+ profile: { name: "anthropic", source: "settings" },
206
+ });
207
+ assert(resolved.model === "anthropic/claude-sonnet-4-5", resolved.model);
208
+ assert(resolved.thinking === "medium", resolved.thinking);
209
+ });
210
+
211
+ check("selection precedence is parameter, env, settings, default", () => {
212
+ const settings = { sassy: { profile: "from-settings" } };
213
+ const env = { PI_SASSY_PROFILE: "from-env" };
214
+ assert(agents.resolveProfileSelection("from-param", settings, env).source === "parameter");
215
+ assert(agents.resolveProfileSelection(undefined, settings, env).name === "from-env");
216
+ assert(agents.resolveProfileSelection(undefined, settings, {}).name === "from-settings");
217
+ assert(agents.resolveProfileSelection(undefined, {}, {}).name === agents.DEFAULT_PROFILE);
218
+ assert(agents.resolveProfileSelection(undefined, {}, {}).source === "default");
219
+ });
220
+
221
+ check("a blank or non-string setting falls through to the default", () => {
222
+ assert(agents.resolveProfileSelection(undefined, { sassy: { profile: " " } }, {}).source === "default");
223
+ assert(agents.resolveProfileSelection(undefined, { sassy: { profile: 42 } }, {}).source === "default");
224
+ });
225
+
226
+ check("the single-key-map shape is rejected clearly", () => {
227
+ const agent = byName.get("keyed");
228
+ assert(agent?.error?.includes('unknown key "deepseek"'), agent?.error);
229
+ });
230
+
231
+ check("model plus profiles is rejected", () => {
232
+ assert(byName.get("both")?.error === 'use either "profiles" or "model", not both', byName.get("both")?.error);
233
+ });
234
+
235
+ check("an invalid thinking level is rejected", () => {
236
+ assert(byName.get("badthinking")?.error?.includes("thinking must be one of"), byName.get("badthinking")?.error);
237
+ });
238
+
239
+ check("a bare model id is rejected", () => {
240
+ assert(byName.get("badmodel")?.error?.includes("must be provider/id"), byName.get("badmodel")?.error);
241
+ });
242
+
243
+ check("duplicate profile names are rejected", () => {
244
+ assert(byName.get("dup")?.error === 'profiles[1] repeats profile "deepseek"', byName.get("dup")?.error);
245
+ });
246
+
247
+ check("an unusable agent fails at dispatch instead of inheriting", () => {
248
+ const resolved = agents.resolveAgentModel(byName.get("keyed"), { name: "deepseek", source: "default" }, {});
249
+ assert(resolved.error?.includes("is unusable"), resolved.error);
250
+ assert(resolved.error?.includes("unknown key"), resolved.error);
251
+ });
252
+
253
+ check("a per-task override keeps the other defaults", () => {
254
+ const defaults = { model: "x/y", thinkingLevel: "low", profile: { name: "deepseek", source: "default" } };
255
+ const overridden = agents.withProfile(defaults, "anthropic");
256
+ assert(overridden.profile.name === "anthropic", overridden.profile.name);
257
+ assert(overridden.profile.source === "parameter", overridden.profile.source);
258
+ assert(overridden.model === "x/y", "defaults were lost");
259
+ assert(agents.withProfile(defaults, " ") === defaults, "a blank override should be a no-op");
260
+ });
261
+
262
+ fs.rmSync(workspace, { recursive: true, force: true });
263
+ console.log(failures === 0 ? "\nall checks passed" : `\n${failures} check(s) failed`);
264
+ process.exit(failures === 0 ? 0 : 1);