@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/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-ui-pages.ts +94 -151
- package/src/admin-ui-pages.unit.test.ts +85 -221
- package/src/admin-ui.ts +27 -1
- 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 +57 -0
- 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/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
|
-
|
|
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);
|
package/src/agent-cron-jobs.ts
CHANGED
|
@@ -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
|
|
497
|
+
const specialty = resolveSpecialty(agent);
|
|
498
|
+
const manifest = this.specialtyRegistry.tryGetManifest(specialty);
|
|
497
499
|
if (!manifest) {
|
|
498
500
|
const defaultName = resolveDefaultSpecialtyName();
|
|
499
|
-
if (
|
|
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
|
|
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
|
+
});
|
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
|
@@ -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
|
+
}
|