@app-vitals/agent-admin 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 } : {}),
@@ -413,9 +413,13 @@ function makeBaseDeps(overrides?: Partial<AdminUIDeps>): AdminUIDeps {
413
413
  describe("GET /admin/chat — auth gate", () => {
414
414
  it("returns 302 redirect to login without session cookie", async () => {
415
415
  const app = createAdminUIApp(makeBaseDeps());
416
- const res = await app.request("/admin/chat");
416
+ const res = await app.request("/admin/chat", {
417
+ headers: { Accept: "text/html" },
418
+ });
417
419
  expect(res.status).toBe(302);
418
- expect(res.headers.get("location")).toContain("/admin/login");
420
+ expect(res.headers.get("location")).toBe(
421
+ "/admin/login?returnTo=%2Fadmin%2Fchat",
422
+ );
419
423
  });
420
424
  });
421
425
 
@@ -616,14 +620,15 @@ describe("GET /admin/chat/:agentId/threads/:threadId — thread detail", () => {
616
620
  sessionCookie = await makeSessionCookie();
617
621
  });
618
622
 
619
- it("returns 401 without session cookie", async () => {
623
+ it("redirects navigations to login with returnTo without session cookie", async () => {
620
624
  const chatClient = makeMockChatClient();
621
625
  const app = createAdminUIApp(makeBaseDeps({ chatClient }));
622
626
  const res = await app.request(
623
627
  `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
628
+ { headers: { "Sec-Fetch-Mode": "navigate" } },
624
629
  );
625
630
  expect(res.status).toBe(302);
626
- expect(res.headers.get("location")).toContain("/admin/login");
631
+ expect(res.headers.get("location")).toContain("/admin/login?returnTo=");
627
632
  });
628
633
 
629
634
  it("returns 200 with messages when chatClient present", async () => {
@@ -1026,14 +1031,14 @@ describe("GET /admin/chat/:agentId/threads/:threadId/messages.json", () => {
1026
1031
  sessionCookie = await makeSessionCookie();
1027
1032
  });
1028
1033
 
1029
- it("returns 302 to login without session cookie", async () => {
1034
+ it("returns 401 signed_out without session cookie", async () => {
1030
1035
  const chatClient = makeMockChatClient();
1031
1036
  const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1032
1037
  const res = await app.request(
1033
1038
  `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1034
1039
  );
1035
- expect(res.status).toBe(302);
1036
- expect(res.headers.get("location")).toContain("/admin/login");
1040
+ expect(res.status).toBe(401);
1041
+ expect(await res.json()).toEqual({ error: "signed_out" });
1037
1042
  });
1038
1043
 
1039
1044
  it("returns 200 JSON with messages array when chatClient present", async () => {
@@ -1148,7 +1153,7 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages.json", () => {
1148
1153
  sessionCookie = await makeSessionCookie();
1149
1154
  });
1150
1155
 
1151
- it("returns 302 to login without session cookie", async () => {
1156
+ it("returns 401 signed_out without session cookie", async () => {
1152
1157
  const chatClient = makeMockChatClient();
1153
1158
  const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1154
1159
  const res = await app.request(
@@ -1159,8 +1164,8 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages.json", () => {
1159
1164
  body: JSON.stringify({ body: "test message" }),
1160
1165
  },
1161
1166
  );
1162
- expect(res.status).toBe(302);
1163
- expect(res.headers.get("location")).toContain("/admin/login");
1167
+ expect(res.status).toBe(401);
1168
+ expect(await res.json()).toEqual({ error: "signed_out" });
1164
1169
  });
1165
1170
 
1166
1171
  it("returns 200 JSON with message when chatClient present", async () => {
@@ -1237,7 +1242,7 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages/upload", () => {
1237
1242
  sessionCookie = await makeSessionCookie();
1238
1243
  });
1239
1244
 
1240
- it("returns 302 to login without session cookie", async () => {
1245
+ it("returns 401 signed_out without session cookie", async () => {
1241
1246
  const chatClient = makeMockChatClient();
1242
1247
  const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1243
1248
  const res = await app.request(
@@ -1248,8 +1253,8 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages/upload", () => {
1248
1253
  body: "body=test+message",
1249
1254
  },
1250
1255
  );
1251
- expect(res.status).toBe(302);
1252
- expect(res.headers.get("location")).toContain("/admin/login");
1256
+ expect(res.status).toBe(401);
1257
+ expect(await res.json()).toEqual({ error: "signed_out" });
1253
1258
  });
1254
1259
 
1255
1260
  it("returns 201 JSON with message when chatClient present", async () => {
@@ -1368,14 +1373,14 @@ describe("GET /admin/chat/:agentId/threads/:threadId/messages/:messageId/attachm
1368
1373
  sessionCookie = await makeSessionCookie();
1369
1374
  });
1370
1375
 
1371
- it("returns 302 to login without session cookie", async () => {
1376
+ it("returns 401 signed_out without session cookie", async () => {
1372
1377
  const chatClient = makeMockChatClient();
1373
1378
  const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1374
1379
  const res = await app.request(
1375
1380
  `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/msg-1/attachment`,
1376
1381
  );
1377
- expect(res.status).toBe(302);
1378
- expect(res.headers.get("location")).toContain("/admin/login");
1382
+ expect(res.status).toBe(401);
1383
+ expect(await res.json()).toEqual({ error: "signed_out" });
1379
1384
  });
1380
1385
 
1381
1386
  it("streams bytes with an audio/* Content-Type for a .webm attachment", async () => {
@@ -1397,6 +1402,63 @@ describe("GET /admin/chat/:agentId/threads/:threadId/messages/:messageId/attachm
1397
1402
  expect(Array.from(body)).toEqual(Array.from(bytes));
1398
1403
  });
1399
1404
 
1405
+ describe("Range support", () => {
1406
+ const bytes = new Uint8Array(Array.from({ length: 100 }, (_, i) => i));
1407
+ async function get(range?: string) {
1408
+ const chatClient = makeMockChatClient({
1409
+ getAttachment: async () => ({ bytes, filename: "reply.wav" }),
1410
+ });
1411
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1412
+ return app.request(
1413
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/msg-1/attachment`,
1414
+ {
1415
+ headers: {
1416
+ Cookie: `admin_session=${sessionCookie}`,
1417
+ ...(range ? { Range: range } : {}),
1418
+ },
1419
+ },
1420
+ );
1421
+ }
1422
+
1423
+ it("200 with Accept-Ranges and Content-Length when no Range is sent", async () => {
1424
+ const res = await get();
1425
+ expect(res.status).toBe(200);
1426
+ expect(res.headers.get("accept-ranges")).toBe("bytes");
1427
+ expect(res.headers.get("content-length")).toBe("100");
1428
+ expect((await res.arrayBuffer()).byteLength).toBe(100);
1429
+ });
1430
+
1431
+ it("206 with Content-Range for a mid-file range", async () => {
1432
+ const res = await get("bytes=10-19");
1433
+ expect(res.status).toBe(206);
1434
+ expect(res.headers.get("content-range")).toBe("bytes 10-19/100");
1435
+ expect(res.headers.get("accept-ranges")).toBe("bytes");
1436
+ expect(res.headers.get("content-length")).toBe("10");
1437
+ expect(Array.from(new Uint8Array(await res.arrayBuffer()))).toEqual(
1438
+ Array.from(bytes.slice(10, 20)),
1439
+ );
1440
+ });
1441
+
1442
+ it("206 for an open-ended range", async () => {
1443
+ const res = await get("bytes=90-");
1444
+ expect(res.status).toBe(206);
1445
+ expect(res.headers.get("content-range")).toBe("bytes 90-99/100");
1446
+ expect((await res.arrayBuffer()).byteLength).toBe(10);
1447
+ });
1448
+
1449
+ it("206 for a suffix range", async () => {
1450
+ const res = await get("bytes=-5");
1451
+ expect(res.status).toBe(206);
1452
+ expect(res.headers.get("content-range")).toBe("bytes 95-99/100");
1453
+ });
1454
+
1455
+ it("416 with Content-Range bytes */total when unsatisfiable", async () => {
1456
+ const res = await get("bytes=500-600");
1457
+ expect(res.status).toBe(416);
1458
+ expect(res.headers.get("content-range")).toBe("bytes */100");
1459
+ });
1460
+ });
1461
+
1400
1462
  it("returns 404 when the chat client reports no attachment", async () => {
1401
1463
  const chatClient = makeMockChatClient({ getAttachment: async () => null });
1402
1464
  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
+ }