@app-vitals/agent-admin 0.16.1 → 1.0.0

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.
Files changed (47) hide show
  1. package/e2e/agents-page.e2e.ts +2 -2
  2. package/openapi.json +3 -3
  3. package/openapi.platform.json +7 -7
  4. package/package.json +3 -5
  5. package/prisma/client/internal/class.ts +1 -1
  6. package/prisma/migrations/20261030100000_set_agent_specialty_default_specialist/migration.sql +4 -0
  7. package/prisma/schema/schema.prisma +1 -1
  8. package/src/account-agent-create.ts +1 -1
  9. package/src/admin-boot.ts +12 -6
  10. package/src/admin-extension.ts +51 -5
  11. package/src/admin-extension.unit.test.ts +61 -0
  12. package/src/admin-local-agent.smoke.test.ts +8 -5
  13. package/src/admin-ui-pages.ts +4 -4
  14. package/src/admin-ui.smoke.test.ts +54 -34
  15. package/src/admin-ui.ts +12 -12
  16. package/src/agent-chat-tokens.smoke.test.ts +2 -2
  17. package/src/agent-cron-jobs.integration.test.ts +23 -21
  18. package/src/agent-cron-jobs.ts +30 -40
  19. package/src/agent-cron-jobs.unit.test.ts +51 -39
  20. package/src/agent-cron-run-stats.smoke.test.ts +2 -2
  21. package/src/agent-manifest.ts +2 -6
  22. package/src/agent-patch-author-allowlist.integration.test.ts +16 -27
  23. package/src/agent-specialty-backfill.integration.test.ts +30 -0
  24. package/src/agents-api.integration.test.ts +29 -40
  25. package/src/agents-api.sentry.smoke.test.ts +2 -2
  26. package/src/agents-api.smoke.test.ts +17 -30
  27. package/src/agents-api.ts +13 -13
  28. package/src/agents.integration.test.ts +23 -33
  29. package/src/agents.ts +35 -34
  30. package/src/agents.unit.test.ts +35 -55
  31. package/src/api.smoke.test.ts +2 -2
  32. package/src/chat-service-provisioning-client.integration.test.ts +1 -1
  33. package/src/openapi-schemas.ts +4 -4
  34. package/src/server.smoke.test.ts +2 -2
  35. package/src/specialty-defaults.ts +4 -4
  36. package/src/specialty-types.ts +92 -0
  37. package/src/specialty-types.unit.test.ts +67 -0
  38. package/e2e/fixtures/specialties/coding/manifest.yaml +0 -33
  39. package/src/coding-task-store-provisioning-client.integration.test.ts +0 -372
  40. package/src/coding-task-store-provisioning-client.ts +0 -156
  41. package/src/fixtures/task-store-provisioning-cassette.json +0 -85
  42. package/src/specialty-manifest-loader.fixture.integration.test.ts +0 -67
  43. package/src/specialty-manifest-loader.ts +0 -245
  44. package/src/specialty-manifest-loader.unit.test.ts +0 -299
  45. package/src/specialty-registry.schema.unit.test.ts +0 -35
  46. package/src/specialty-registry.ts +0 -236
  47. package/src/specialty-registry.unit.test.ts +0 -272
package/src/agents-api.ts CHANGED
@@ -49,10 +49,6 @@ import {
49
49
  registerAgentAskRoutes,
50
50
  } from "./agent-ask.ts";
51
51
  import type { AgentChatTokenService } from "./agent-chat-tokens.ts";
52
- import {
53
- type CronRunNowDeps,
54
- registerCronRunNowRoutes,
55
- } from "./cron-run-now.ts";
56
52
  import type {
57
53
  AgentCronJobService,
58
54
  AgentCronJobWithRunSummary,
@@ -93,6 +89,10 @@ import {
93
89
  memberOnlyCallerScopeResolver,
94
90
  } from "./caller-scope.ts";
95
91
  import type { ChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
92
+ import {
93
+ type CronRunNowDeps,
94
+ registerCronRunNowRoutes,
95
+ } from "./cron-run-now.ts";
96
96
  import {
97
97
  type CronRunTranscriptDeps,
98
98
  registerCronRunTranscriptRoutes,
@@ -157,7 +157,7 @@ import {
157
157
  } from "./openapi-schemas.ts";
158
158
  import type { SelfServeConfig } from "./self-serve-config.ts";
159
159
  import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
160
- import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
160
+ import type { SpecialtyTypeResolver } from "./specialty-types.ts";
161
161
  import { managedReconcileTargets } from "./startup-policy-reconcile.ts";
162
162
 
163
163
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -218,15 +218,15 @@ export interface AdminDeps {
218
218
  /** Account-aware caller-scope resolver (SSP-2.1); defaults to AgentMember-only. */
219
219
  callerScopeResolver?: CallerScopeResolver;
220
220
  /**
221
- * Resolves a specialty name to its parsed Specialty manifest. Consumed
221
+ * Resolves a specialty name to its type record. Consumed
222
222
  * by POST /agents (createAgentRoute, APA-2.1) to seed AgentTool/AgentPlugin
223
- * rows from the resolved manifest during agent creation — the same
223
+ * rows from the resolved type during agent creation — the same
224
224
  * createAgent() call admin-ui.ts's POST /admin/agents form handler makes
225
225
  * with its own independently-constructed specialtyRegistry instance.
226
226
  * Required (not optional) because the route needs it unconditionally.
227
227
  */
228
- specialtyRegistry: SpecialtyManifestResolver;
229
- /** Extension-supplied specialty defaults, consulted before the manifest. */
228
+ specialtyRegistry: SpecialtyTypeResolver;
229
+ /** Extension-supplied specialty defaults, consulted before the built-in type. */
230
230
  specialtyDefaults?: SpecialtyDefaultsResolver;
231
231
  agentChatTokenService: Pick<
232
232
  AgentChatTokenService,
@@ -421,7 +421,7 @@ const GetAgentResultSchema = z
421
421
  createdAt: z.string().datetime(),
422
422
  updatedAt: z.string().datetime(),
423
423
  /**
424
- * Required env keys declared by the agent's type manifest that have no
424
+ * Required env keys declared by the agent's type that have no
425
425
  * corresponding AgentEnv row yet — key names only, never values
426
426
  * (secrets_in_logs). Always present; informational only (ATS-4.2).
427
427
  */
@@ -585,7 +585,7 @@ const createAgentRoute = createRoute({
585
585
  path: "/agents",
586
586
  summary: "Create an agent",
587
587
  description:
588
- 'Admin-only. Validates name/specialty, creates the Agent row, seeds AgentTool/AgentPlugin rows from the resolved type manifest, attaches repos/allowlists/members, patches Claude credentials, and provisions Kubernetes when `runtime` is "in-cluster" — the same createAgent() implementation (APA-1.1) the web UI form at /admin/agents/new uses. Transactional: on any failure zero rows persist, and this route never returns a partial-success 200. Returns the created agent in the same shape as GET /agents/:id.',
588
+ 'Admin-only. Validates name/specialty, creates the Agent row, seeds AgentTool/AgentPlugin rows from the resolved type, attaches repos/allowlists/members, patches Claude credentials, and provisions Kubernetes when `runtime` is "in-cluster" — the same createAgent() implementation (APA-1.1) the web UI form at /admin/agents/new uses. Transactional: on any failure zero rows persist, and this route never returns a partial-success 200. Returns the created agent in the same shape as GET /agents/:id.',
589
589
  request: {
590
590
  body: {
591
591
  content: { "application/json": { schema: CreateAgentBodySchema } },
@@ -606,7 +606,7 @@ const getAgentRoute = createRoute({
606
606
  path: "/agents/{id}",
607
607
  summary: "Get an agent",
608
608
  description:
609
- "Admin-only. Returns the full agent record including `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `specialty`, `accountId` (null for unassigned agents), and `missingRequiredEnv` — required env keys declared by the agent's type manifest with no corresponding AgentEnv row yet (informational only).",
609
+ "Admin-only. Returns the full agent record including `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `specialty`, `accountId` (null for unassigned agents), and `missingRequiredEnv` — required env keys declared by the agent's type with no corresponding AgentEnv row yet (informational only).",
610
610
  request: { params: AgentIdParamSchema },
611
611
  responses: {
612
612
  200: {
@@ -809,7 +809,7 @@ const reconcileCronsRoute = createRoute({
809
809
  path: "/agents/{id}/crons/reconcile",
810
810
  summary: "Reconcile system crons",
811
811
  description:
812
- "Reconciles the agent's system crons against the cron list declared by its Specialty manifest. Runs in three passes within a single transaction: create-or-update matched-by-name system crons (preserving IDs so run history survives), link/unlink parent-child cron relationships, and delete orphaned system crons no longer in the manifest. Called automatically on agent startup; returns created/updated/deleted counts.",
812
+ "Reconciles the agent's system crons against the cron list supplied by the AdminExtension for its specialty. Runs in three passes within a single transaction: create-or-update matched-by-name system crons (preserving IDs so run history survives), link/unlink parent-child cron relationships, and delete orphaned system crons no longer in the defaults. Called automatically on agent startup; returns created/updated/deleted counts.",
813
813
  request: { params: AgentIdParamSchema },
814
814
  responses: {
815
815
  200: {
@@ -26,11 +26,13 @@ import { AgentMemberService } from "./agent-members.ts";
26
26
  import { AgentPluginService } from "./agent-plugins.ts";
27
27
  import type { AgentProvisioner, ProvisionResult } from "./agent-provisioner.ts";
28
28
  import { AgentToolService } from "./agent-tools.ts";
29
- import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
30
- import type { SpecialtyManifest } from "./specialty-registry.ts";
31
29
  import type { CreateAgentDeps } from "./agents.ts";
32
30
  import { AgentService, createAgent } from "./agents.ts";
33
31
  import { createAdminPrismaClient } from "./prisma-client.ts";
32
+ import type {
33
+ SpecialtyType,
34
+ SpecialtyTypeResolver,
35
+ } from "./specialty-types.ts";
34
36
  import { makeTokenCrypto } from "./token-crypto.ts";
35
37
 
36
38
  const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
@@ -42,35 +44,22 @@ function makePrisma(): PrismaClient {
42
44
  }
43
45
 
44
46
  /** A minimal valid "coding" manifest, matching agents-api.integration.test.ts's fixture. */
45
- const CODING_MANIFEST: SpecialtyManifest = {
46
- apiVersion: "shipwright.dev/v1alpha1",
47
- kind: "Specialty",
48
- metadata: {
49
- name: "coding",
50
- displayName: "Coding Agent",
51
- description: "test manifest",
52
- version: "1.0.0",
53
- skills: [],
54
- },
55
- identity: { templatesDir: "workspace/" },
56
- crons: [],
57
- plugins: ["shipwright"],
47
+ const CODING_TYPE: SpecialtyType = {
48
+ name: "coding",
49
+ displayName: "Coding Agent",
50
+ templatesDir: "workspace/",
58
51
  tools: ["Read", "Write", "Edit", "Bash"],
59
- env: { required: [], optional: [] },
60
- members: [],
61
- repos: [],
62
- chat: true,
63
- voice: true,
52
+ requiredEnv: [],
64
53
  };
65
54
 
66
- function fakeSpecialtyRegistry(
67
- byType: Record<string, SpecialtyManifest> = { coding: CODING_MANIFEST },
68
- ): SpecialtyManifestResolver {
55
+ function fakeSpecialtyTypes(
56
+ byType: Record<string, SpecialtyType> = { coding: CODING_TYPE },
57
+ ): SpecialtyTypeResolver {
69
58
  return {
70
- getManifest(specialty: string): SpecialtyManifest {
71
- return byType[specialty] ?? (byType.coding as SpecialtyManifest);
59
+ getType(specialty: string): SpecialtyType {
60
+ return byType[specialty] ?? (byType.coding as SpecialtyType);
72
61
  },
73
- tryGetManifest(specialty: string): SpecialtyManifest | undefined {
62
+ tryGetType(specialty: string): SpecialtyType | undefined {
74
63
  return byType[specialty];
75
64
  },
76
65
  listTypes() {
@@ -130,7 +119,7 @@ describeOrSkip("createAgent() — integration (real Postgres)", () => {
130
119
 
131
120
  beforeEach(async () => {
132
121
  prisma = makePrisma();
133
- agentService = new AgentService(prisma, fakeSpecialtyRegistry());
122
+ agentService = new AgentService(prisma, fakeSpecialtyTypes());
134
123
  agentToolService = new AgentToolService(prisma);
135
124
  agentPluginService = new AgentPluginService(prisma);
136
125
  agentMemberService = new AgentMemberService(prisma);
@@ -141,7 +130,7 @@ describeOrSkip("createAgent() — integration (real Postgres)", () => {
141
130
  agentCronJobService = new AgentCronJobService(
142
131
  prisma,
143
132
  undefined,
144
- fakeSpecialtyRegistry(),
133
+ fakeSpecialtyTypes(),
145
134
  );
146
135
 
147
136
  // Truncate between tests — this suite shares a DB with every other
@@ -171,7 +160,8 @@ describeOrSkip("createAgent() — integration (real Postgres)", () => {
171
160
  agentEnvService,
172
161
  agentCronJobService,
173
162
  provisioner: makeProvisioner(),
174
- specialtyRegistry: fakeSpecialtyRegistry(),
163
+ specialtyRegistry: fakeSpecialtyTypes(),
164
+ specialtyDefaults: () => ({ plugins: ["shipwright"] }),
175
165
  ...overrides,
176
166
  };
177
167
  }
@@ -205,7 +195,7 @@ describeOrSkip("createAgent() — integration (real Postgres)", () => {
205
195
  where: { agentId: result.agent.id },
206
196
  });
207
197
  expect(tools.map((t) => t.pattern).sort()).toEqual(
208
- [...CODING_MANIFEST.tools].sort(),
198
+ [...CODING_TYPE.tools].sort(),
209
199
  );
210
200
 
211
201
  const plugins = await prisma.agentPlugin.findMany({
@@ -518,7 +508,7 @@ describeOrSkip("Agent.specialty — final shape (AGR-6.3)", () => {
518
508
 
519
509
  beforeEach(() => {
520
510
  prisma = makePrisma();
521
- agentService = new AgentService(prisma, fakeSpecialtyRegistry());
511
+ agentService = new AgentService(prisma, fakeSpecialtyTypes());
522
512
  });
523
513
 
524
514
  afterEach(async () => {
@@ -574,7 +564,7 @@ describeOrSkip("Agent.specialty — final shape (AGR-6.3)", () => {
574
564
  ).toBe("research");
575
565
  });
576
566
 
577
- it("specialty is required: NULL is rejected and omitting it applies the 'coding' default", async () => {
567
+ it("specialty is required: NULL is rejected and omitting it applies the 'specialist' default", async () => {
578
568
  await expect(
579
569
  (async () =>
580
570
  prisma.agent.create({
@@ -584,6 +574,6 @@ describeOrSkip("Agent.specialty — final shape (AGR-6.3)", () => {
584
574
 
585
575
  const created = await agentService.create({ name: "No Specialty" });
586
576
  createdIds.push(created.id);
587
- expect(created.specialty).toBe("coding");
577
+ expect(created.specialty).toBe("specialist");
588
578
  });
589
579
  });
package/src/agents.ts CHANGED
@@ -19,13 +19,13 @@ import { AgentMemberService } from "./agent-members.ts";
19
19
  import type { AgentPluginService } from "./agent-plugins.ts";
20
20
  import type { AgentProvisioner } from "./agent-provisioner.ts";
21
21
  import type { AgentToolService } from "./agent-tools.ts";
22
- import {
23
- type SpecialtyManifestResolver,
24
- SpecialtyRegistry,
25
- } from "./specialty-manifest-loader.ts";
26
- import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
27
22
  import { ANTHROPIC_CREDENTIALS_BLOCKED_CODE } from "./anthropic-credential-guard.ts";
28
23
  import type { PrismaTransactionClient } from "./prisma-tx.ts";
24
+ import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
25
+ import {
26
+ type SpecialtyTypeResolver,
27
+ SpecialtyTypes,
28
+ } from "./specialty-types.ts";
29
29
 
30
30
  // ─── Types ────────────────────────────────────────────────────────────────────
31
31
 
@@ -33,9 +33,9 @@ interface CreateAgentInput {
33
33
  name: string;
34
34
  slackId?: string | null;
35
35
  selfHosted?: boolean;
36
- /** Specialty name. Omitted means Prisma's column default ("coding") applies. */
36
+ /** Specialty name. Omitted means Prisma's column default ("specialist") applies. */
37
37
  specialty?: string;
38
- /** Initial repos[] — the manifest repos merged with any request-supplied repos. */
38
+ /** Initial repos[] from the request. */
39
39
  repos?: string[];
40
40
  /** Initial reviewAuthorAllowlist[]. */
41
41
  reviewAuthorAllowlist?: string[];
@@ -108,7 +108,7 @@ export interface AgentDetail extends Partial<AgentAnthropicIdentity> {
108
108
  createdAt: Date;
109
109
  updatedAt: Date;
110
110
  /**
111
- * Required env keys declared by the agent's type manifest that have no
111
+ * Required env keys declared by the agent's type that have no
112
112
  * corresponding AgentEnv row yet — key names only, never values
113
113
  * (secrets_in_logs). Always present (empty array when the type's required
114
114
  * contract is empty or every required key is set). Informational only —
@@ -302,7 +302,7 @@ const DETAIL_SELECT = {
302
302
  export class AgentService {
303
303
  constructor(
304
304
  private prisma: PrismaClient,
305
- private specialtyRegistry: SpecialtyManifestResolver = new SpecialtyRegistry(),
305
+ private specialtyRegistry: SpecialtyTypeResolver = new SpecialtyTypes(),
306
306
  private agentMemberService: Pick<
307
307
  AgentMemberService,
308
308
  "listByAgentId"
@@ -407,11 +407,11 @@ export class AgentService {
407
407
  }
408
408
 
409
409
  /**
410
- * The env-contract gap: the type manifest's required env keys minus the
410
+ * The env-contract gap: the type's required env keys minus the
411
411
  * keys already present in this agent's AgentEnv rows (key names only,
412
412
  * never values — the AgentEnv query below selects only `key`, never
413
413
  * `value`, so decrypted secrets can never reach this computation).
414
- * Skips the AgentEnv query entirely when the manifest declares no required
414
+ * Skips the AgentEnv query entirely when the type declares no required
415
415
  * keys. Purely informational: never blocks create or gates provisioning
416
416
  * (ATS-4.2).
417
417
  */
@@ -420,9 +420,7 @@ export class AgentService {
420
420
  specialty: string,
421
421
  client: PrismaTransactionClient = this.prisma,
422
422
  ): Promise<string[]> {
423
- const requiredKeys = this.specialtyRegistry
424
- .getManifest(specialty)
425
- .env.required.map((entry) => entry.key);
423
+ const requiredKeys = this.specialtyRegistry.getType(specialty).requiredEnv;
426
424
 
427
425
  if (requiredKeys.length === 0) return [];
428
426
 
@@ -784,8 +782,11 @@ export interface CreateAgentDeps {
784
782
  agentEnvService: Pick<AgentEnvService, "patch">;
785
783
  agentCronJobService: Pick<AgentCronJobService, "reconcileSystemCrons">;
786
784
  provisioner: Pick<AgentProvisioner, "canProvision" | "provision">;
787
- specialtyRegistry: Pick<SpecialtyManifestResolver, "tryGetManifest">;
788
- /** Extension-supplied tools/plugins, consulted before the manifest's. */
785
+ specialtyRegistry: Pick<
786
+ SpecialtyTypeResolver,
787
+ "tryGetType" | "hasExtensionType"
788
+ >;
789
+ /** Extension-supplied tools/plugins, consulted before the type's tools floor. */
789
790
  specialtyDefaults?: SpecialtyDefaultsResolver;
790
791
  /** WIF-2.1: Anthropic federation is enabled; defaults to false. */
791
792
  anthropicFederationEnabled?: boolean;
@@ -836,7 +837,7 @@ type CreateAgentTxOutcome =
836
837
 
837
838
  /**
838
839
  * Atomically create an agent: validate name/specialty, create the Agent row,
839
- * seed AgentTool/AgentPlugin from the resolved type manifest, attach
840
+ * seed AgentTool/AgentPlugin from the resolved type, attach
840
841
  * repos/allowlists/members, patch Claude credentials, provision Kubernetes
841
842
  * resources when requested, and best-effort reconcile system crons.
842
843
  *
@@ -875,16 +876,21 @@ export async function createAgent(
875
876
  }
876
877
 
877
878
  // Resolve the requested type BEFORE creating any row — an unknown/missing
878
- // type must fail with zero rows created. The resolved manifest is
879
- // captured (not discarded) so its tools/plugins can be seeded below.
879
+ // type must fail with zero rows created. The resolved type is
880
+ // captured (not discarded) so its tools can be seeded below.
880
881
  const specialty = input.specialty;
881
- const manifest = specialty
882
- ? deps.specialtyRegistry.tryGetManifest(specialty)
882
+ const type = specialty
883
+ ? deps.specialtyRegistry.tryGetType(specialty)
883
884
  : undefined;
884
885
  const extensionDefaults = specialty
885
886
  ? deps.specialtyDefaults?.(specialty)
886
887
  : undefined;
887
- if (!specialty || (!manifest && !extensionDefaults)) {
888
+ if (
889
+ !specialty ||
890
+ (!type &&
891
+ !extensionDefaults &&
892
+ !deps.specialtyRegistry.hasExtensionType?.(specialty))
893
+ ) {
888
894
  return { ok: false, errorCode: "invalid_type" };
889
895
  }
890
896
 
@@ -939,21 +945,16 @@ export async function createAgent(
939
945
  tx,
940
946
  );
941
947
 
942
- // Seed AgentTool/AgentPlugin rows from the resolved manifest. Roll the
943
- // agent row back on any seeding failure so a retry with the same name
944
- // doesn't collide with a half-seeded agent. Members/repos are NOT
945
- // seeded from the manifest here — they have their own dedicated
946
- // handling below, and the manifest's members/repos arrays are empty
947
- // for every specialty that exists today.
948
+ // Seed AgentTool/AgentPlugin rows from the extension's defaults, else
949
+ // the type's tools floor. Roll the agent row back on any seeding
950
+ // failure so a retry with the same name doesn't collide with a
951
+ // half-seeded agent. Members/repos have their own dedicated handling
952
+ // below.
948
953
  try {
949
- for (const pattern of extensionDefaults?.tools ??
950
- manifest?.tools ??
951
- []) {
954
+ for (const pattern of extensionDefaults?.tools ?? type?.tools ?? []) {
952
955
  await deps.agentToolService.add(agent.id, pattern, tx);
953
956
  }
954
- for (const pluginName of extensionDefaults?.plugins ??
955
- manifest?.plugins ??
956
- []) {
957
+ for (const pluginName of extensionDefaults?.plugins ?? []) {
957
958
  await deps.agentPluginService.add(
958
959
  agent.id,
959
960
  pluginName,
@@ -7,8 +7,6 @@
7
7
 
8
8
  import { describe, expect, it } from "bun:test";
9
9
  import { DEFAULT_AGENT_ENV } from "@app-vitals/agent-lib/default-agent-env";
10
- import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
11
- import type { SpecialtyManifest } from "./specialty-registry.ts";
12
10
  import type {
13
11
  AgentDetail,
14
12
  CreateAgentDeps,
@@ -17,6 +15,11 @@ import type {
17
15
  } from "./agents.ts";
18
16
  import { AgentService, createAgent, resolveOwnerEmail } from "./agents.ts";
19
17
  import type { PrismaTransactionClient } from "./prisma-tx.ts";
18
+ import {
19
+ SPECIALIST_TYPE,
20
+ type SpecialtyType,
21
+ type SpecialtyTypeResolver,
22
+ } from "./specialty-types.ts";
20
23
 
21
24
  // ─── In-memory prisma.agent test double ────────────────────────────────────
22
25
 
@@ -255,24 +258,15 @@ type FakePrisma = ReturnType<typeof makeFakePrisma>;
255
258
  * fakeRegistry() pattern in agent-cron-jobs.unit.test.ts. */
256
259
  function fakeRegistry(
257
260
  requiredEnvByType: Record<string, string[]>,
258
- ): SpecialtyManifestResolver {
261
+ ): SpecialtyTypeResolver {
259
262
  return {
260
- getManifest(specialty: string): SpecialtyManifest {
263
+ getType(specialty: string): SpecialtyType {
261
264
  const required = requiredEnvByType[specialty] ?? [];
262
- return {
263
- env: {
264
- required: required.map((key) => ({
265
- key,
266
- description: `${key} description`,
267
- secret: true,
268
- })),
269
- optional: [],
270
- },
271
- } as unknown as SpecialtyManifest;
265
+ return { ...SPECIALIST_TYPE, requiredEnv: required };
272
266
  },
273
- tryGetManifest(specialty: string): SpecialtyManifest | undefined {
267
+ tryGetType(specialty: string): SpecialtyType | undefined {
274
268
  if (!(specialty in requiredEnvByType)) return undefined;
275
- return this.getManifest(specialty);
269
+ return this.getType(specialty);
276
270
  },
277
271
  listTypes() {
278
272
  return Object.keys(requiredEnvByType).map((name) => ({
@@ -1119,32 +1113,19 @@ describe("AgentService.updateFields", () => {
1119
1113
  // criterion this whole task is about) is covered separately by
1120
1114
  // agents.integration.test.ts against a real DB.
1121
1115
 
1122
- const CODING_MANIFEST: SpecialtyManifest = {
1123
- apiVersion: "shipwright.dev/v1alpha1",
1124
- kind: "Specialty",
1125
- metadata: {
1126
- name: "coding",
1127
- displayName: "Coding Agent",
1128
- description: "test manifest",
1129
- version: "1.0.0",
1130
- skills: [],
1131
- },
1132
- identity: { templatesDir: "workspace/" },
1133
- crons: [],
1134
- plugins: ["shipwright"],
1116
+ const CODING_TYPE: SpecialtyType = {
1117
+ name: "coding",
1118
+ displayName: "Coding Agent",
1119
+ templatesDir: "workspace/",
1135
1120
  tools: ["Read", "Write", "Bash"],
1136
- env: { required: [], optional: [] },
1137
- members: [],
1138
- repos: [],
1139
- chat: true,
1140
- voice: true,
1141
- } as unknown as SpecialtyManifest;
1142
-
1143
- function fakeCreateSpecialtyRegistry(
1144
- byType: Record<string, SpecialtyManifest> = { coding: CODING_MANIFEST },
1145
- ): Pick<SpecialtyManifestResolver, "tryGetManifest"> {
1121
+ requiredEnv: [],
1122
+ };
1123
+
1124
+ function fakeCreateSpecialtyTypes(
1125
+ byType: Record<string, SpecialtyType> = { coding: CODING_TYPE },
1126
+ ): Pick<SpecialtyTypeResolver, "tryGetType"> {
1146
1127
  return {
1147
- tryGetManifest: (specialty: string) => byType[specialty],
1128
+ tryGetType: (specialty: string) => byType[specialty],
1148
1129
  };
1149
1130
  }
1150
1131
 
@@ -1166,7 +1147,7 @@ interface CreateAgentHarnessCalls {
1166
1147
 
1167
1148
  function makeCreateAgentHarness(
1168
1149
  opts: {
1169
- manifests?: Record<string, SpecialtyManifest>;
1150
+ manifests?: Record<string, SpecialtyType>;
1170
1151
  canProvision?: boolean;
1171
1152
  provisionImpl?: (agentId: string) => Promise<unknown>;
1172
1153
  toolAddImpl?: (agentId: string, pattern: string) => Promise<unknown>;
@@ -1283,7 +1264,7 @@ function makeCreateAgentHarness(
1283
1264
  },
1284
1265
  };
1285
1266
 
1286
- const specialtyRegistry = fakeCreateSpecialtyRegistry(opts.manifests);
1267
+ const specialtyRegistry = fakeCreateSpecialtyTypes(opts.manifests);
1287
1268
 
1288
1269
  return {
1289
1270
  deps: {
@@ -1320,7 +1301,7 @@ function baseCreateAgentInput(
1320
1301
  }
1321
1302
 
1322
1303
  describe("createAgent() specialtyDefaults hook", () => {
1323
- it("seeds tools and plugins from the hook instead of the manifest", async () => {
1304
+ it("seeds tools and plugins from the hook instead of the type", async () => {
1324
1305
  const { deps, calls } = makeCreateAgentHarness();
1325
1306
  deps.specialtyDefaults = () => ({
1326
1307
  tools: ["Grep"],
@@ -1334,18 +1315,16 @@ describe("createAgent() specialtyDefaults hook", () => {
1334
1315
  expect(calls.pluginsAdded.map((p) => p.name)).toEqual(["ext-plugin"]);
1335
1316
  });
1336
1317
 
1337
- it("falls back to the manifest when the hook returns nothing", async () => {
1318
+ it("falls back to the type's tools floor when the hook returns nothing", async () => {
1338
1319
  const { deps, calls } = makeCreateAgentHarness();
1339
1320
  deps.specialtyDefaults = () => undefined;
1340
1321
 
1341
1322
  await createAgent(deps, baseCreateAgentInput());
1342
1323
 
1343
- expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(
1344
- CODING_MANIFEST.tools,
1345
- );
1346
- expect(calls.pluginsAdded.map((p) => p.name)).toEqual(
1347
- CODING_MANIFEST.plugins,
1348
- );
1324
+ expect(calls.toolsAdded.map((t) => t.pattern)).toEqual([
1325
+ ...CODING_TYPE.tools,
1326
+ ]);
1327
+ expect(calls.pluginsAdded).toEqual([]);
1349
1328
  });
1350
1329
 
1351
1330
  it("falls back per field when the hook omits tools or plugins", async () => {
@@ -1354,9 +1333,9 @@ describe("createAgent() specialtyDefaults hook", () => {
1354
1333
 
1355
1334
  await createAgent(deps, baseCreateAgentInput());
1356
1335
 
1357
- expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(
1358
- CODING_MANIFEST.tools,
1359
- );
1336
+ expect(calls.toolsAdded.map((t) => t.pattern)).toEqual([
1337
+ ...CODING_TYPE.tools,
1338
+ ]);
1360
1339
  expect(calls.pluginsAdded.map((p) => p.name)).toEqual(["ext-plugin"]);
1361
1340
  });
1362
1341
 
@@ -1533,7 +1512,7 @@ describe("createAgent()", () => {
1533
1512
  expect(calls.created).toEqual([]);
1534
1513
  });
1535
1514
 
1536
- it("happy path: creates the agent, seeds manifest tools/plugins, attaches repos/allowlists/members, and reconciles crons", async () => {
1515
+ it("happy path: creates the agent, seeds the type's tools, attaches repos/allowlists/members, and reconciles crons", async () => {
1537
1516
  const { deps, calls } = makeCreateAgentHarness({ canProvision: false });
1538
1517
 
1539
1518
  const result = await createAgent(
@@ -1557,7 +1536,7 @@ describe("createAgent()", () => {
1557
1536
  "Write",
1558
1537
  "Bash",
1559
1538
  ]);
1560
- expect(calls.pluginsAdded.map((p) => p.name)).toEqual(["shipwright"]);
1539
+ expect(calls.pluginsAdded).toEqual([]);
1561
1540
  expect(calls.updateFieldsCalls).toEqual([
1562
1541
  { id: result.agent.id, fields: { repos: ["org/repo1", "org/repo2"] } },
1563
1542
  {
@@ -1615,6 +1594,7 @@ describe("createAgent()", () => {
1615
1594
  throw new Error("boom: plugin seeding failed");
1616
1595
  },
1617
1596
  });
1597
+ deps.specialtyDefaults = () => ({ plugins: ["ext-plugin"] });
1618
1598
 
1619
1599
  const result = await createAgent(deps, baseCreateAgentInput());
1620
1600
 
@@ -824,10 +824,10 @@ function buildCombinedApp() {
824
824
  runTransaction: async (fn) => fn(undefined as never),
825
825
  },
826
826
  specialtyRegistry: {
827
- getManifest: () => {
827
+ getType: () => {
828
828
  throw new Error("not implemented");
829
829
  },
830
- tryGetManifest: () => undefined,
830
+ tryGetType: () => undefined,
831
831
  listTypes: () => [],
832
832
  },
833
833
  agentEnvService: {
@@ -6,7 +6,7 @@
6
6
  * Drives the client through an INJECTED fetchFn that replays canned Responses
7
7
  * from a cassette keyed by scenario — no live API server, no global.fetch
8
8
  * override, no mock.module(). Mirrors the pattern in
9
- * task-store-provisioning-client.integration.test.ts.
9
+ * the other provisioning-client integration tests.
10
10
  */
11
11
 
12
12
  import { describe, expect, it } from "bun:test";
@@ -34,7 +34,7 @@ export const AgentSummarySchema = z
34
34
  id: z.string().openapi({ example: "clx1234567890" }),
35
35
  name: z.string().openapi({ example: "Bodhi" }),
36
36
  selfHosted: z.boolean().openapi({ example: false }),
37
- specialty: z.string().openapi({ example: "coding" }),
37
+ specialty: z.string().openapi({ example: "specialist" }),
38
38
  })
39
39
  .openapi("AgentSummary");
40
40
 
@@ -147,7 +147,7 @@ export const PatchAgentBodySchema = z
147
147
  export const CreateAgentBodySchema = z
148
148
  .object({
149
149
  name: z.string().min(1).openapi({ example: "Bodhi" }),
150
- specialty: z.string().min(1).openapi({ example: "coding" }),
150
+ specialty: z.string().min(1).openapi({ example: "specialist" }),
151
151
  /**
152
152
  * "in-cluster" means K8s-provisioned; anything else (including absent)
153
153
  * means self-hosted.
@@ -1343,7 +1343,7 @@ export const AgentConfigResponseSchema = z
1343
1343
  * ARD-6.1: the agent's Specialty name, used by the runtime to pick its
1344
1344
  * workspace template directory.
1345
1345
  */
1346
- specialty: z.string().openapi({ example: "coding" }),
1346
+ specialty: z.string().openapi({ example: "specialist" }),
1347
1347
  /**
1348
1348
  * DEPRECATED back-compat alias of `specialty` (always equal to it). Kept
1349
1349
  * only for pre-AGR-6.3 runtime images, whose entrypoint reads
@@ -1353,7 +1353,7 @@ export const AgentConfigResponseSchema = z
1353
1353
  typeName: z
1354
1354
  .string()
1355
1355
  .optional()
1356
- .openapi({ example: "coding", deprecated: true }),
1356
+ .openapi({ example: "specialist", deprecated: true }),
1357
1357
  /**
1358
1358
  * PMC-1.1: phase -> subagentType (or null) map, covering all six pipeline
1359
1359
  * phases (see AGENT_PHASES) regardless of whether the agent has an
@@ -77,8 +77,8 @@ function buildComposedApp() {
77
77
  runTransaction: notImplemented,
78
78
  },
79
79
  specialtyRegistry: {
80
- getManifest: notImplemented,
81
- tryGetManifest: notImplemented,
80
+ getType: notImplemented,
81
+ tryGetType: notImplemented,
82
82
  listTypes: notImplemented,
83
83
  },
84
84
  agentEnvService: {
@@ -4,12 +4,12 @@
4
4
  * The shape of a specialty's default crons, tools and plugins as supplied by
5
5
  * an AdminExtension's specialtyDefaults() hook (admin-extension.ts). The cron
6
6
  * reconcile (agent-cron-jobs.ts) and create-time seeding (agents.ts) consult
7
- * the hook first and fall back to the specialty manifest when it returns
8
- * nothing. Kept in its own module so those services can name the types without
7
+ * the hook first and fall back to the built-in type (lib/specialist-type.ts)
8
+ * when it returns nothing. Kept in its own module so those services can name the types without
9
9
  * importing admin-extension.ts.
10
10
  */
11
11
 
12
- /** A default cron template; same fields as a manifest cron, defaults optional. */
12
+ /** A default cron template; defaults optional. */
13
13
  export interface SpecialtyDefaultCron {
14
14
  name: string;
15
15
  schedule: string;
@@ -22,7 +22,7 @@ export interface SpecialtyDefaultCron {
22
22
  parentCron?: string;
23
23
  }
24
24
 
25
- /** Any field left undefined falls back to the manifest for that field. */
25
+ /** Any field left undefined falls back to the built-in type for that field. */
26
26
  export interface SpecialtyDefaults {
27
27
  crons?: readonly SpecialtyDefaultCron[];
28
28
  tools?: readonly string[];