@app-vitals/agent-admin 0.16.0 → 0.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anthropic-ai/sdk": "0.135.0",
46
- "@app-vitals/agent-lib": "0.16.0",
46
+ "@app-vitals/agent-lib": "0.16.1",
47
47
  "@hono/zod-openapi": "^1.0.0",
48
48
  "@prisma/adapter-pg": "^7.10.0",
49
49
  "@prisma/client": "^7.10.0",
@@ -14,7 +14,7 @@ import { resolveDefaultSpecialtyName } from "./specialty-manifest-loader.ts";
14
14
  /**
15
15
  * The only specialty account users may create: SHIPWRIGHT_SELF_SERVE_AGENT_TYPE
16
16
  * when set, else the platform's default specialty (SHIPWRIGHT_DEFAULT_AGENT_TYPE,
17
- * "coding" when unset).
17
+ * "specialist" when unset).
18
18
  */
19
19
  export function getSelfServeSpecialty(
20
20
  env: Record<string, string | undefined> = process.env,
@@ -7,8 +7,8 @@ import { describe, expect, it } from "bun:test";
7
7
  import { getSelfServeSpecialty } from "./account-agent-create.ts";
8
8
 
9
9
  describe("getSelfServeSpecialty", () => {
10
- it("defaults to coding", () => {
11
- expect(getSelfServeSpecialty({})).toBe("coding");
10
+ it("defaults to specialist", () => {
11
+ expect(getSelfServeSpecialty({})).toBe("specialist");
12
12
  expect(
13
13
  getSelfServeSpecialty({ SHIPWRIGHT_DEFAULT_AGENT_TYPE: "fixture" }),
14
14
  ).toBe("fixture");
@@ -80,7 +80,7 @@ export interface AgentDetail {
80
80
  * Rendered as a checkbox on both the create and edit forms.
81
81
  */
82
82
  restrictSlackToMembers: boolean;
83
- /** The agent's type name (e.g. "coding"), resolved at creation time. */
83
+ /** The agent's type name (e.g. "specialist"), resolved at creation time. */
84
84
  specialty: string;
85
85
  /**
86
86
  * Required env keys declared by the agent's type manifest with no
@@ -488,7 +488,7 @@ export function renderNewLocalAgentPage(
488
488
  /**
489
489
  * Set for self-serve account users (SSP-4.2): shows the account's
490
490
  * remaining agent quota and the prerequisites, and pins the type to
491
- * "coding" (the server forces it regardless).
491
+ * the self-serve specialty (the server forces it regardless).
492
492
  */
493
493
  selfServe?: { remaining: number; max: number; contactEmail: string };
494
494
  },
@@ -4289,7 +4289,9 @@ describe("renderNewLocalAgentPage — self-serve account user (SSP-4.2)", () =>
4289
4289
  const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
4290
4290
  selfServe,
4291
4291
  });
4292
- expect(html).toContain('<input type="hidden" name="type" value="coding"');
4292
+ expect(html).toContain(
4293
+ '<input type="hidden" name="type" value="specialist"',
4294
+ );
4293
4295
  });
4294
4296
  });
4295
4297
 
package/src/admin-ui.ts CHANGED
@@ -1511,7 +1511,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
1511
1511
  return c.redirect("/admin/agents/new", 302);
1512
1512
  }
1513
1513
  const connectSlack = connectSlackRaw === "true";
1514
- // SSP-4.2: account users always create "coding" agents in their own
1514
+ // SSP-4.2: account users always create self-serve-specialty agents in their own
1515
1515
  // account — any submitted type/accountId is ignored.
1516
1516
  if (accountId !== null) specialty = getSelfServeSpecialty();
1517
1517
  // Absent/unrecognized runtime means self-hosted — the historical behavior
@@ -8,20 +8,17 @@
8
8
  */
9
9
 
10
10
  import type { AgentCronJob, PrismaClient } from "../prisma/client/client.ts";
11
- import {
12
- type SpecialtyManifestResolver,
13
- SpecialtyRegistry,
14
- missingDefaultSpecialtyError,
15
- resolveDefaultSpecialtyName,
16
- resolveSpecialtiesDir,
17
- } from "./specialty-manifest-loader.ts";
11
+ import { type Clock, SystemClock } from "./clock.ts";
12
+ import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
18
13
  import type {
19
14
  SpecialtyDefaultCron,
20
15
  SpecialtyDefaultsResolver,
21
16
  } from "./specialty-defaults.ts";
17
+ import {
18
+ type SpecialtyManifestResolver,
19
+ SpecialtyRegistry,
20
+ } from "./specialty-manifest-loader.ts";
22
21
  import type { SpecialtyManifest } from "./specialty-registry.ts";
23
- import { type Clock, SystemClock } from "./clock.ts";
24
- import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
25
22
 
26
23
  /**
27
24
  * The shape of a single cron template resolved from a specialty's manifest.
@@ -448,11 +445,10 @@ export class AgentCronJobService {
448
445
  * The cron list is resolved from the agent's type manifest — the agent's
449
446
  * stored `specialty` is looked up via the injected SpecialtyRegistry's
450
447
  * `tryGetManifest()` (the parsed `specialties/{specialty}/manifest.yaml`),
451
- * deliberately WITHOUT the "coding" fallback: a non-default type with no
452
- * loadable manifest logs an error and reconciles nothing (no seeding of
453
- * coding crons, no deletion of existing system crons), while a "coding"
454
- * manifest that cannot load throws — the universal fallback failing is a
455
- * startup bug. This replaced the former hardcoded
448
+ * deliberately WITHOUT the default-type fallback: any type (the default
449
+ * included) with no loadable manifest logs an error and reconciles nothing
450
+ * (no seeding of another type's crons, no deletion of existing system
451
+ * crons). This replaced the former hardcoded
456
452
  * SYSTEM_CRONS array as the source of truth (ATS-3.2).
457
453
  *
458
454
  * Two passes so parent/child links resolve regardless of the manifest's
@@ -488,9 +484,9 @@ export class AgentCronJobService {
488
484
  async reconcileSystemCrons(
489
485
  agentId: string,
490
486
  ): Promise<{ created: number; updated: number; deleted: number }> {
491
- // Resolve the agent's type manifest → its declared cron list. No "coding"
492
- // fallback: seeding enabled coding crons on a non-coding agent is worse
493
- // than leaving its crons untouched.
487
+ // Resolve the agent's type manifest → its declared cron list. No default-type
488
+ // fallback: seeding another type's crons on an agent is worse than
489
+ // leaving its crons untouched.
494
490
  const agent = await this.prisma.agent.findUnique({
495
491
  where: { id: agentId },
496
492
  select: { specialty: true },
@@ -505,14 +501,6 @@ export class AgentCronJobService {
505
501
  ? undefined
506
502
  : this.specialtyRegistry.tryGetManifest(specialty);
507
503
  if (!extensionCrons && !manifest) {
508
- const defaultName = resolveDefaultSpecialtyName();
509
- if (specialty === defaultName) {
510
- throw missingDefaultSpecialtyError(
511
- defaultName,
512
- resolveSpecialtiesDir(),
513
- new Error(`reconcileSystemCrons for agent ${agentId}`),
514
- );
515
- }
516
504
  console.error(
517
505
  `reconcileSystemCrons: no loadable manifest for specialty "${specialty}" (agent ${agentId}) — leaving crons untouched`,
518
506
  );
@@ -12,8 +12,8 @@ import { describe, expect, test } from "bun:test";
12
12
  import type { AgentCronJob, PrismaClient } from "../prisma/client/client.ts";
13
13
  import { AgentCronJobService, isValidCron } from "./agent-cron-jobs.ts";
14
14
  import {
15
- type SpecialtyManifestResolver,
16
15
  resolveDefaultSpecialtyName,
16
+ type SpecialtyManifestResolver,
17
17
  } from "./specialty-manifest-loader.ts";
18
18
  import type { SpecialtyManifest } from "./specialty-registry.ts";
19
19
 
@@ -374,36 +374,29 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
374
374
  expect(String(errors[0]?.[0])).toContain("agent-1");
375
375
  });
376
376
 
377
- test("the default manifest that cannot load still throws", async () => {
377
+ test("the default type with no loadable manifest logs an error and changes nothing", async () => {
378
378
  const registry = fakeRegistry({});
379
- const { prisma } = makeFakePrisma({
380
- specialty: resolveDefaultSpecialtyName(),
381
- });
382
- const service = new AgentCronJobService(
383
- prisma as unknown as PrismaClient,
384
- undefined,
385
- registry,
386
- );
387
-
388
- await expect(service.reconcileSystemCrons("agent-1")).rejects.toThrow(
389
- resolveDefaultSpecialtyName(),
390
- );
391
- });
392
-
393
- test("the default-manifest failure names the env vars that configure it", async () => {
394
- const registry = fakeRegistry({});
395
- const { prisma } = makeFakePrisma({
396
- specialty: resolveDefaultSpecialtyName(),
379
+ const { prisma, rows } = makeFakePrisma({
380
+ specialty: "specialist",
381
+ existing: [{ id: "sys-1", name: "old-system", system: true }],
397
382
  });
398
383
  const service = new AgentCronJobService(
399
384
  prisma as unknown as PrismaClient,
400
385
  undefined,
401
386
  registry,
402
387
  );
403
-
404
- await expect(service.reconcileSystemCrons("agent-1")).rejects.toThrow(
405
- /SHIPWRIGHT_SPECIALTIES_DIR.*SHIPWRIGHT_DEFAULT_AGENT_TYPE/s,
406
- );
388
+ const original = console.error;
389
+ console.error = () => {};
390
+ try {
391
+ expect(await service.reconcileSystemCrons("agent-1")).toEqual({
392
+ created: 0,
393
+ updated: 0,
394
+ deleted: 0,
395
+ });
396
+ } finally {
397
+ console.error = original;
398
+ }
399
+ expect(Array.from(rows.keys())).toEqual(["sys-1"]);
407
400
  });
408
401
 
409
402
  test("a valid non-coding manifest reconciles normally", async () => {
@@ -50,13 +50,17 @@ describe("SpecialtyRegistry with the fixture specialty", () => {
50
50
  expect(fixtureRegistry().tryGetManifest("renamed-type")).toBeUndefined();
51
51
  });
52
52
 
53
- it("fails with an actionable error when the default specialty is not on disk", () => {
53
+ it("warns and returns an empty-crons manifest when the default specialty is not on disk", () => {
54
+ const warnings: string[] = [];
54
55
  const registry = new SpecialtyRegistry({
55
56
  typesDir: FIXTURE_SPECIALTIES_DIR,
56
57
  defaultTypeName: "not-there",
57
- warn: () => {},
58
+ warn: (m) => warnings.push(m),
58
59
  });
59
- expect(() => registry.getManifest("fixture-or-anything")).toThrow(
60
+ const manifest = registry.getManifest("fixture-or-anything");
61
+ expect(manifest.crons).toEqual([]);
62
+ expect(manifest.plugins).toEqual([]);
63
+ expect(warnings.join("\n")).toMatch(
60
64
  /SHIPWRIGHT_SPECIALTIES_DIR.*SHIPWRIGHT_DEFAULT_AGENT_TYPE/s,
61
65
  );
62
66
  });
@@ -2,24 +2,25 @@
2
2
  * admin/src/specialty-manifest-loader.ts
3
3
  *
4
4
  * SpecialtyRegistry — resolves an agent's stored `specialty` to its parsed
5
- * Specialty manifest by loading `specialties/{specialty}/manifest.yaml` from
6
- * disk (relative to the repo root) and validating it via
5
+ * Specialty manifest by loading `{typesDir}/{specialty}/manifest.yaml` from
6
+ * disk (typesDir defaults to the shipped agent-types/) and validating it via
7
7
  * parseSpecialtyManifest().
8
8
  *
9
9
  * This is the runtime source of truth for an agent's default system crons
10
10
  * (and, in time, its default plugins/tools/env contract). It replaces the
11
11
  * former hardcoded SYSTEM_CRONS array in admin/src/system-crons.ts.
12
12
  *
13
- * Fallback contract: an unknown `specialty` (a value with no matching
14
- * directory under specialties/) resolves to the "coding" manifest with a
15
- * logged warning — never throws. This keeps the boot-path
16
- * POST /agents/:id/crons/reconcile route from beginning to 5xx for an agent
17
- * whose type was renamed or removed.
13
+ * Defaults: the default type is `specialist` and the type directory is the
14
+ * shipped `agent-types/` (both resolved by @app-vitals/agent-lib/default-specialty,
15
+ * the single shared helper; SHIPWRIGHT_DEFAULT_AGENT_TYPE /
16
+ * SHIPWRIGHT_SPECIALTIES_DIR override them).
18
17
  *
19
- * Hard-failure contract: if the "coding" manifest itself (the universal
20
- * fallback) cannot be loaded or fails validation, that is a configuration error
21
- * (it names SHIPWRIGHT_SPECIALTIES_DIR / SHIPWRIGHT_DEFAULT_AGENT_TYPE) and the error propagates — we never silently degrade the fallback into a
22
- * no-op that would strip every agent's system crons.
18
+ * Fallback contract: getManifest() never throws. An unknown `specialty` (no
19
+ * matching directory under the type dir) resolves to the default type's
20
+ * manifest with a logged warning. If even the default cannot be loaded (bad
21
+ * env override, missing/invalid manifest) it warns and returns a built-in
22
+ * minimal manifest with no crons/plugins, so the boot-path
23
+ * POST /agents/:id/crons/reconcile route never 5xxs over configuration.
23
24
  *
24
25
  * The class takes an injectable reader/warner so unit tests can drive it
25
26
  * without touching the filesystem (see agent-cron-jobs.unit.test.ts).
@@ -28,12 +29,47 @@
28
29
  import { readdirSync, readFileSync } from "node:fs";
29
30
  import { join } from "node:path";
30
31
  import {
31
- type SpecialtyManifest,
32
+ DEFAULT_SPECIALTY_NAME,
33
+ resolveDefaultSpecialtyName,
34
+ resolveSpecialtiesDir,
35
+ } from "@app-vitals/agent-lib/default-specialty";
36
+ import {
32
37
  parseSpecialtyManifest,
38
+ type SpecialtyManifest,
33
39
  } from "./specialty-registry.ts";
34
40
 
35
- /** The universal fallback type — every agent degrades to this manifest. */
36
- export const DEFAULT_SPECIALTY_NAME = "coding";
41
+ export {
42
+ DEFAULT_SPECIALTY_NAME,
43
+ resolveDefaultSpecialtyName,
44
+ resolveSpecialtiesDir,
45
+ };
46
+
47
+ /**
48
+ * Built-in last-resort manifest: used only when the default type itself cannot
49
+ * be loaded. Declares nothing (no crons, plugins, tools) so it never seeds
50
+ * anything unexpected.
51
+ */
52
+ const BUILT_IN_FALLBACK_MANIFEST_YAML = `apiVersion: shipwright.dev/v1alpha1
53
+ kind: Specialty
54
+ metadata:
55
+ name: ${DEFAULT_SPECIALTY_NAME}
56
+ displayName: Specialist Agent
57
+ description: Built-in fallback used when the default specialty manifest cannot be loaded.
58
+ version: 0.0.0
59
+ skills: []
60
+ identity:
61
+ templatesDir: workspace/
62
+ crons: []
63
+ plugins: []
64
+ tools: []
65
+ env:
66
+ required: []
67
+ optional: []
68
+ members: []
69
+ repos: []
70
+ chat: true
71
+ voice: true
72
+ `;
37
73
 
38
74
  /** Reads a manifest's raw YAML text for a type; throws if it does not exist. */
39
75
  type ManifestReader = (specialty: string) => string;
@@ -54,51 +90,6 @@ export interface SpecialtyOption {
54
90
  displayName: string;
55
91
  }
56
92
 
57
- /**
58
- * Repo root resolved relative to this module (admin/src/ → ../../). The
59
- * specialties/ directory lives at the repo root alongside admin/.
60
- */
61
- const REPO_ROOT = join(import.meta.dir, "..", "..");
62
- const BUILT_IN_SPECIALTIES_DIR = join(REPO_ROOT, "specialties");
63
-
64
- /**
65
- * The specialties directory: SHIPWRIGHT_SPECIALTIES_DIR when set, else the
66
- * repo-root specialties/ (today's location).
67
- */
68
- export function resolveSpecialtiesDir(
69
- env: Record<string, string | undefined> = process.env,
70
- ): string {
71
- return env.SHIPWRIGHT_SPECIALTIES_DIR || BUILT_IN_SPECIALTIES_DIR;
72
- }
73
-
74
- /**
75
- * The universal fallback type: SHIPWRIGHT_DEFAULT_AGENT_TYPE when set, else
76
- * "coding".
77
- */
78
- /**
79
- * The actionable error for a default specialty that cannot be loaded. The
80
- * platform ships no specialty of its own, so this is a configuration problem:
81
- * the operator must point the two env vars at a real specialties directory.
82
- */
83
- export function missingDefaultSpecialtyError(
84
- defaultTypeName: string,
85
- typesDir: string,
86
- cause?: unknown,
87
- ): Error {
88
- const detail = cause instanceof Error ? ` (${cause.message})` : "";
89
- return new Error(
90
- `SpecialtyRegistry: the default specialty "${defaultTypeName}" could not be loaded from "${typesDir}"${detail}. ` +
91
- "Set SHIPWRIGHT_SPECIALTIES_DIR to a directory containing <type>/manifest.yaml and " +
92
- "SHIPWRIGHT_DEFAULT_AGENT_TYPE to the name of a specialty in it.",
93
- );
94
- }
95
-
96
- export function resolveDefaultSpecialtyName(
97
- env: Record<string, string | undefined> = process.env,
98
- ): string {
99
- return env.SHIPWRIGHT_DEFAULT_AGENT_TYPE || DEFAULT_SPECIALTY_NAME;
100
- }
101
-
102
93
  /** Default reader: loads {typesDir}/{specialty}/manifest.yaml from disk. */
103
94
  export function defaultManifestReader(
104
95
  specialty: string,
@@ -122,18 +113,19 @@ export function defaultTypeDirLister(
122
113
  */
123
114
  export interface SpecialtyManifestResolver {
124
115
  /**
125
- * Resolve `specialty` to its parsed manifest. Falls back to the "coding"
126
- * manifest (with a warning) when the type is unknown. Never throws for an
127
- * unknown type — only if the "coding" fallback itself fails to load.
116
+ * Resolve `specialty` to its parsed manifest. Falls back to the default
117
+ * type's manifest (with a warning) when the type is unknown, and to a
118
+ * built-in empty-crons manifest (with a warning) if the default cannot load
119
+ * either. Never throws.
128
120
  */
129
121
  getManifest(specialty: string): SpecialtyManifest;
130
122
  /**
131
123
  * Resolve `specialty` to its parsed manifest WITHOUT falling back to
132
- * "coding" — returns undefined for an unknown type instead. No warning is
124
+ * the default type — returns undefined for an unknown type instead. No warning is
133
125
  * logged (an unknown type here is an expected, callers-handle-it outcome,
134
126
  * not a degraded-boot condition). Used by POST /admin/agents to reject an
135
127
  * unknown requested `type` with 400 rather than silently seeding a
136
- * "coding" agent under a different label.
128
+ * default-type agent under a different label.
137
129
  */
138
130
  tryGetManifest(specialty: string): SpecialtyManifest | undefined;
139
131
  /**
@@ -157,8 +149,8 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
157
149
 
158
150
  /**
159
151
  * `typesDir` and `defaultTypeName` default to SHIPWRIGHT_SPECIALTIES_DIR /
160
- * SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to today's repo-root specialties/
161
- * and "coding". An injected `read`/`list` takes precedence over `typesDir`.
152
+ * SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to the shipped agent-types/
153
+ * and "specialist". An injected `read`/`list` takes precedence over `typesDir`.
162
154
  */
163
155
  constructor(
164
156
  opts: {
@@ -179,33 +171,29 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
179
171
  }
180
172
 
181
173
  getManifest(specialty: string): SpecialtyManifest {
182
- const resolved = this.tryLoad(specialty);
174
+ const resolved = this.tryLoadSafe(specialty);
183
175
  if (resolved) return resolved;
184
176
 
185
- // Unknown type — warn once and fall back to "coding". The fallback load is
186
- // deliberately NOT wrapped in tryLoad: if "coding" can't load, that's a
187
- // hard startup bug and must surface, not silently become a no-op.
188
177
  this.warn(
189
178
  `SpecialtyRegistry: unknown agent specialty "${specialty}" — ` +
190
179
  `falling back to "${this.defaultTypeName}" manifest`,
191
180
  );
192
- if (specialty === this.defaultTypeName) {
193
- // Guard against infinite intent: the default itself failed to load.
194
- throw missingDefaultSpecialtyError(this.defaultTypeName, this.typesDir);
195
- }
196
- try {
197
- return this.loadOrThrow(this.defaultTypeName);
198
- } catch (err) {
199
- throw missingDefaultSpecialtyError(
200
- this.defaultTypeName,
201
- this.typesDir,
202
- err,
203
- );
181
+ if (specialty !== this.defaultTypeName) {
182
+ const fallback = this.tryLoadSafe(this.defaultTypeName);
183
+ if (fallback) return fallback;
204
184
  }
185
+ // Even the default is unloadable (bad SHIPWRIGHT_SPECIALTIES_DIR /
186
+ // SHIPWRIGHT_DEFAULT_AGENT_TYPE override). Degrade to an empty manifest
187
+ // rather than failing boot; the warning names the knobs to fix.
188
+ this.warn(
189
+ `SpecialtyRegistry: the default specialty "${this.defaultTypeName}" could not be loaded from "${this.typesDir}" — ` +
190
+ "using a built-in manifest with no crons. Check SHIPWRIGHT_SPECIALTIES_DIR and SHIPWRIGHT_DEFAULT_AGENT_TYPE.",
191
+ );
192
+ return parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
205
193
  }
206
194
 
207
195
  /**
208
- * Resolve `specialty` without falling back to "coding" — see the interface
196
+ * Resolve `specialty` without falling back to the default type — see the interface
209
197
  * doc comment. Thin public wrapper around the existing tryLoad() used
210
198
  * internally by getManifest().
211
199
  */
@@ -227,6 +215,15 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
227
215
  return options;
228
216
  }
229
217
 
218
+ /** tryLoad, but an invalid (unparseable) manifest also yields undefined. */
219
+ private tryLoadSafe(specialty: string): SpecialtyManifest | undefined {
220
+ try {
221
+ return this.tryLoad(specialty);
222
+ } catch {
223
+ return undefined;
224
+ }
225
+ }
226
+
230
227
  /** Load + cache a manifest, returning undefined only if the file is missing. */
231
228
  private tryLoad(specialty: string): SpecialtyManifest | undefined {
232
229
  const cached = this.cache.get(specialty);
@@ -240,13 +237,6 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
240
237
  return this.parseAndCache(specialty, raw);
241
238
  }
242
239
 
243
- /** Load + cache a manifest, propagating any read/parse error. */
244
- private loadOrThrow(specialty: string): SpecialtyManifest {
245
- const cached = this.cache.get(specialty);
246
- if (cached) return cached;
247
- return this.parseAndCache(specialty, this.read(specialty));
248
- }
249
-
250
240
  private parseAndCache(specialty: string, raw: string): SpecialtyManifest {
251
241
  const manifest = parseSpecialtyManifest(raw);
252
242
  this.cache.set(specialty, manifest);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * admin/src/specialty-manifest-loader.unit.test.ts
3
3
  * Unit tests for SpecialtyRegistry — the disk-backed resolver from an agent's
4
- * specialty to its parsed manifest, with the unknown-type "coding" fallback.
4
+ * specialty to its parsed manifest, with the unknown-type default-type fallback.
5
5
  *
6
6
  * Pure logic against an injected reader/warner — no filesystem, no DB. See
7
7
  * docs/testing.md for the unit-layer contract.
@@ -9,10 +9,10 @@
9
9
 
10
10
  import { describe, expect, it } from "bun:test";
11
11
  import {
12
- SpecialtyRegistry,
13
12
  DEFAULT_SPECIALTY_NAME,
14
- resolveSpecialtiesDir,
15
13
  resolveDefaultSpecialtyName,
14
+ resolveSpecialtiesDir,
15
+ SpecialtyRegistry,
16
16
  } from "./specialty-manifest-loader.ts";
17
17
 
18
18
  /** A minimal valid Specialty manifest YAML for a given type name. */
@@ -49,17 +49,17 @@ describe("SpecialtyRegistry", () => {
49
49
  defaultTypeName: DEFAULT_SPECIALTY_NAME,
50
50
  read: (t) => manifestYaml(t),
51
51
  });
52
- const manifest = registry.getManifest("coding");
53
- expect(manifest.metadata.name).toBe("coding");
54
- expect(manifest.crons[0]?.name).toBe("coding-cron");
52
+ const manifest = registry.getManifest("specialist");
53
+ expect(manifest.metadata.name).toBe("specialist");
54
+ expect(manifest.crons[0]?.name).toBe("specialist-cron");
55
55
  });
56
56
 
57
- it("falls back to the coding manifest with a warning for an unknown specialty", () => {
57
+ it("falls back to the default (specialist) manifest with a warning for an unknown specialty", () => {
58
58
  const warnings: string[] = [];
59
59
  const registry = new SpecialtyRegistry({
60
60
  defaultTypeName: DEFAULT_SPECIALTY_NAME,
61
61
  read: (t) => {
62
- if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("coding");
62
+ if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("specialist");
63
63
  throw new Error(`ENOENT: no manifest for ${t}`);
64
64
  },
65
65
  warn: (m) => warnings.push(m),
@@ -67,51 +67,49 @@ describe("SpecialtyRegistry", () => {
67
67
 
68
68
  const manifest = registry.getManifest("renamed-type");
69
69
 
70
- expect(manifest.metadata.name).toBe("coding");
70
+ expect(manifest.metadata.name).toBe("specialist");
71
71
  expect(warnings).toHaveLength(1);
72
72
  expect(warnings[0]).toContain("renamed-type");
73
- expect(warnings[0]).toContain("coding");
74
- });
75
-
76
- it("throws (does not silently no-op) when the coding fallback itself cannot load", () => {
77
- const registry = new SpecialtyRegistry({
78
- defaultTypeName: DEFAULT_SPECIALTY_NAME,
79
- read: () => {
80
- throw new Error("ENOENT: coding manifest missing");
81
- },
82
- warn: () => {},
83
- });
84
-
85
- // Requesting the default type when it can't load must surface a hard error
86
- // rather than degrade into an empty cron set that would strip system crons.
87
- expect(() => registry.getManifest("coding")).toThrow();
73
+ expect(warnings[0]).toContain("specialist");
88
74
  });
89
75
 
90
- it("names SHIPWRIGHT_SPECIALTIES_DIR and SHIPWRIGHT_DEFAULT_AGENT_TYPE when the default specialty is missing", () => {
76
+ it("never throws: an unloadable default degrades to an empty-crons manifest with warnings naming the env knobs", () => {
77
+ const warnings: string[] = [];
91
78
  const registry = new SpecialtyRegistry({
92
79
  read: () => {
93
80
  throw new Error("ENOENT: no manifest");
94
81
  },
95
- warn: () => {},
82
+ warn: (m) => warnings.push(m),
96
83
  typesDir: "/nonexistent/specialties",
97
84
  defaultTypeName: "absent",
98
85
  });
99
86
 
100
87
  for (const specialty of ["absent", "some-other-type"]) {
101
- let message = "";
102
- try {
103
- registry.getManifest(specialty);
104
- } catch (err) {
105
- message = (err as Error).message;
106
- }
107
- expect(message).toContain('default specialty "absent"');
108
- expect(message).toContain("/nonexistent/specialties");
109
- expect(message).toContain("SHIPWRIGHT_SPECIALTIES_DIR");
110
- expect(message).toContain("SHIPWRIGHT_DEFAULT_AGENT_TYPE");
111
- expect(message).not.toContain("startup-time bug");
88
+ warnings.length = 0;
89
+ const manifest = registry.getManifest(specialty);
90
+ expect(manifest.crons).toEqual([]);
91
+ expect(manifest.plugins).toEqual([]);
92
+ const all = warnings.join("\n");
93
+ expect(all).toContain('default specialty "absent"');
94
+ expect(all).toContain("/nonexistent/specialties");
95
+ expect(all).toContain("SHIPWRIGHT_SPECIALTIES_DIR");
96
+ expect(all).toContain("SHIPWRIGHT_DEFAULT_AGENT_TYPE");
112
97
  }
113
98
  });
114
99
 
100
+ it("uses the default manifest silently when the default type itself is requested and loads", () => {
101
+ const warnings: string[] = [];
102
+ const registry = new SpecialtyRegistry({
103
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
104
+ read: (t) => manifestYaml(t),
105
+ warn: (m) => warnings.push(m),
106
+ });
107
+ expect(registry.getManifest(DEFAULT_SPECIALTY_NAME).metadata.name).toBe(
108
+ "specialist",
109
+ );
110
+ expect(warnings).toEqual([]);
111
+ });
112
+
115
113
  it("caches a resolved manifest — the reader is called once per type", () => {
116
114
  let reads = 0;
117
115
  const registry = new SpecialtyRegistry({
@@ -122,15 +120,15 @@ describe("SpecialtyRegistry", () => {
122
120
  },
123
121
  });
124
122
 
125
- registry.getManifest("coding");
126
- registry.getManifest("coding");
123
+ registry.getManifest("specialist");
124
+ registry.getManifest("specialist");
127
125
 
128
126
  expect(reads).toBe(1);
129
127
  });
130
128
 
131
129
  // ─── tryGetManifest — non-fallback lookup ────────────────────────────────
132
130
  //
133
- // Unlike getManifest(), tryGetManifest() must NOT fall back to "coding" for
131
+ // Unlike getManifest(), tryGetManifest() must NOT fall back to the default type for
134
132
  // an unknown specialty — it returns undefined instead. This is what lets
135
133
  // POST /agents distinguish "resolved to the exact requested type" from
136
134
  // "silently degraded", which the cron-reconcile boot path (getManifest())
@@ -141,8 +139,8 @@ describe("SpecialtyRegistry", () => {
141
139
  defaultTypeName: DEFAULT_SPECIALTY_NAME,
142
140
  read: (t) => manifestYaml(t),
143
141
  });
144
- const manifest = registry.tryGetManifest("coding");
145
- expect(manifest?.metadata.name).toBe("coding");
142
+ const manifest = registry.tryGetManifest("specialist");
143
+ expect(manifest?.metadata.name).toBe("specialist");
146
144
  });
147
145
 
148
146
  it("tryGetManifest returns undefined for an unknown specialty — no fallback, no warning", () => {
@@ -150,7 +148,7 @@ describe("SpecialtyRegistry", () => {
150
148
  const registry = new SpecialtyRegistry({
151
149
  defaultTypeName: DEFAULT_SPECIALTY_NAME,
152
150
  read: (t) => {
153
- if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("coding");
151
+ if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("specialist");
154
152
  throw new Error(`ENOENT: no manifest for ${t}`);
155
153
  },
156
154
  warn: (m) => warnings.push(m),
@@ -172,8 +170,8 @@ describe("SpecialtyRegistry", () => {
172
170
  },
173
171
  });
174
172
 
175
- registry.getManifest("coding");
176
- registry.tryGetManifest("coding");
173
+ registry.getManifest("specialist");
174
+ registry.tryGetManifest("specialist");
177
175
 
178
176
  expect(reads).toBe(1);
179
177
  });
@@ -273,9 +271,9 @@ voice: true
273
271
  });
274
272
 
275
273
  describe("specialties option resolution", () => {
276
- it("defaults to coding and the repo-root specialties/ with no env set", () => {
277
- expect(resolveDefaultSpecialtyName({})).toBe("coding");
278
- expect(resolveSpecialtiesDir({})).toMatch(/specialties$/);
274
+ it("defaults to specialist and the shipped agent-types/ with no env set", () => {
275
+ expect(resolveDefaultSpecialtyName({})).toBe("specialist");
276
+ expect(resolveSpecialtiesDir({})).toMatch(/agent-types$/);
279
277
  });
280
278
 
281
279
  it("honours SHIPWRIGHT_DEFAULT_AGENT_TYPE and SHIPWRIGHT_SPECIALTIES_DIR", () => {