@app-vitals/agent-admin 0.16.0 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.platform.json +4 -4
- package/package.json +2 -2
- package/prisma/client/internal/class.ts +1 -1
- package/prisma/migrations/20261030100000_set_agent_specialty_default_specialist/migration.sql +4 -0
- package/prisma/schema/schema.prisma +1 -1
- package/src/account-agent-create.ts +1 -1
- package/src/account-agent-create.unit.test.ts +2 -2
- package/src/admin-boot.ts +7 -1
- package/src/admin-extension.ts +45 -0
- package/src/admin-extension.unit.test.ts +61 -0
- package/src/admin-ui-pages.ts +2 -2
- package/src/admin-ui-pages.unit.test.ts +3 -1
- package/src/admin-ui.smoke.test.ts +33 -1
- package/src/admin-ui.ts +1 -1
- package/src/agent-cron-jobs.ts +13 -25
- package/src/agent-cron-jobs.unit.test.ts +17 -24
- package/src/agent-specialty-backfill.integration.test.ts +30 -0
- package/src/agents-api.integration.test.ts +9 -9
- package/src/agents.integration.test.ts +2 -2
- package/src/agents.ts +11 -3
- package/src/openapi-schemas.ts +4 -4
- package/src/specialty-manifest-loader.fixture.integration.test.ts +7 -3
- package/src/specialty-manifest-loader.ts +123 -93
- package/src/specialty-manifest-loader.unit.test.ts +113 -50
|
@@ -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
|
|
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("
|
|
53
|
-
expect(manifest.metadata.name).toBe("
|
|
54
|
-
expect(manifest.crons[0]?.name).toBe("
|
|
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
|
|
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("
|
|
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("
|
|
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("
|
|
73
|
+
expect(warnings[0]).toContain("specialist");
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
it("throws
|
|
77
|
-
const
|
|
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();
|
|
88
|
-
});
|
|
89
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
expect(
|
|
108
|
-
expect(
|
|
109
|
-
expect(
|
|
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("
|
|
126
|
-
registry.getManifest("
|
|
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
|
|
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("
|
|
145
|
-
expect(manifest?.metadata.name).toBe("
|
|
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("
|
|
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),
|
|
@@ -162,6 +160,38 @@ describe("SpecialtyRegistry", () => {
|
|
|
162
160
|
expect(warnings).toHaveLength(0);
|
|
163
161
|
});
|
|
164
162
|
|
|
163
|
+
it("tryGetManifest resolves the built-in default type when no specialty is discoverable", () => {
|
|
164
|
+
const registry = new SpecialtyRegistry({
|
|
165
|
+
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
166
|
+
list: () => {
|
|
167
|
+
throw new Error("ENOENT: no such file or directory");
|
|
168
|
+
},
|
|
169
|
+
read: (t) => {
|
|
170
|
+
throw new Error(`ENOENT: no manifest for ${t}`);
|
|
171
|
+
},
|
|
172
|
+
warn: () => {},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(registry.tryGetManifest(DEFAULT_SPECIALTY_NAME)?.metadata.name).toBe(
|
|
176
|
+
DEFAULT_SPECIALTY_NAME,
|
|
177
|
+
);
|
|
178
|
+
expect(registry.tryGetManifest("nonexistent-type")).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("tryGetManifest does not synthesize the default type when other specialties are discoverable", () => {
|
|
182
|
+
const registry = new SpecialtyRegistry({
|
|
183
|
+
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
184
|
+
list: () => ["coding"],
|
|
185
|
+
read: (t) => {
|
|
186
|
+
if (t === "coding") return manifestYaml("coding");
|
|
187
|
+
throw new Error(`ENOENT: no manifest for ${t}`);
|
|
188
|
+
},
|
|
189
|
+
warn: () => {},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(registry.tryGetManifest(DEFAULT_SPECIALTY_NAME)).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
165
195
|
it("tryGetManifest shares the cache with getManifest — reader called once per type", () => {
|
|
166
196
|
let reads = 0;
|
|
167
197
|
const registry = new SpecialtyRegistry({
|
|
@@ -172,8 +202,8 @@ describe("SpecialtyRegistry", () => {
|
|
|
172
202
|
},
|
|
173
203
|
});
|
|
174
204
|
|
|
175
|
-
registry.getManifest("
|
|
176
|
-
registry.tryGetManifest("
|
|
205
|
+
registry.getManifest("specialist");
|
|
206
|
+
registry.tryGetManifest("specialist");
|
|
177
207
|
|
|
178
208
|
expect(reads).toBe(1);
|
|
179
209
|
});
|
|
@@ -261,21 +291,54 @@ voice: true
|
|
|
261
291
|
expect(types).toEqual([{ name: "coding", displayName: "coding" }]);
|
|
262
292
|
});
|
|
263
293
|
|
|
264
|
-
it("listTypes
|
|
294
|
+
it("listTypes falls back to the built-in default option when no type directories are discovered", () => {
|
|
265
295
|
const registry = new SpecialtyRegistry({
|
|
266
296
|
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
267
297
|
list: () => [],
|
|
268
298
|
read: (t) => manifestYaml(t),
|
|
269
299
|
});
|
|
270
300
|
|
|
271
|
-
expect(registry.listTypes()).toEqual([
|
|
301
|
+
expect(registry.listTypes()).toEqual([
|
|
302
|
+
{ name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
|
|
303
|
+
]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("listTypes warns and returns the built-in default when the list fn throws (missing dir)", () => {
|
|
307
|
+
const warnings: string[] = [];
|
|
308
|
+
const registry = new SpecialtyRegistry({
|
|
309
|
+
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
310
|
+
warn: (m) => warnings.push(m),
|
|
311
|
+
list: () => {
|
|
312
|
+
throw new Error("ENOENT: no such file or directory");
|
|
313
|
+
},
|
|
314
|
+
read: (t) => manifestYaml(t),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(registry.listTypes()).toEqual([
|
|
318
|
+
{ name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
|
|
319
|
+
]);
|
|
320
|
+
expect(warnings).toHaveLength(1);
|
|
321
|
+
expect(warnings[0]).toContain("ENOENT");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("listTypes does not throw on a nonexistent typesDir with the real lister", () => {
|
|
325
|
+
const warnings: string[] = [];
|
|
326
|
+
const registry = new SpecialtyRegistry({
|
|
327
|
+
typesDir: "/nonexistent/agent-types",
|
|
328
|
+
warn: (m) => warnings.push(m),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(registry.listTypes()).toEqual([
|
|
332
|
+
{ name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
|
|
333
|
+
]);
|
|
334
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
272
335
|
});
|
|
273
336
|
});
|
|
274
337
|
|
|
275
338
|
describe("specialties option resolution", () => {
|
|
276
|
-
it("defaults to
|
|
277
|
-
expect(resolveDefaultSpecialtyName({})).toBe("
|
|
278
|
-
expect(resolveSpecialtiesDir({})).toMatch(/
|
|
339
|
+
it("defaults to specialist and the shipped agent-types/ with no env set", () => {
|
|
340
|
+
expect(resolveDefaultSpecialtyName({})).toBe("specialist");
|
|
341
|
+
expect(resolveSpecialtiesDir({})).toMatch(/agent-types$/);
|
|
279
342
|
});
|
|
280
343
|
|
|
281
344
|
it("honours SHIPWRIGHT_DEFAULT_AGENT_TYPE and SHIPWRIGHT_SPECIALTIES_DIR", () => {
|