@app-vitals/agent-admin 0.16.2 → 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 (42) hide show
  1. package/e2e/agents-page.e2e.ts +2 -2
  2. package/openapi.json +3 -3
  3. package/openapi.platform.json +3 -3
  4. package/package.json +3 -5
  5. package/src/account-agent-create.ts +1 -1
  6. package/src/admin-boot.ts +6 -6
  7. package/src/admin-extension.ts +16 -15
  8. package/src/admin-extension.unit.test.ts +2 -2
  9. package/src/admin-local-agent.smoke.test.ts +8 -5
  10. package/src/admin-ui-pages.ts +4 -4
  11. package/src/admin-ui.smoke.test.ts +27 -39
  12. package/src/admin-ui.ts +12 -12
  13. package/src/agent-chat-tokens.smoke.test.ts +2 -2
  14. package/src/agent-cron-jobs.integration.test.ts +23 -21
  15. package/src/agent-cron-jobs.ts +30 -40
  16. package/src/agent-cron-jobs.unit.test.ts +51 -39
  17. package/src/agent-cron-run-stats.smoke.test.ts +2 -2
  18. package/src/agent-manifest.ts +2 -6
  19. package/src/agent-patch-author-allowlist.integration.test.ts +16 -27
  20. package/src/agents-api.integration.test.ts +20 -31
  21. package/src/agents-api.sentry.smoke.test.ts +2 -2
  22. package/src/agents-api.smoke.test.ts +17 -30
  23. package/src/agents-api.ts +13 -13
  24. package/src/agents.integration.test.ts +21 -31
  25. package/src/agents.ts +27 -34
  26. package/src/agents.unit.test.ts +35 -55
  27. package/src/api.smoke.test.ts +2 -2
  28. package/src/chat-service-provisioning-client.integration.test.ts +1 -1
  29. package/src/server.smoke.test.ts +2 -2
  30. package/src/specialty-defaults.ts +4 -4
  31. package/src/specialty-types.ts +92 -0
  32. package/src/specialty-types.unit.test.ts +67 -0
  33. package/e2e/fixtures/specialties/coding/manifest.yaml +0 -33
  34. package/src/coding-task-store-provisioning-client.integration.test.ts +0 -372
  35. package/src/coding-task-store-provisioning-client.ts +0 -156
  36. package/src/fixtures/task-store-provisioning-cassette.json +0 -85
  37. package/src/specialty-manifest-loader.fixture.integration.test.ts +0 -67
  38. package/src/specialty-manifest-loader.ts +0 -285
  39. package/src/specialty-manifest-loader.unit.test.ts +0 -364
  40. package/src/specialty-registry.schema.unit.test.ts +0 -35
  41. package/src/specialty-registry.ts +0 -236
  42. package/src/specialty-registry.unit.test.ts +0 -272
@@ -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[];
@@ -0,0 +1,92 @@
1
+ /**
2
+ * admin/src/specialty-types.ts
3
+ *
4
+ * Resolves an agent's stored `specialty` to its type record. The platform has
5
+ * one built-in type, `specialist` (lib/specialist-type.ts); every other valid
6
+ * type is registered by an AdminExtension via agentTypes()
7
+ * (admin-extension.ts), with its default crons/tools/plugins supplied by the
8
+ * extension's specialtyDefaults() hook. The configured default type
9
+ * (SHIPWRIGHT_DEFAULT_AGENT_TYPE) is also accepted so a platform-only
10
+ * deployment can rename its default.
11
+ *
12
+ * Every type shares the specialist floor (tools, required env): getType()
13
+ * never throws and resolves any stored value, including legacy ones, to it, so
14
+ * the boot-path cron reconcile and the missing-required-env report cannot fail
15
+ * over a stale specialty.
16
+ */
17
+
18
+ import {
19
+ DEFAULT_SPECIALTY_NAME,
20
+ resolveDefaultSpecialtyName,
21
+ resolveSpecialtiesDir,
22
+ } from "@app-vitals/agent-lib/default-specialty";
23
+ import {
24
+ SPECIALIST_TYPE,
25
+ type SpecialtyType,
26
+ } from "@app-vitals/agent-lib/specialist-type";
27
+
28
+ export type { SpecialtyType };
29
+ export {
30
+ DEFAULT_SPECIALTY_NAME,
31
+ resolveDefaultSpecialtyName,
32
+ resolveSpecialtiesDir,
33
+ SPECIALIST_TYPE,
34
+ };
35
+
36
+ /** A type offered by the new-agent picker. */
37
+ export interface SpecialtyOption {
38
+ name: string;
39
+ displayName: string;
40
+ }
41
+
42
+ /**
43
+ * Interface the create, reconcile and picker paths depend on — kept narrow so
44
+ * tests can supply a trivial double.
45
+ */
46
+ export interface SpecialtyTypeResolver {
47
+ /** Resolve `specialty` to its type record; unknown values get the specialist floor. Never throws. */
48
+ getType(specialty: string): SpecialtyType;
49
+ /**
50
+ * Resolve `specialty` WITHOUT falling back: undefined for a type that is
51
+ * neither built in nor the configured default. POST /admin/agents uses it to
52
+ * reject an unknown requested type with 400.
53
+ */
54
+ tryGetType(specialty: string): SpecialtyType | undefined;
55
+ /** The built-in types for the new-agent picker (extension types are merged in by withExtensionAgentTypes). */
56
+ listTypes(): SpecialtyOption[];
57
+ /** True when an AdminExtension declares `specialty` via agentTypes(). Optional: the built-in resolver has none. */
58
+ hasExtensionType?(specialty: string): boolean;
59
+ }
60
+
61
+ export class SpecialtyTypes implements SpecialtyTypeResolver {
62
+ private readonly defaultTypeName: string;
63
+
64
+ constructor(opts: { defaultTypeName?: string } = {}) {
65
+ this.defaultTypeName =
66
+ opts.defaultTypeName ?? resolveDefaultSpecialtyName();
67
+ }
68
+
69
+ getType(_specialty: string): SpecialtyType {
70
+ return SPECIALIST_TYPE;
71
+ }
72
+
73
+ tryGetType(specialty: string): SpecialtyType | undefined {
74
+ return specialty === SPECIALIST_TYPE.name ||
75
+ specialty === this.defaultTypeName
76
+ ? SPECIALIST_TYPE
77
+ : undefined;
78
+ }
79
+
80
+ listTypes(): SpecialtyOption[] {
81
+ const options: SpecialtyOption[] = [
82
+ { name: SPECIALIST_TYPE.name, displayName: SPECIALIST_TYPE.displayName },
83
+ ];
84
+ if (this.defaultTypeName !== SPECIALIST_TYPE.name) {
85
+ options.push({
86
+ name: this.defaultTypeName,
87
+ displayName: this.defaultTypeName,
88
+ });
89
+ }
90
+ return options;
91
+ }
92
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Specialist type contract + type validity (PCD-5.1).
3
+ *
4
+ * The expected values below are the retired
5
+ * agent-types/specialist/manifest.yaml's, so existing specialist agents keep
6
+ * the same tools floor, required-env reporting and template seeding.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { withExtensionAgentTypes } from "./admin-extension.ts";
11
+ import { SPECIALIST_TYPE, SpecialtyTypes } from "./specialty-types.ts";
12
+
13
+ describe("specialist type contract", () => {
14
+ const types = new SpecialtyTypes({ defaultTypeName: "specialist" });
15
+
16
+ it("resolves specialist to the old manifest's tools floor, required env and templatesDir", () => {
17
+ const t = types.getType("specialist");
18
+ expect([...t.tools]).toEqual(["Bash", "WebSearch", "WebFetch", "Agent"]);
19
+ expect([...t.requiredEnv]).toEqual(["CLAUDE_CODE_OAUTH_TOKEN"]);
20
+ expect(t.templatesDir).toBe("agent-types/specialist/templates/");
21
+ expect(t).toBe(SPECIALIST_TYPE);
22
+ });
23
+
24
+ it("resolves every stored specialty (including legacy values) to the specialist floor", () => {
25
+ for (const stored of ["specialist", "coding", "", "anything-old"]) {
26
+ expect(types.getType(stored)).toBe(SPECIALIST_TYPE);
27
+ }
28
+ });
29
+
30
+ it("rejects an unknown specialty on create-time validation", () => {
31
+ expect(types.tryGetType("specialist")).toBe(SPECIALIST_TYPE);
32
+ expect(types.tryGetType("nope")).toBeUndefined();
33
+ });
34
+
35
+ it("accepts the configured default type name", () => {
36
+ const renamed = new SpecialtyTypes({ defaultTypeName: "base" });
37
+ expect(renamed.tryGetType("base")).toBe(SPECIALIST_TYPE);
38
+ expect(renamed.listTypes().map((t) => t.name)).toEqual([
39
+ "specialist",
40
+ "base",
41
+ ]);
42
+ });
43
+ });
44
+
45
+ describe("extension-registered types", () => {
46
+ const wrapped = withExtensionAgentTypes(
47
+ new SpecialtyTypes({ defaultTypeName: "specialist" }),
48
+ [{ agentTypes: () => [{ name: "coding", displayName: "Coding Agent" }] }],
49
+ );
50
+
51
+ it("lists the extension type next to specialist", () => {
52
+ expect(wrapped.listTypes()).toEqual([
53
+ { name: "specialist", displayName: "Specialist Agent" },
54
+ { name: "coding", displayName: "Coding Agent" },
55
+ ]);
56
+ });
57
+
58
+ it("is accepted via hasExtensionType while still unknown to the built-in lookup", () => {
59
+ expect(wrapped.tryGetType("coding")).toBeUndefined();
60
+ expect(wrapped.hasExtensionType?.("coding")).toBe(true);
61
+ expect(wrapped.hasExtensionType?.("nope")).toBe(false);
62
+ });
63
+
64
+ it("falls back to the specialist floor for its tools and required env", () => {
65
+ expect(wrapped.getType("coding")).toBe(SPECIALIST_TYPE);
66
+ });
67
+ });
@@ -1,33 +0,0 @@
1
- # Minimal "coding" specialty for the admin Playwright e2e suite. test-server.ts
2
- # points SHIPWRIGHT_SPECIALTIES_DIR at the parent of this directory so the
3
- # new-agent type picker has a "coding" option without a product specialty.
4
- apiVersion: shipwright.dev/v1alpha1
5
- kind: Specialty
6
- metadata:
7
- name: coding
8
- displayName: Coding Agent
9
- description: Minimal coding specialty used by the admin e2e suite.
10
- version: 1.0.0
11
- skills: []
12
- identity:
13
- # Resolved against the repo root first, then against this manifest's own directory.
14
- templatesDir: templates/
15
- crons: []
16
- plugins:
17
- - fixture-plugin
18
- tools:
19
- - Bash
20
- - WebSearch
21
- env:
22
- required:
23
- - key: CLAUDE_CODE_OAUTH_TOKEN
24
- description: Claude Code OAuth token used to authenticate the agent's Claude sessions.
25
- secret: true
26
- optional:
27
- - key: GH_TOKEN
28
- description: GitHub Personal Access Token.
29
- secret: true
30
- members: []
31
- repos: []
32
- chat: true
33
- voice: true
@@ -1,372 +0,0 @@
1
- /**
2
- * admin/src/coding-task-store-provisioning-client.integration.test.ts
3
- * Integration tests for HttpTaskStoreProvisioningClient against recorded
4
- * task-store token API fixtures.
5
- *
6
- * Drives the client through an INJECTED fetchFn that replays canned Responses
7
- * from a cassette keyed by scenario — no live API server, no global.fetch
8
- * override, no mock.module(). Mirrors the pattern in
9
- * kubernetes-client.integration.test.ts.
10
- */
11
-
12
- import { describe, expect, it } from "bun:test";
13
- import { readFileSync } from "node:fs";
14
- import {
15
- HttpTaskStoreProvisioningClient,
16
- NoopTaskStoreProvisioningClient,
17
- } from "./coding-task-store-provisioning-client.ts";
18
-
19
- // ─── Cassette ───────────────────────────────────────────────────────────────
20
-
21
- interface CassetteEntry {
22
- status: number;
23
- body: unknown;
24
- }
25
-
26
- const CASSETTE_PATH = new URL(
27
- "./fixtures/task-store-provisioning-cassette.json",
28
- import.meta.url,
29
- ).pathname;
30
-
31
- const cassette: Record<string, CassetteEntry> = JSON.parse(
32
- readFileSync(CASSETTE_PATH, "utf-8"),
33
- );
34
-
35
- interface RecordedRequest {
36
- url: string;
37
- method: string;
38
- headers: Headers;
39
- body?: string;
40
- }
41
-
42
- /**
43
- * Build an injected fetchFn that returns the cassette entry for `key`.
44
- * Records the last request so tests can assert URL/method/headers/body.
45
- */
46
- function cassetteFetch(key: string): {
47
- fetchFn: typeof fetch;
48
- lastRequest: () => RecordedRequest;
49
- } {
50
- let last: RecordedRequest | undefined;
51
- const entry = cassette[key];
52
- if (!entry) throw new Error(`cassette key not found: ${key}`);
53
-
54
- const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
55
- const url = typeof input === "string" ? input : input.toString();
56
- last = {
57
- url,
58
- method: init?.method ?? "GET",
59
- headers: new Headers(init?.headers),
60
- body: typeof init?.body === "string" ? init.body : undefined,
61
- };
62
- return new Response(JSON.stringify(entry.body), {
63
- status: entry.status,
64
- statusText: `status-${entry.status}`,
65
- headers: { "Content-Type": "application/json" },
66
- });
67
- }) as typeof fetch;
68
-
69
- return {
70
- fetchFn,
71
- lastRequest: () => {
72
- if (!last) throw new Error("fetchFn was not called");
73
- return last;
74
- },
75
- };
76
- }
77
-
78
- /**
79
- * Build an injected fetchFn that resolves each request from `cassette[key]`
80
- * based on the request's method + URL (a simple router), and records every
81
- * request made — needed for tests that drive multiple calls (e.g. list then
82
- * revoke several matches) against a single client instance.
83
- *
84
- * `routes` maps "METHOD path" (path relative to baseUrl, e.g. "GET /tokens"
85
- * or "DELETE /tokens/tok_abc123") to a cassette key.
86
- */
87
- function multiCassetteFetch(routes: Record<string, string>): {
88
- fetchFn: typeof fetch;
89
- requests: () => RecordedRequest[];
90
- } {
91
- const requests: RecordedRequest[] = [];
92
-
93
- const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
94
- const url = typeof input === "string" ? input : input.toString();
95
- const method = init?.method ?? "GET";
96
- const path = url.replace("https://task-store.example.com", "");
97
- const routeKey = `${method} ${path}`;
98
- const cassetteKey = routes[routeKey];
99
- if (!cassetteKey) {
100
- throw new Error(`no route configured for: ${routeKey}`);
101
- }
102
- const entry = cassette[cassetteKey];
103
- if (!entry) throw new Error(`cassette key not found: ${cassetteKey}`);
104
-
105
- requests.push({
106
- url,
107
- method,
108
- headers: new Headers(init?.headers),
109
- body: typeof init?.body === "string" ? init.body : undefined,
110
- });
111
-
112
- return new Response(JSON.stringify(entry.body), {
113
- status: entry.status,
114
- statusText: `status-${entry.status}`,
115
- headers: { "Content-Type": "application/json" },
116
- });
117
- }) as typeof fetch;
118
-
119
- return { fetchFn, requests: () => requests };
120
- }
121
-
122
- function makeMultiClient(routes: Record<string, string>): {
123
- client: HttpTaskStoreProvisioningClient;
124
- requests: () => RecordedRequest[];
125
- } {
126
- const { fetchFn, requests } = multiCassetteFetch(routes);
127
- const client = new HttpTaskStoreProvisioningClient(
128
- "https://task-store.example.com",
129
- "admin-token-xyz",
130
- { fetchFn },
131
- );
132
- return { client, requests };
133
- }
134
-
135
- function makeClient(key: string): {
136
- client: HttpTaskStoreProvisioningClient;
137
- lastRequest: () => RecordedRequest;
138
- } {
139
- const { fetchFn, lastRequest } = cassetteFetch(key);
140
- const client = new HttpTaskStoreProvisioningClient(
141
- "https://task-store.example.com",
142
- "admin-token-xyz",
143
- { fetchFn },
144
- );
145
- return { client, lastRequest };
146
- }
147
-
148
- // ─── mintToken ───────────────────────────────────────────────────────────────
149
-
150
- describe("HttpTaskStoreProvisioningClient — mintToken", () => {
151
- it("POSTs to /tokens with Bearer auth and returns id/rawToken", async () => {
152
- const { client, lastRequest } = makeClient("mintToken_success");
153
- const result = await client.mintToken("agent-abc label");
154
-
155
- const req = lastRequest();
156
- expect(req.method).toBe("POST");
157
- expect(req.url).toBe("https://task-store.example.com/tokens");
158
- expect(req.headers.get("authorization")).toBe("Bearer admin-token-xyz");
159
- expect(req.headers.get("content-type")).toBe("application/json");
160
- expect(JSON.parse(req.body ?? "{}")).toEqual({ label: "agent-abc label" });
161
- expect(result).toEqual({
162
- id: "tok_abc123",
163
- rawToken: "sts_raw_test_token_value",
164
- });
165
- });
166
-
167
- it("includes agentId in the body when provided", async () => {
168
- const { client, lastRequest } = makeClient(
169
- "mintToken_success_with_agentId",
170
- );
171
- const result = await client.mintToken("agent-scoped label", "agent-123");
172
-
173
- const req = lastRequest();
174
- expect(JSON.parse(req.body ?? "{}")).toEqual({
175
- label: "agent-scoped label",
176
- agentId: "agent-123",
177
- });
178
- expect(result.id).toBe("tok_def456");
179
- });
180
-
181
- it("throws on a non-ok response", async () => {
182
- const { client } = makeClient("mintToken_500");
183
- await expect(client.mintToken("label")).rejects.toThrow(
184
- /task-store POST \/tokens failed: 500/,
185
- );
186
- });
187
- });
188
-
189
- // ─── revokeToken ─────────────────────────────────────────────────────────────
190
-
191
- describe("HttpTaskStoreProvisioningClient — revokeToken", () => {
192
- it("DELETEs /tokens/:id with Bearer auth", async () => {
193
- const { client, lastRequest } = makeClient("revokeToken_success");
194
- await client.revokeToken("tok_abc123");
195
-
196
- const req = lastRequest();
197
- expect(req.method).toBe("DELETE");
198
- expect(req.url).toBe("https://task-store.example.com/tokens/tok_abc123");
199
- expect(req.headers.get("authorization")).toBe("Bearer admin-token-xyz");
200
- });
201
-
202
- it("treats 404 as success (already revoked / not found is not an error)", async () => {
203
- const { client } = makeClient("revokeToken_404");
204
- await expect(client.revokeToken("tok_missing")).resolves.toBeUndefined();
205
- });
206
-
207
- it("throws on a non-ok, non-404 response", async () => {
208
- const { client } = makeClient("revokeToken_500");
209
- await expect(client.revokeToken("tok_abc123")).rejects.toThrow(
210
- /task-store DELETE \/tokens\/tok_abc123 failed: 500/,
211
- );
212
- });
213
- });
214
-
215
- // ─── listTokensForAgent ────────────────────────────────────────────────────────
216
-
217
- describe("HttpTaskStoreProvisioningClient — listTokensForAgent", () => {
218
- it("GETs /tokens with Bearer auth and filters client-side by agentId", async () => {
219
- const { client, lastRequest } = makeClient("listTokens_mixedAgents");
220
- const result = await client.listTokensForAgent("agent-1");
221
-
222
- const req = lastRequest();
223
- expect(req.method).toBe("GET");
224
- expect(req.url).toBe("https://task-store.example.com/tokens");
225
- expect(req.headers.get("authorization")).toBe("Bearer admin-token-xyz");
226
-
227
- // Only tokens with agentId === "agent-1" — includes the already-revoked
228
- // one (revocation state is irrelevant to listing/filtering).
229
- expect(result).toEqual([
230
- { id: "tok_agent1_a" },
231
- { id: "tok_agent1_b" },
232
- { id: "tok_agent1_revoked" },
233
- ]);
234
- });
235
-
236
- it("excludes tokens with a different agentId and tokens with no agentId", async () => {
237
- const { client } = makeClient("listTokens_mixedAgents");
238
- const result = await client.listTokensForAgent("agent-2");
239
- expect(result).toEqual([{ id: "tok_agent2_a" }]);
240
- });
241
-
242
- it("returns an empty array when no tokens match", async () => {
243
- const { client } = makeClient("listTokens_noMatches");
244
- const result = await client.listTokensForAgent("agent-1");
245
- expect(result).toEqual([]);
246
- });
247
-
248
- it("returns an empty array when the token list itself is empty", async () => {
249
- const { client } = makeClient("listTokens_empty");
250
- const result = await client.listTokensForAgent("agent-1");
251
- expect(result).toEqual([]);
252
- });
253
-
254
- it("throws on a non-ok response", async () => {
255
- const { client } = makeClient("listTokens_500");
256
- await expect(client.listTokensForAgent("agent-1")).rejects.toThrow(
257
- /task-store GET \/tokens failed: 500/,
258
- );
259
- });
260
- });
261
-
262
- // ─── listTokensForAgent + revokeToken orchestration (caller-driven) ──────────
263
-
264
- describe("listTokensForAgent + revokeToken — agent token cleanup flow", () => {
265
- it("revokes every token matching the agent (multiple matches)", async () => {
266
- const { client, requests } = makeMultiClient({
267
- "GET /tokens": "listTokens_mixedAgents",
268
- "DELETE /tokens/tok_agent1_a": "revokeToken_success",
269
- "DELETE /tokens/tok_agent1_b": "revokeToken_success",
270
- "DELETE /tokens/tok_agent1_revoked": "revokeToken_success",
271
- });
272
-
273
- const matches = await client.listTokensForAgent("agent-1");
274
- expect(matches).toEqual([
275
- { id: "tok_agent1_a" },
276
- { id: "tok_agent1_b" },
277
- { id: "tok_agent1_revoked" },
278
- ]);
279
-
280
- for (const { id } of matches) {
281
- await client.revokeToken(id);
282
- }
283
-
284
- const made = requests();
285
- expect(made).toHaveLength(4);
286
- expect(made[0].method).toBe("GET");
287
- expect(made[0].url).toBe("https://task-store.example.com/tokens");
288
- expect(made.slice(1).map((r) => r.method)).toEqual([
289
- "DELETE",
290
- "DELETE",
291
- "DELETE",
292
- ]);
293
-
294
- // Every request only ever touches /tokens paths — never /tasks. This is
295
- // the client-side guarantee that no Task/assignee mutation occurs: this
296
- // client has no method that can call anything but the tokens endpoint.
297
- for (const r of made) {
298
- expect(r.url).toMatch(
299
- /^https:\/\/task-store\.example\.com\/tokens(\/|$)/,
300
- );
301
- expect(r.url).not.toContain("/tasks");
302
- }
303
- });
304
-
305
- it("is a no-op when zero tokens match the agent", async () => {
306
- const { client, requests } = makeMultiClient({
307
- "GET /tokens": "listTokens_noMatches",
308
- });
309
-
310
- const matches = await client.listTokensForAgent("agent-does-not-exist");
311
- expect(matches).toEqual([]);
312
-
313
- for (const { id } of matches) {
314
- await client.revokeToken(id);
315
- }
316
-
317
- expect(requests()).toHaveLength(1);
318
- expect(requests()[0].method).toBe("GET");
319
- });
320
-
321
- it("re-revoking an already-revoked token does not throw", async () => {
322
- const { client, requests } = makeMultiClient({
323
- "GET /tokens": "listTokens_mixedAgents",
324
- // tok_agent1_revoked is already revoked server-side; task-store's
325
- // revoke() is idempotent, so this still returns success (200/204),
326
- // not a 404 or 500.
327
- "DELETE /tokens/tok_agent1_revoked": "revokeToken_success",
328
- "DELETE /tokens/tok_agent1_a": "revokeToken_success",
329
- "DELETE /tokens/tok_agent1_b": "revokeToken_success",
330
- });
331
-
332
- const matches = await client.listTokensForAgent("agent-1");
333
- const revokedMatch = matches.find((m) => m.id === "tok_agent1_revoked");
334
- expect(revokedMatch).toBeDefined();
335
-
336
- await expect(
337
- client.revokeToken("tok_agent1_revoked"),
338
- ).resolves.toBeUndefined();
339
-
340
- const made = requests();
341
- expect(made.some((r) => r.url.endsWith("/tokens/tok_agent1_revoked"))).toBe(
342
- true,
343
- );
344
- });
345
- });
346
-
347
- // ─── NoopTaskStoreProvisioningClient ─────────────────────────────────────────
348
-
349
- describe("NoopTaskStoreProvisioningClient", () => {
350
- it("mintToken returns empty id/rawToken", async () => {
351
- const client = new NoopTaskStoreProvisioningClient();
352
- const result = await client.mintToken("label");
353
- expect(result).toEqual({ id: "", rawToken: "" });
354
- });
355
-
356
- it("mintToken accepts an agentId without erroring", async () => {
357
- const client = new NoopTaskStoreProvisioningClient();
358
- const result = await client.mintToken("label", "agent-1");
359
- expect(result).toEqual({ id: "", rawToken: "" });
360
- });
361
-
362
- it("revokeToken resolves without error", async () => {
363
- const client = new NoopTaskStoreProvisioningClient();
364
- await expect(client.revokeToken("any-id")).resolves.toBeUndefined();
365
- });
366
-
367
- it("listTokensForAgent returns an empty array with no-op behavior", async () => {
368
- const client = new NoopTaskStoreProvisioningClient();
369
- const result = await client.listTokensForAgent("agent-1");
370
- expect(result).toEqual([]);
371
- });
372
- });