@app-vitals/agent-admin 0.8.0 → 0.9.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.
package/src/admin-ui.ts CHANGED
@@ -85,6 +85,7 @@ import {
85
85
  audioContentTypeForFilename,
86
86
  validateAttachment,
87
87
  } from "./attachment-validation.ts";
88
+ import { parseByteRange } from "./http-range.ts";
88
89
  import {
89
90
  type CallerScopeResolver,
90
91
  memberOnlyCallerScopeResolver,
@@ -3349,9 +3350,34 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
3349
3350
  // .slice() normalizes to Uint8Array<ArrayBuffer> — Hono's Data type
3350
3351
  // requires that exact generic, not the wider Uint8Array<ArrayBufferLike>
3351
3352
  // that new Uint8Array(await res.arrayBuffer()) produces.
3352
- return c.body(attachment.bytes.slice(), 200, {
3353
+ const total = attachment.bytes.byteLength;
3354
+ const baseHeaders = {
3353
3355
  "Content-Type": contentType,
3354
3356
  "Content-Disposition": `inline; filename="${attachment.filename.replace(/"/g, "")}"`,
3357
+ "Accept-Ranges": "bytes",
3358
+ };
3359
+ // iOS Safari only plays media from servers that honour byte ranges.
3360
+ const range = parseByteRange(c.req.header("Range"), total);
3361
+ if (range.kind === "unsatisfiable") {
3362
+ return c.body(null, 416, {
3363
+ ...baseHeaders,
3364
+ "Content-Range": `bytes */${total}`,
3365
+ });
3366
+ }
3367
+ if (range.kind === "range") {
3368
+ return c.body(
3369
+ attachment.bytes.slice(range.start, range.end + 1),
3370
+ 206,
3371
+ {
3372
+ ...baseHeaders,
3373
+ "Content-Range": `bytes ${range.start}-${range.end}/${total}`,
3374
+ "Content-Length": String(range.end - range.start + 1),
3375
+ },
3376
+ );
3377
+ }
3378
+ return c.body(attachment.bytes.slice(), 200, {
3379
+ ...baseHeaders,
3380
+ "Content-Length": String(total),
3355
3381
  });
3356
3382
  } catch {
3357
3383
  return c.json({ error: "failed to fetch attachment" }, 500);
@@ -17,6 +17,7 @@ import {
17
17
  } from "./specialty-manifest-loader.ts";
18
18
  import type { SpecialtyManifest } from "./specialty-registry.ts";
19
19
  import { type Clock, SystemClock } from "./clock.ts";
20
+ import { resolveSpecialty } from "./agent-specialty.ts";
20
21
  import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
21
22
 
22
23
  /**
@@ -488,15 +489,16 @@ export class AgentCronJobService {
488
489
  // than leaving its crons untouched.
489
490
  const agent = await this.prisma.agent.findUnique({
490
491
  where: { id: agentId },
491
- select: { typeName: true },
492
+ select: { typeName: true, specialty: true },
492
493
  });
493
494
  if (!agent) {
494
495
  throw new NotFoundError(`agent ${agentId} not found`);
495
496
  }
496
- const manifest = this.specialtyRegistry.tryGetManifest(agent.typeName);
497
+ const specialty = resolveSpecialty(agent);
498
+ const manifest = this.specialtyRegistry.tryGetManifest(specialty);
497
499
  if (!manifest) {
498
500
  const defaultName = resolveDefaultSpecialtyName();
499
- if (agent.typeName === defaultName) {
501
+ if (specialty === defaultName) {
500
502
  throw missingDefaultSpecialtyError(
501
503
  defaultName,
502
504
  resolveSpecialtiesDir(),
@@ -504,7 +506,7 @@ export class AgentCronJobService {
504
506
  );
505
507
  }
506
508
  console.error(
507
- `reconcileSystemCrons: no loadable manifest for typeName "${agent.typeName}" (agent ${agentId}) — leaving crons untouched`,
509
+ `reconcileSystemCrons: no loadable manifest for specialty "${specialty}" (agent ${agentId}) — leaving crons untouched`,
508
510
  );
509
511
  return { created: 0, updated: 0, deleted: 0 };
510
512
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * AGR-6.1: typeName -> specialty rename, ADD step. `Agent.specialty` is a
3
+ * nullable column written alongside `typeName`; reads prefer it and fall back
4
+ * to `typeName` for rows that predate the column. Nothing is retired yet.
5
+ */
6
+
7
+ export interface SpecialtyBearingRow {
8
+ typeName: string;
9
+ specialty?: string | null;
10
+ }
11
+
12
+ /** The agent's specialty: the new column when set, else the legacy `typeName`. */
13
+ export function resolveSpecialty(row: SpecialtyBearingRow): string {
14
+ return row.specialty ?? row.typeName;
15
+ }
16
+
17
+ /** Returns the row with `specialty` resolved to a non-null string. */
18
+ export function withResolvedSpecialty<T extends SpecialtyBearingRow>(
19
+ row: T,
20
+ ): Omit<T, "specialty"> & { specialty: string } {
21
+ return { ...row, specialty: resolveSpecialty(row) };
22
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Unit tests for the AGR-6.1 specialty mapper (typeName -> specialty ADD step).
3
+ */
4
+
5
+ import { describe, expect, it } from "bun:test";
6
+ import { resolveSpecialty, withResolvedSpecialty } from "./agent-specialty.ts";
7
+
8
+ describe("resolveSpecialty", () => {
9
+ it("prefers specialty when set", () => {
10
+ expect(
11
+ resolveSpecialty({ typeName: "coding", specialty: "research" }),
12
+ ).toBe("research");
13
+ });
14
+
15
+ it("falls back to typeName when specialty is null", () => {
16
+ expect(resolveSpecialty({ typeName: "coding", specialty: null })).toBe(
17
+ "coding",
18
+ );
19
+ });
20
+
21
+ it("falls back to typeName when specialty is absent", () => {
22
+ expect(resolveSpecialty({ typeName: "coding" })).toBe("coding");
23
+ });
24
+
25
+ it("does not treat an empty-string specialty as unset", () => {
26
+ expect(resolveSpecialty({ typeName: "coding", specialty: "" })).toBe("");
27
+ });
28
+ });
29
+
30
+ describe("withResolvedSpecialty", () => {
31
+ it("returns a copy with specialty resolved and typeName preserved", () => {
32
+ const row = { id: "a1", typeName: "coding", specialty: null };
33
+ const out = withResolvedSpecialty(row);
34
+ expect(out).toEqual({ id: "a1", typeName: "coding", specialty: "coding" });
35
+ expect(row.specialty).toBeNull();
36
+ });
37
+
38
+ it("keeps an explicit specialty", () => {
39
+ expect(
40
+ withResolvedSpecialty({ typeName: "coding", specialty: "research" }),
41
+ ).toEqual({ typeName: "coding", specialty: "research" });
42
+ });
43
+ });
@@ -2349,6 +2349,7 @@ describe("admin API — POST /agents/reconcile", () => {
2349
2349
  name: "Regular Agent",
2350
2350
  selfHosted: false,
2351
2351
  typeName: "coding",
2352
+ specialty: "coding",
2352
2353
  });
2353
2354
  });
2354
2355
  });
package/src/agents-api.ts CHANGED
@@ -65,6 +65,7 @@ import type { AgentEnvService } from "./agent-envs.ts";
65
65
  import type { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
66
66
  import type { AgentMemberService } from "./agent-members.ts";
67
67
  import type { AgentPluginService } from "./agent-plugins.ts";
68
+ import { resolveSpecialty } from "./agent-specialty.ts";
68
69
  import {
69
70
  type AgentProvisioner,
70
71
  hasPerAgentIdentity,
@@ -411,6 +412,8 @@ const GetAgentResultSchema = z
411
412
  patchAuthorAllowlist: z.array(z.string()),
412
413
  restrictSlackToMembers: z.boolean(),
413
414
  typeName: z.string(),
415
+ /** AGR-6.1: specialty (specialty ?? typeName); additive next to typeName. */
416
+ specialty: z.string(),
414
417
  /** COS-49.1: the pod ServiceAccount; null when unset. */
415
418
  podServiceAccountName: z.string().nullable(),
416
419
  /** Owning account; null for unassigned agents (task-store maps null to its 'default' account). */
@@ -1905,7 +1908,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1905
1908
  anthropicFederationRuleId: _r,
1906
1909
  k8sServiceAccountName: _k,
1907
1910
  ...summary
1908
- }) => summary,
1911
+ }) => ({ ...summary, specialty: resolveSpecialty(summary) }),
1909
1912
  );
1910
1913
  if (c.get("isAdmin") === true || email === undefined) {
1911
1914
  return c.json(agents, 200);
@@ -2816,6 +2819,7 @@ function serializeAgent(
2816
2819
  patchAuthorAllowlist?: string[];
2817
2820
  restrictSlackToMembers?: boolean;
2818
2821
  typeName: string;
2822
+ specialty?: string | null;
2819
2823
  accountId?: string | null;
2820
2824
  podServiceAccountName?: string | null;
2821
2825
  createdAt: Date;
@@ -2846,6 +2850,7 @@ function serializeAgent(
2846
2850
  patchAuthorAllowlist: agent.patchAuthorAllowlist ?? [],
2847
2851
  restrictSlackToMembers: agent.restrictSlackToMembers ?? false,
2848
2852
  typeName: agent.typeName,
2853
+ specialty: resolveSpecialty(agent),
2849
2854
  accountId: agent.accountId ?? null,
2850
2855
  podServiceAccountName: agent.podServiceAccountName ?? null,
2851
2856
  createdAt: agent.createdAt.toISOString(),
@@ -510,3 +510,93 @@ describeOrSkip("createAgent() — integration (real Postgres)", () => {
510
510
  });
511
511
  });
512
512
  });
513
+
514
+ describeOrSkip("Agent.specialty — dual-write + fallback read (AGR-6.1)", () => {
515
+ let prisma: PrismaClient;
516
+ let agentService: AgentService;
517
+ const createdIds: string[] = [];
518
+
519
+ beforeEach(() => {
520
+ prisma = makePrisma();
521
+ agentService = new AgentService(prisma, fakeSpecialtyRegistry());
522
+ });
523
+
524
+ afterEach(async () => {
525
+ await prisma.agent.deleteMany({ where: { id: { in: createdIds } } });
526
+ createdIds.length = 0;
527
+ await prisma.$disconnect();
528
+ });
529
+
530
+ it("create() writes specialty and typeName with the same value", async () => {
531
+ const created = await agentService.create({
532
+ name: "Dual Write",
533
+ typeName: "coding",
534
+ });
535
+ createdIds.push(created.id);
536
+
537
+ const row = await prisma.agent.findUniqueOrThrow({
538
+ where: { id: created.id },
539
+ });
540
+ expect(row.typeName).toBe("coding");
541
+ expect(row.specialty).toBe("coding");
542
+ expect(created.typeName).toBe("coding");
543
+ expect(created.specialty).toBe("coding");
544
+ });
545
+
546
+ it("reads prefer specialty over typeName", async () => {
547
+ const created = await agentService.create({
548
+ name: "Prefers Specialty",
549
+ typeName: "coding",
550
+ });
551
+ createdIds.push(created.id);
552
+ await prisma.agent.update({
553
+ where: { id: created.id },
554
+ data: { specialty: "research" },
555
+ });
556
+
557
+ const detail = await agentService.getDetail(created.id);
558
+ expect(detail?.typeName).toBe("coding");
559
+ expect(detail?.specialty).toBe("research");
560
+ expect((await agentService.getSummary(created.id))?.specialty).toBe(
561
+ "research",
562
+ );
563
+ expect(
564
+ (await agentService.list()).find((a) => a.id === created.id)?.specialty,
565
+ ).toBe("research");
566
+ });
567
+
568
+ it("falls back to typeName when specialty is NULL (pre-migration row)", async () => {
569
+ const created = await agentService.create({
570
+ name: "Legacy Row",
571
+ typeName: "coding",
572
+ });
573
+ createdIds.push(created.id);
574
+ await prisma.agent.update({
575
+ where: { id: created.id },
576
+ data: { specialty: null },
577
+ });
578
+
579
+ const detail = await agentService.getDetail(created.id);
580
+ expect(detail?.specialty).toBe("coding");
581
+ expect((await agentService.getSummary(created.id))?.specialty).toBe(
582
+ "coding",
583
+ );
584
+ expect((await agentService.getById(created.id))?.specialty).toBe("coding");
585
+ expect((await agentService.listByIds([created.id]))[0]?.specialty).toBe(
586
+ "coding",
587
+ );
588
+ });
589
+
590
+ it("createAgent() leaves both columns populated", async () => {
591
+ const created = await agentService.create({ name: "No Type" });
592
+ createdIds.push(created.id);
593
+ const row = await prisma.agent.findUniqueOrThrow({
594
+ where: { id: created.id },
595
+ });
596
+ // typeName omitted: column default applies and specialty stays NULL,
597
+ // so the fallback read still yields the default.
598
+ expect(row.typeName).toBe("coding");
599
+ expect(row.specialty).toBeNull();
600
+ expect(created.specialty).toBe("coding");
601
+ });
602
+ });
package/src/agents.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  SpecialtyRegistry,
25
25
  } from "./specialty-manifest-loader.ts";
26
26
  import { ANTHROPIC_CREDENTIALS_BLOCKED_CODE } from "./anthropic-credential-guard.ts";
27
+ import { resolveSpecialty, withResolvedSpecialty } from "./agent-specialty.ts";
27
28
  import type { PrismaTransactionClient } from "./prisma-tx.ts";
28
29
 
29
30
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -54,6 +55,8 @@ export interface AgentRecord {
54
55
  slackId: string | null;
55
56
  selfHosted: boolean;
56
57
  typeName: string;
58
+ /** AGR-6.1: resolved specialty (specialty ?? typeName). */
59
+ specialty?: string;
57
60
  createdAt: Date;
58
61
  updatedAt: Date;
59
62
  /** Owning account (SSP-5.2 admin Account column); null for legacy agents. */
@@ -82,6 +85,8 @@ export interface AgentSummary extends Partial<AgentAnthropicIdentity> {
82
85
  name: string;
83
86
  selfHosted: boolean;
84
87
  typeName: string;
88
+ /** AGR-6.1: resolved specialty (specialty ?? typeName). */
89
+ specialty?: string;
85
90
  /** Owning account; null/absent for platform agents. */
86
91
  accountId?: string | null;
87
92
  }
@@ -98,6 +103,8 @@ export interface AgentDetail extends Partial<AgentAnthropicIdentity> {
98
103
  patchAuthorAllowlist: string[];
99
104
  restrictSlackToMembers: boolean;
100
105
  typeName: string;
106
+ /** AGR-6.1: resolved specialty (specialty ?? typeName). */
107
+ specialty?: string;
101
108
  /**
102
109
  * Owning account, null for unassigned (platform) agents. Optional here so
103
110
  * existing test doubles constructing a narrower AgentDetail literal don't
@@ -205,6 +212,8 @@ interface AgentIdAndRepos {
205
212
  * Optional for the same fixture-compat reason as trialExpiresAt.
206
213
  */
207
214
  typeName?: string;
215
+ /** AGR-6.1: resolved specialty (specialty ?? typeName). */
216
+ specialty?: string;
208
217
  /**
209
218
  * APM-1.5: the 6 agent-policy fields, read by the runtime GET /:id/config
210
219
  * route for direct passthrough. Required (not optional) since the Prisma
@@ -255,6 +264,7 @@ const SUMMARY_SELECT = {
255
264
  name: true,
256
265
  selfHosted: true,
257
266
  typeName: true,
267
+ specialty: true,
258
268
  accountId: true,
259
269
  ownerEmail: true,
260
270
  anthropicIdentityName: true,
@@ -274,6 +284,7 @@ const DETAIL_SELECT = {
274
284
  patchAuthorAllowlist: true,
275
285
  restrictSlackToMembers: true,
276
286
  typeName: true,
287
+ specialty: true,
277
288
  accountId: true,
278
289
  createdAt: true,
279
290
  updatedAt: true,
@@ -327,7 +338,9 @@ export class AgentService {
327
338
  name: input.name,
328
339
  slackId: input.slackId ?? null,
329
340
  selfHosted: input.selfHosted ?? false,
330
- ...(input.typeName !== undefined ? { typeName: input.typeName } : {}),
341
+ ...(input.typeName !== undefined
342
+ ? { typeName: input.typeName, specialty: input.typeName }
343
+ : {}),
331
344
  ...(input.repos !== undefined ? { repos: input.repos } : {}),
332
345
  ...(input.reviewAuthorAllowlist !== undefined
333
346
  ? { reviewAuthorAllowlist: input.reviewAuthorAllowlist }
@@ -348,10 +361,10 @@ export class AgentService {
348
361
  });
349
362
  const missingRequiredEnv = await this.computeMissingRequiredEnv(
350
363
  row.id,
351
- row.typeName,
364
+ resolveSpecialty(row),
352
365
  client,
353
366
  );
354
- return { ...row, missingRequiredEnv };
367
+ return { ...withResolvedSpecialty(row), missingRequiredEnv };
355
368
  }
356
369
 
357
370
  /**
@@ -385,10 +398,11 @@ export class AgentService {
385
398
  * List all agents (id + name + selfHosted + typeName), ordered by name asc.
386
399
  */
387
400
  async list(): Promise<AgentSummary[]> {
388
- return this.prisma.agent.findMany({
401
+ const rows = await this.prisma.agent.findMany({
389
402
  select: SUMMARY_SELECT,
390
403
  orderBy: { name: "asc" },
391
404
  });
405
+ return rows.map(withResolvedSpecialty);
392
406
  }
393
407
 
394
408
  /**
@@ -396,10 +410,11 @@ export class AgentService {
396
410
  * not found.
397
411
  */
398
412
  async getSummary(id: string): Promise<AgentSummary | null> {
399
- return this.prisma.agent.findUnique({
413
+ const row = await this.prisma.agent.findUnique({
400
414
  where: { id },
401
415
  select: SUMMARY_SELECT,
402
416
  });
417
+ return row ? withResolvedSpecialty(row) : null;
403
418
  }
404
419
 
405
420
  /**
@@ -445,9 +460,9 @@ export class AgentService {
445
460
 
446
461
  const missingRequiredEnv = await this.computeMissingRequiredEnv(
447
462
  id,
448
- row.typeName,
463
+ resolveSpecialty(row),
449
464
  );
450
- return { ...row, missingRequiredEnv };
465
+ return { ...withResolvedSpecialty(row), missingRequiredEnv };
451
466
  }
452
467
 
453
468
  /**
@@ -516,9 +531,9 @@ export class AgentService {
516
531
  });
517
532
  const missingRequiredEnv = await this.computeMissingRequiredEnv(
518
533
  id,
519
- row.typeName,
534
+ resolveSpecialty(row),
520
535
  );
521
- return { ...row, missingRequiredEnv };
536
+ return { ...withResolvedSpecialty(row), missingRequiredEnv };
522
537
  }
523
538
 
524
539
  /**
@@ -539,6 +554,7 @@ export class AgentService {
539
554
  trialExpiresAt: true,
540
555
  selfHosted: true,
541
556
  typeName: true,
557
+ specialty: true,
542
558
  account: { select: { status: true } },
543
559
  autoPostReviews: true,
544
560
  allowSelfReview: true,
@@ -553,7 +569,11 @@ export class AgentService {
553
569
  const members = await this.agentMemberService.listByAgentId(id);
554
570
  const memberEmails = members.map((m) => m.email);
555
571
  const { account, ...rest } = row;
556
- return { ...rest, memberEmails, accountStatus: account?.status ?? null };
572
+ return {
573
+ ...withResolvedSpecialty(rest),
574
+ memberEmails,
575
+ accountStatus: account?.status ?? null,
576
+ };
557
577
  }
558
578
 
559
579
  /**
@@ -563,7 +583,7 @@ export class AgentService {
563
583
  * order Prisma returns.
564
584
  */
565
585
  async listAll(): Promise<AgentRecord[]> {
566
- return this.prisma.agent.findMany();
586
+ return (await this.prisma.agent.findMany()).map(withResolvedSpecialty);
567
587
  }
568
588
 
569
589
  /**
@@ -572,7 +592,10 @@ export class AgentService {
572
592
  * and agentNames-resolution on the tasks/PRs pages).
573
593
  */
574
594
  async listByIds(ids: string[]): Promise<AgentRecord[]> {
575
- return this.prisma.agent.findMany({ where: { id: { in: ids } } });
595
+ const rows = await this.prisma.agent.findMany({
596
+ where: { id: { in: ids } },
597
+ });
598
+ return rows.map(withResolvedSpecialty);
576
599
  }
577
600
 
578
601
  /**
@@ -580,9 +603,10 @@ export class AgentService {
580
603
  * /admin/tasks agent-name filter.
581
604
  */
582
605
  async searchByName(query: string): Promise<AgentRecord[]> {
583
- return this.prisma.agent.findMany({
606
+ const rows = await this.prisma.agent.findMany({
584
607
  where: { name: { contains: query, mode: "insensitive" } },
585
608
  });
609
+ return rows.map(withResolvedSpecialty);
586
610
  }
587
611
 
588
612
  /**
@@ -653,10 +677,10 @@ export class AgentService {
653
677
  });
654
678
  const missingRequiredEnv = await this.computeMissingRequiredEnv(
655
679
  id,
656
- row.typeName,
680
+ resolveSpecialty(row),
657
681
  client,
658
682
  );
659
- return { ...row, missingRequiredEnv };
683
+ return { ...withResolvedSpecialty(row), missingRequiredEnv };
660
684
  }
661
685
  }
662
686
 
@@ -409,8 +409,20 @@ describe("AgentService.list", () => {
409
409
  const result = await service.list();
410
410
 
411
411
  expect(result).toEqual([
412
- { id: "a2", name: "Alpha", selfHosted: true, typeName: "coding" },
413
- { id: "a1", name: "Zeta", selfHosted: false, typeName: "coding" },
412
+ {
413
+ id: "a2",
414
+ name: "Alpha",
415
+ selfHosted: true,
416
+ typeName: "coding",
417
+ specialty: "coding",
418
+ },
419
+ {
420
+ id: "a1",
421
+ name: "Zeta",
422
+ selfHosted: false,
423
+ typeName: "coding",
424
+ specialty: "coding",
425
+ },
414
426
  ]);
415
427
  });
416
428
 
@@ -435,6 +447,7 @@ describe("AgentService.getSummary", () => {
435
447
  name: "Existing",
436
448
  selfHosted: true,
437
449
  typeName: "coding",
450
+ specialty: "coding",
438
451
  });
439
452
  });
440
453
 
@@ -475,6 +488,7 @@ describe("AgentService.getDetail", () => {
475
488
  patchAuthorAllowlist: [],
476
489
  restrictSlackToMembers: false,
477
490
  typeName: "coding",
491
+ specialty: "coding",
478
492
  createdAt: row.createdAt,
479
493
  updatedAt: row.updatedAt,
480
494
  missingRequiredEnv: [],
@@ -735,6 +749,7 @@ describe("AgentService.getById", () => {
735
749
  accountStatus: null,
736
750
  // ARD-6.1: getById() now selects typeName for the runtime config response.
737
751
  typeName: "coding",
752
+ specialty: "coding",
738
753
  selfHosted: false,
739
754
  // APM-1.5: getById() now selects these 6 policy fields too.
740
755
  autoPostReviews: true,
@@ -772,6 +787,7 @@ describe("AgentService.getById", () => {
772
787
  accountStatus: null,
773
788
  // ARD-6.1: getById() now selects typeName for the runtime config response.
774
789
  typeName: "coding",
790
+ specialty: "coding",
775
791
  selfHosted: false,
776
792
  // APM-1.5: getById() now selects these 6 policy fields too.
777
793
  autoPostReviews: true,
@@ -1040,6 +1056,7 @@ describe("AgentService.updateFields", () => {
1040
1056
  patchAuthorAllowlist: [],
1041
1057
  restrictSlackToMembers: false,
1042
1058
  typeName: "coding",
1059
+ specialty: "coding",
1043
1060
  createdAt: row.createdAt,
1044
1061
  updatedAt: new Date("2024-01-02"),
1045
1062
  missingRequiredEnv: [],
package/src/api.ts CHANGED
@@ -23,6 +23,7 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
23
23
  import type { AgentCronJob } from "./agent-cron-jobs.ts";
24
24
  import type { AgentEnvBundle } from "./agent-envs.ts";
25
25
  import type { AgentTokenService } from "./agent-tokens.ts";
26
+ import { resolveSpecialty } from "./agent-specialty.ts";
26
27
  import { anthropicCredentialKeysIn } from "./anthropic-credential-guard.ts";
27
28
  import { type AdminApiKey, createAdminAuthMiddleware } from "./api-auth.ts";
28
29
  import {
@@ -71,6 +72,8 @@ export interface AgentConfigResponse {
71
72
  * the agent runtime treats an absent value as "coding".
72
73
  */
73
74
  typeName?: string;
75
+ /** AGR-6.1: specialty (specialty ?? typeName); additive, optional for doubles. */
76
+ specialty?: string;
74
77
  /**
75
78
  * PMC-1.1: phase -> subagentType (or null) map, covering all six pipeline
76
79
  * phases regardless of whether the agent has an explicit override row for
@@ -134,6 +137,7 @@ interface AgentServiceLike {
134
137
  /** SSP-8.3: owning account status; optional for pre-existing test doubles. */
135
138
  accountStatus?: string | null;
136
139
  typeName?: string;
140
+ specialty?: string | null;
137
141
  /** WIF-2.1: optional so pre-existing test doubles keep compiling; absent is treated as not self-hosted. */
138
142
  selfHosted?: boolean;
139
143
  /** APM-1.5: the 6 agent-policy fields, direct-passthrough into the response. */
@@ -346,6 +350,13 @@ export function createAgentRuntimeApp(deps: AgentRuntimeDeps): OpenAPIHono {
346
350
  : null,
347
351
  accountStatus: agent.accountStatus ?? null,
348
352
  typeName: agent.typeName,
353
+ specialty:
354
+ agent.typeName === undefined
355
+ ? undefined
356
+ : resolveSpecialty({
357
+ typeName: agent.typeName,
358
+ specialty: agent.specialty,
359
+ }),
349
360
  // Omitted entirely when no phase-methodology service was supplied
350
361
  // (platform-only admin, no coding extension).
351
362
  ...(agentPhaseMethodologyService ? { phaseMethodology } : {}),
@@ -1397,6 +1397,63 @@ describe("GET /admin/chat/:agentId/threads/:threadId/messages/:messageId/attachm
1397
1397
  expect(Array.from(body)).toEqual(Array.from(bytes));
1398
1398
  });
1399
1399
 
1400
+ describe("Range support", () => {
1401
+ const bytes = new Uint8Array(Array.from({ length: 100 }, (_, i) => i));
1402
+ async function get(range?: string) {
1403
+ const chatClient = makeMockChatClient({
1404
+ getAttachment: async () => ({ bytes, filename: "reply.wav" }),
1405
+ });
1406
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1407
+ return app.request(
1408
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/msg-1/attachment`,
1409
+ {
1410
+ headers: {
1411
+ Cookie: `admin_session=${sessionCookie}`,
1412
+ ...(range ? { Range: range } : {}),
1413
+ },
1414
+ },
1415
+ );
1416
+ }
1417
+
1418
+ it("200 with Accept-Ranges and Content-Length when no Range is sent", async () => {
1419
+ const res = await get();
1420
+ expect(res.status).toBe(200);
1421
+ expect(res.headers.get("accept-ranges")).toBe("bytes");
1422
+ expect(res.headers.get("content-length")).toBe("100");
1423
+ expect((await res.arrayBuffer()).byteLength).toBe(100);
1424
+ });
1425
+
1426
+ it("206 with Content-Range for a mid-file range", async () => {
1427
+ const res = await get("bytes=10-19");
1428
+ expect(res.status).toBe(206);
1429
+ expect(res.headers.get("content-range")).toBe("bytes 10-19/100");
1430
+ expect(res.headers.get("accept-ranges")).toBe("bytes");
1431
+ expect(res.headers.get("content-length")).toBe("10");
1432
+ expect(Array.from(new Uint8Array(await res.arrayBuffer()))).toEqual(
1433
+ Array.from(bytes.slice(10, 20)),
1434
+ );
1435
+ });
1436
+
1437
+ it("206 for an open-ended range", async () => {
1438
+ const res = await get("bytes=90-");
1439
+ expect(res.status).toBe(206);
1440
+ expect(res.headers.get("content-range")).toBe("bytes 90-99/100");
1441
+ expect((await res.arrayBuffer()).byteLength).toBe(10);
1442
+ });
1443
+
1444
+ it("206 for a suffix range", async () => {
1445
+ const res = await get("bytes=-5");
1446
+ expect(res.status).toBe(206);
1447
+ expect(res.headers.get("content-range")).toBe("bytes 95-99/100");
1448
+ });
1449
+
1450
+ it("416 with Content-Range bytes */total when unsatisfiable", async () => {
1451
+ const res = await get("bytes=500-600");
1452
+ expect(res.status).toBe(416);
1453
+ expect(res.headers.get("content-range")).toBe("bytes */100");
1454
+ });
1455
+ });
1456
+
1400
1457
  it("returns 404 when the chat client reports no attachment", async () => {
1401
1458
  const chatClient = makeMockChatClient({ getAttachment: async () => null });
1402
1459
  const app = createAdminUIApp(makeBaseDeps({ chatClient }));
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Single-range HTTP `Range` header parsing (RFC 9110 §14) for the attachment
3
+ * proxy. Pure — no I/O. Multi-range, malformed and non-`bytes` requests are
4
+ * ignored (`none`), which the caller answers with a normal 200.
5
+ */
6
+ export type ByteRange =
7
+ | { kind: "none" }
8
+ | { kind: "range"; start: number; end: number }
9
+ | { kind: "unsatisfiable" };
10
+
11
+ export function parseByteRange(
12
+ header: string | null | undefined,
13
+ size: number,
14
+ ): ByteRange {
15
+ if (!header) return { kind: "none" };
16
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
17
+ if (!m || (m[1] === "" && m[2] === "")) return { kind: "none" };
18
+
19
+ if (m[1] === "") {
20
+ // Suffix: the last n bytes.
21
+ const n = Number(m[2]);
22
+ if (n === 0 || size === 0) return { kind: "unsatisfiable" };
23
+ return { kind: "range", start: Math.max(0, size - n), end: size - 1 };
24
+ }
25
+
26
+ const start = Number(m[1]);
27
+ if (m[2] !== "" && Number(m[2]) < start) return { kind: "none" };
28
+ if (start >= size) return { kind: "unsatisfiable" };
29
+ const end = m[2] === "" ? size - 1 : Math.min(Number(m[2]), size - 1);
30
+ return { kind: "range", start, end };
31
+ }