@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/e2e/chat-thread-page.e2e.ts +0 -79
- package/openapi.json +14 -1
- package/openapi.platform.json +14 -1
- package/package.json +2 -2
- package/prisma/client/internal/class.ts +4 -4
- package/prisma/client/internal/prismaNamespace.ts +1 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +1 -0
- package/prisma/client/models/Agent.ts +76 -1
- package/prisma/migrations/20261029000000_add_agent_specialty/migration.sql +5 -0
- package/prisma/schema/schema.prisma +3 -0
- package/src/admin-local-agent.smoke.test.ts +6 -2
- package/src/admin-ui-pages.ts +109 -153
- package/src/admin-ui-pages.unit.test.ts +85 -221
- package/src/admin-ui-push-test.ts +5 -1
- package/src/admin-ui.smoke.test.ts +69 -6
- package/src/admin-ui.ts +71 -10
- package/src/agent-cron-jobs.ts +6 -4
- package/src/agent-specialty.ts +22 -0
- package/src/agent-specialty.unit.test.ts +43 -0
- package/src/agents-api.smoke.test.ts +1 -0
- package/src/agents-api.ts +6 -1
- package/src/agents.integration.test.ts +90 -0
- package/src/agents.ts +39 -15
- package/src/agents.unit.test.ts +19 -2
- package/src/api.ts +11 -0
- package/src/chat.smoke.test.ts +78 -16
- package/src/http-range.ts +31 -0
- package/src/http-range.unit.test.ts +65 -0
- package/src/openapi-schemas.ts +3 -0
- package/src/push-toggle.ts +6 -2
- package/src/pwa.ts +1 -0
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
680
|
+
resolveSpecialty(row),
|
|
657
681
|
client,
|
|
658
682
|
);
|
|
659
|
-
return { ...row, missingRequiredEnv };
|
|
683
|
+
return { ...withResolvedSpecialty(row), missingRequiredEnv };
|
|
660
684
|
}
|
|
661
685
|
}
|
|
662
686
|
|
package/src/agents.unit.test.ts
CHANGED
|
@@ -409,8 +409,20 @@ describe("AgentService.list", () => {
|
|
|
409
409
|
const result = await service.list();
|
|
410
410
|
|
|
411
411
|
expect(result).toEqual([
|
|
412
|
-
{
|
|
413
|
-
|
|
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 } : {}),
|
package/src/chat.smoke.test.ts
CHANGED
|
@@ -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")).
|
|
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("
|
|
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
|
|
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(
|
|
1036
|
-
expect(res.
|
|
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
|
|
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(
|
|
1163
|
-
expect(res.
|
|
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
|
|
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(
|
|
1252
|
-
expect(res.
|
|
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
|
|
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(
|
|
1378
|
-
expect(res.
|
|
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
|
+
}
|