@frockbot/plugin-subagents 0.0.0 → 0.1.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/backend.ts ADDED
@@ -0,0 +1,193 @@
1
+ // The Subagents gateway Contribution: the authenticated HTTP surface.
2
+ //
3
+ // Three routes:
4
+ //
5
+ // GET /api/bots/:botId/tasks the Bot's task list
6
+ // GET /api/bots/:botId/tasks/:taskId one task
7
+ // POST /api/bots/:botId/tasks/:taskId/stop explicit, authenticated cancel
8
+ //
9
+ // The gateway owns none of this state. It carries the request to the *parent*
10
+ // Bot Durable Object — the authority, per ADR 0017 — which proves directory
11
+ // membership before it answers, so a Bot that is not this User's is a 404 here
12
+ // for the same reason it is one on `/api/bots/:id/settings`, and never because
13
+ // this module checked.
14
+ //
15
+ // A child Session is never reachable from here. The Subagent Durable Object is
16
+ // an execution host with no route of its own, and a task's summary reaches the
17
+ // User through this list and through the parent's transcript — never as a
18
+ // second conversation.
19
+ import type { Plugin } from "cordis";
20
+ import { isTaskIdV1, SubagentDecodeError } from "./records.js";
21
+ import {
22
+ decodeTaskListViewV1,
23
+ decodeTaskViewV1,
24
+ type TaskListViewV1,
25
+ type TaskViewV1,
26
+ } from "./shared.js";
27
+
28
+ export interface SubagentsGatewayHost {
29
+ listTasks(userId: string, botId: string): Promise<TaskListViewV1>;
30
+ readTask(userId: string, botId: string, taskId: string): Promise<TaskViewV1>;
31
+ /**
32
+ * The User's own cancellation. It is the same durable act the Bot's
33
+ * `task_stop` performs, through a second authenticated door — never a second
34
+ * mechanism, so a task cannot be terminal on one path and live on the other.
35
+ */
36
+ stopTask(userId: string, botId: string, taskId: string): Promise<TaskViewV1>;
37
+ }
38
+
39
+ export interface SubagentsBackendRouteContribution {
40
+ packageId: string;
41
+ route(
42
+ request: Request,
43
+ url: URL,
44
+ context: { userId?: string; client: "browser" | "desktop" },
45
+ ): Promise<Response | undefined>;
46
+ }
47
+
48
+ const TASKS = /^\/api\/bots\/([^/]+)\/tasks$/;
49
+ const TASK = /^\/api\/bots\/([^/]+)\/tasks\/([^/]+)$/;
50
+ const TASK_STOP = /^\/api\/bots\/([^/]+)\/tasks\/([^/]+)\/stop$/;
51
+
52
+ /**
53
+ * A Bot id may not carry `#`, and this is one of the two doors that is why.
54
+ *
55
+ * The Subagent Durable Object is `<userId>:<botId>#task:<taskId>` in the same
56
+ * namespace as the Bot's own object (ADR 0017), so a `#` smuggled through a
57
+ * path segment would let a caller name an object the directory never minted.
58
+ * `PUBLIC_IDENTIFIER_PATTERN` excludes `#`; this route restates the check at
59
+ * its own door rather than trusting the next one.
60
+ */
61
+ const BOT_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
62
+
63
+ function jsonError(status: number, message: string): Response {
64
+ return Response.json({ error: message }, { status });
65
+ }
66
+
67
+ function pathSegment(value: string): string {
68
+ let decoded: string;
69
+ try {
70
+ decoded = decodeURIComponent(value);
71
+ } catch {
72
+ throw new SubagentDecodeError("request path is invalid");
73
+ }
74
+ if (!BOT_ID.test(decoded)) {
75
+ throw new SubagentDecodeError("invalid bot id");
76
+ }
77
+ return decoded;
78
+ }
79
+
80
+ /**
81
+ * A task id from a path segment. Restated here for the same reason the Bot id
82
+ * is: a task id becomes part of a Durable Object name (ADR 0017), so the door
83
+ * it arrives at is the door that checks it.
84
+ */
85
+ function taskSegment(value: string): string {
86
+ let decoded: string;
87
+ try {
88
+ decoded = decodeURIComponent(value);
89
+ } catch {
90
+ throw new SubagentDecodeError("request path is invalid");
91
+ }
92
+ if (!isTaskIdV1(decoded)) {
93
+ throw new SubagentDecodeError("invalid task id");
94
+ }
95
+ return decoded;
96
+ }
97
+
98
+ /** A Bot the caller does not own, and one that does not exist, are one answer. */
99
+ function isMissingBot(error: unknown): boolean {
100
+ return (
101
+ typeof error === "object" &&
102
+ error !== null &&
103
+ "name" in error &&
104
+ (error.name === "BotNotFoundError" || error.name === "TaskNotFoundError")
105
+ );
106
+ }
107
+
108
+ function errorResponse(error: unknown): Response {
109
+ if (isMissingBot(error)) {
110
+ return jsonError(404, error instanceof Error ? error.message : "not found");
111
+ }
112
+ if (
113
+ error instanceof SubagentDecodeError ||
114
+ (typeof error === "object" &&
115
+ error !== null &&
116
+ "name" in error &&
117
+ error.name === "SubagentDecodeError")
118
+ ) {
119
+ return jsonError(
120
+ 400,
121
+ error instanceof Error ? error.message : "task request is invalid",
122
+ );
123
+ }
124
+ return jsonError(
125
+ 500,
126
+ error instanceof Error ? error.message : "task request failed",
127
+ );
128
+ }
129
+
130
+ export function createSubagentsBackendContribution(
131
+ host: SubagentsGatewayHost,
132
+ ): SubagentsBackendRouteContribution {
133
+ return {
134
+ packageId: "subagents",
135
+ async route(request, url, context) {
136
+ const tasks = TASKS.exec(url.pathname);
137
+ const task = TASK.exec(url.pathname);
138
+ const stop = TASK_STOP.exec(url.pathname);
139
+ if (!tasks && !task && !stop) return undefined;
140
+ if (!context.userId) return undefined;
141
+ if ([...url.searchParams.keys()].length > 0) {
142
+ return jsonError(400, "task routes take no query parameters");
143
+ }
144
+ const userId = context.userId;
145
+ try {
146
+ if (stop) {
147
+ if (request.method !== "POST") {
148
+ return jsonError(405, "method not allowed");
149
+ }
150
+ return Response.json(
151
+ decodeTaskViewV1(
152
+ await host.stopTask(
153
+ userId,
154
+ pathSegment(stop[1]!),
155
+ taskSegment(stop[2]!),
156
+ ),
157
+ ),
158
+ );
159
+ }
160
+ if (request.method !== "GET") {
161
+ return jsonError(405, "method not allowed");
162
+ }
163
+ if (task) {
164
+ return Response.json(
165
+ decodeTaskViewV1(
166
+ await host.readTask(
167
+ userId,
168
+ pathSegment(task[1]!),
169
+ taskSegment(task[2]!),
170
+ ),
171
+ ),
172
+ );
173
+ }
174
+ return Response.json(
175
+ decodeTaskListViewV1(
176
+ await host.listTasks(userId, pathSegment(tasks![1]!)),
177
+ ),
178
+ );
179
+ } catch (error) {
180
+ return errorResponse(error);
181
+ }
182
+ },
183
+ };
184
+ }
185
+
186
+ export namespace createSubagentsBackendContribution {
187
+ export function plugin(
188
+ host: SubagentsGatewayHost,
189
+ lifecycle: { mount(value: SubagentsBackendRouteContribution): () => void },
190
+ ): Plugin {
191
+ return () => lifecycle.mount(createSubagentsBackendContribution(host));
192
+ }
193
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./models.js";
2
+ export * from "./quota.js";
3
+ export * from "./records.js";
4
+ export * from "./roles.js";
5
+ export * from "./shared.js";
6
+ export * from "./storage-keys.js";
7
+ export * from "./store.js";
8
+ export * from "./testing.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
@@ -0,0 +1,175 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ renderAvailableSubagentModelsPromptV1,
4
+ resolveSubagentModelV1,
5
+ subagentModelCatalogV1,
6
+ subagentModelSlugV1,
7
+ } from "./models.js";
8
+ import type { TaskModelBindingV1 } from "./records.js";
9
+
10
+ function assignment(
11
+ packageId: string,
12
+ providerModelId: string,
13
+ assignmentId = `asg-${providerModelId}`,
14
+ ): TaskModelBindingV1 {
15
+ return {
16
+ assignmentId,
17
+ packageId,
18
+ capabilityId: `${packageId}-models`,
19
+ connectionId: `conn-${assignmentId}`,
20
+ provider: packageId,
21
+ providerModelId,
22
+ };
23
+ }
24
+
25
+ const DEFAULT = assignment("provider-ollama-cloud", "glm-5.3-flash:cloud");
26
+ const SECOND = assignment("provider-foundation", "sand-automation");
27
+
28
+ describe("the slug", () => {
29
+ test("is the Package and the provider model, and nothing else", () => {
30
+ expect(subagentModelSlugV1(DEFAULT)).toBe(
31
+ "provider-ollama-cloud/glm-5.3-flash:cloud",
32
+ );
33
+ });
34
+ });
35
+
36
+ describe("the catalog one Turn is offered", () => {
37
+ test("is built from the Bot's enabled model Assignments, default first", () => {
38
+ const catalog = subagentModelCatalogV1({
39
+ assignments: [SECOND, DEFAULT],
40
+ defaultBinding: DEFAULT,
41
+ turnType: "chat",
42
+ });
43
+ expect(catalog.map((option) => option.slug)).toEqual([
44
+ "provider-ollama-cloud/glm-5.3-flash:cloud",
45
+ "provider-foundation/sand-automation",
46
+ ]);
47
+ expect(catalog[0]?.isDefault).toBe(true);
48
+ expect(catalog[1]?.isDefault).toBe(false);
49
+ });
50
+
51
+ test("keeps one entry per slug when two Assignments name one model", () => {
52
+ const twin = assignment(
53
+ "provider-ollama-cloud",
54
+ "glm-5.3-flash:cloud",
55
+ "asg-twin",
56
+ );
57
+ expect(
58
+ subagentModelCatalogV1({
59
+ assignments: [DEFAULT, twin],
60
+ defaultBinding: DEFAULT,
61
+ turnType: "chat",
62
+ }),
63
+ ).toHaveLength(1);
64
+ });
65
+
66
+ test("renders exactly one slug on an automation turn — the Bot's own binding", () => {
67
+ for (const turnType of ["automation", "subagent"] as const) {
68
+ const catalog = subagentModelCatalogV1({
69
+ assignments: [SECOND, DEFAULT],
70
+ defaultBinding: DEFAULT,
71
+ turnType,
72
+ });
73
+ expect(catalog.map((option) => option.slug)).toEqual([
74
+ "provider-ollama-cloud/glm-5.3-flash:cloud",
75
+ ]);
76
+ }
77
+ });
78
+
79
+ test("is empty for a Bot with no enabled model Assignment", () => {
80
+ expect(
81
+ subagentModelCatalogV1({ assignments: [], turnType: "chat" }),
82
+ ).toEqual([]);
83
+ });
84
+ });
85
+
86
+ describe("resolving a `Task` model against the catalog", () => {
87
+ const catalog = subagentModelCatalogV1({
88
+ assignments: [SECOND, DEFAULT],
89
+ defaultBinding: DEFAULT,
90
+ turnType: "chat",
91
+ });
92
+
93
+ test("an omitted model inherits the parent's binding", () => {
94
+ const resolved = resolveSubagentModelV1(catalog, undefined);
95
+ expect(resolved).toMatchObject({
96
+ status: "resolved",
97
+ model: { slug: "provider-ollama-cloud/glm-5.3-flash:cloud" },
98
+ });
99
+ });
100
+
101
+ test("a named slug resolves to the Assignment that carries it", () => {
102
+ const resolved = resolveSubagentModelV1(
103
+ catalog,
104
+ "provider-foundation/sand-automation",
105
+ );
106
+ expect(resolved).toMatchObject({
107
+ status: "resolved",
108
+ model: { binding: { assignmentId: SECOND.assignmentId } },
109
+ });
110
+ });
111
+
112
+ test("a slug the Turn was not offered is refused, with the list it was", () => {
113
+ const resolved = resolveSubagentModelV1(catalog, "anthropic/opus");
114
+ expect(resolved).toMatchObject({ status: "refused" });
115
+ expect(resolved.status === "refused" && resolved.reason).toContain(
116
+ "provider-ollama-cloud/glm-5.3-flash:cloud",
117
+ );
118
+ });
119
+
120
+ test("an automation turn refuses the second slug it was not shown", () => {
121
+ const narrowed = subagentModelCatalogV1({
122
+ assignments: [SECOND, DEFAULT],
123
+ defaultBinding: DEFAULT,
124
+ turnType: "automation",
125
+ });
126
+ expect(
127
+ resolveSubagentModelV1(narrowed, "provider-foundation/sand-automation"),
128
+ ).toMatchObject({ status: "refused" });
129
+ });
130
+
131
+ test("a Bot with no model Assignment is refused rather than defaulted", () => {
132
+ expect(resolveSubagentModelV1([], undefined)).toMatchObject({
133
+ status: "refused",
134
+ });
135
+ expect(resolveSubagentModelV1([], "anything/at-all")).toMatchObject({
136
+ status: "refused",
137
+ });
138
+ });
139
+ });
140
+
141
+ describe("<available_subagent_models>", () => {
142
+ test("renders one element per slug and marks the default", () => {
143
+ const rendered = renderAvailableSubagentModelsPromptV1(
144
+ subagentModelCatalogV1({
145
+ assignments: [SECOND, DEFAULT],
146
+ defaultBinding: DEFAULT,
147
+ turnType: "chat",
148
+ }),
149
+ );
150
+ expect(rendered).toContain("<available_subagent_models>");
151
+ expect(rendered).toContain(
152
+ '<model slug="provider-ollama-cloud/glm-5.3-flash:cloud" provider="provider-ollama-cloud" default="true" />',
153
+ );
154
+ expect(rendered).toContain(
155
+ '<model slug="provider-foundation/sand-automation" provider="provider-foundation" />',
156
+ );
157
+ });
158
+
159
+ test("renders exactly one model line on an automation turn", () => {
160
+ const rendered = renderAvailableSubagentModelsPromptV1(
161
+ subagentModelCatalogV1({
162
+ assignments: [SECOND, DEFAULT],
163
+ defaultBinding: DEFAULT,
164
+ turnType: "automation",
165
+ }),
166
+ );
167
+ expect(
168
+ rendered.split("\n").filter((line) => line.includes("<model ")),
169
+ ).toHaveLength(1);
170
+ });
171
+
172
+ test("renders nothing at all for an empty catalog", () => {
173
+ expect(renderAvailableSubagentModelsPromptV1([])).toBe("");
174
+ });
175
+ });
package/src/models.ts ADDED
@@ -0,0 +1,185 @@
1
+ // Per-turn subagent model slugs.
2
+ //
3
+ // GrokBot injects `<available_subagent_models>` into the system prompt of every
4
+ // turn that may dispatch one, and `createTaskTool` reads the slug back out of
5
+ // the `model` argument (`docs/research/grokbot-computer.md` l.472–474). Two
6
+ // rules come with it: an automation turn holds exactly one slug — the Bot's own
7
+ // default binding, GrokBot's `sand-automation` — and an omitted `model`
8
+ // inherits the parent's binding rather than picking anything.
9
+ //
10
+ // A slug is `<packageId>/<providerModelId>`. It names an *Assignment*, not a
11
+ // provider: resolution runs against the Bot's enabled model Assignments, so a
12
+ // slug the Bot invents resolves to nothing and the dispatch is refused.
13
+
14
+ import type { TurnTypeV1 } from "@frockbot/kernel-contracts";
15
+ import {
16
+ decodeTaskModelBindingV1,
17
+ SubagentDecodeError,
18
+ type TaskModelBindingV1,
19
+ type TaskModelV1,
20
+ } from "./records.js";
21
+
22
+ /** Most slugs the prompt section will ever render. A catalog, not a directory. */
23
+ export const SUBAGENT_MODEL_CATALOG_LIMIT_V1 = 32;
24
+
25
+ /** One offerable model, projected from one enabled model Assignment. */
26
+ export interface SubagentModelOptionV1 {
27
+ slug: string;
28
+ binding: TaskModelBindingV1;
29
+ /** True for the Bot's own durable binding: what an omitted `model` inherits. */
30
+ isDefault: boolean;
31
+ }
32
+
33
+ export function subagentModelSlugV1(binding: {
34
+ packageId: string;
35
+ providerModelId: string;
36
+ }): string {
37
+ return `${binding.packageId}/${binding.providerModelId}`;
38
+ }
39
+
40
+ /**
41
+ * The turn types that may see more than one slug.
42
+ *
43
+ * An `automation` or `subagent` turn renders exactly one — the Bot's default
44
+ * binding. It is not a permission (the Assignments are the same either way); it
45
+ * is that an unattended Turn choosing a model per task is a decision with
46
+ * nobody to answer for it.
47
+ */
48
+ export function subagentModelsAreNarrowedV1(turnType: TurnTypeV1): boolean {
49
+ return turnType === "automation" || turnType === "subagent";
50
+ }
51
+
52
+ /**
53
+ * The catalog one Turn is offered.
54
+ *
55
+ * `assignments` are the Bot's enabled model Assignments, already resolved by
56
+ * the Shell; `defaultBinding` is the Bot's own durable binding. A duplicate
57
+ * slug is kept once — two Assignments naming one provider model are one choice.
58
+ */
59
+ export function subagentModelCatalogV1(input: {
60
+ assignments: readonly TaskModelBindingV1[];
61
+ defaultBinding?: TaskModelBindingV1;
62
+ turnType: TurnTypeV1;
63
+ }): SubagentModelOptionV1[] {
64
+ const defaultSlug = input.defaultBinding
65
+ ? subagentModelSlugV1(input.defaultBinding)
66
+ : undefined;
67
+ const seen = new Set<string>();
68
+ const options: SubagentModelOptionV1[] = [];
69
+ const consider = (binding: TaskModelBindingV1) => {
70
+ const slug = subagentModelSlugV1(binding);
71
+ if (seen.has(slug)) return;
72
+ if (options.length >= SUBAGENT_MODEL_CATALOG_LIMIT_V1) return;
73
+ seen.add(slug);
74
+ options.push({ slug, binding, isDefault: slug === defaultSlug });
75
+ };
76
+ // The default first, so the one slug a narrowed turn renders is always the
77
+ // Bot's own binding and never whichever Assignment happens to sort first.
78
+ if (input.defaultBinding) consider(input.defaultBinding);
79
+ if (!subagentModelsAreNarrowedV1(input.turnType)) {
80
+ for (const assignment of input.assignments) consider(assignment);
81
+ }
82
+ return subagentModelsAreNarrowedV1(input.turnType)
83
+ ? options.slice(0, 1)
84
+ : options;
85
+ }
86
+
87
+ export type SubagentModelResolutionV1 =
88
+ | { status: "resolved"; model: TaskModelV1 }
89
+ | { status: "refused"; reason: string };
90
+
91
+ /**
92
+ * The model one dispatch pins.
93
+ *
94
+ * An omitted slug inherits the parent's binding. A named slug must be in the
95
+ * catalog this Turn was offered — a Bot that names a model it was not shown is
96
+ * refused with the list it *was* shown, which is the honest answer and also the
97
+ * one that repairs the next attempt.
98
+ */
99
+ export function resolveSubagentModelV1(
100
+ catalog: readonly SubagentModelOptionV1[],
101
+ requested: string | undefined,
102
+ ): SubagentModelResolutionV1 {
103
+ if (requested === undefined) {
104
+ const inherited = catalog.find((option) => option.isDefault) ?? catalog[0];
105
+ if (!inherited) {
106
+ return {
107
+ status: "refused",
108
+ reason:
109
+ "this Bot has no enabled model Assignment, so a subagent has no model to run on",
110
+ };
111
+ }
112
+ return {
113
+ status: "resolved",
114
+ model: { binding: inherited.binding, slug: inherited.slug },
115
+ };
116
+ }
117
+ const match = catalog.find((option) => option.slug === requested);
118
+ if (!match) {
119
+ const offered = catalog.map((option) => option.slug).join(", ");
120
+ return {
121
+ status: "refused",
122
+ reason:
123
+ offered.length > 0
124
+ ? `model "${requested}" is not one of this Turn's subagent models (${offered})`
125
+ : `model "${requested}" is not available: this Bot has no enabled model Assignment`,
126
+ };
127
+ }
128
+ return {
129
+ status: "resolved",
130
+ model: { binding: match.binding, slug: match.slug },
131
+ };
132
+ }
133
+
134
+ function escapeAttribute(value: string): string {
135
+ return value
136
+ .replace(/&/g, "&amp;")
137
+ .replace(/</g, "&lt;")
138
+ .replace(/>/g, "&gt;")
139
+ .replace(/"/g, "&quot;");
140
+ }
141
+
142
+ /**
143
+ * The `<available_subagent_models>` block, in the shape
144
+ * `renderSkillCatalogPromptV1` established: an element per entry, then the
145
+ * sentences that say what the list is and what reading it does not authorize.
146
+ * An empty catalog renders nothing at all rather than an empty element.
147
+ */
148
+ export function renderAvailableSubagentModelsPromptV1(
149
+ catalog: readonly SubagentModelOptionV1[],
150
+ ): string {
151
+ if (catalog.length === 0) return "";
152
+ const entries = catalog.map((option) => {
153
+ const attributes = [
154
+ `slug="${escapeAttribute(option.slug)}"`,
155
+ `provider="${escapeAttribute(option.binding.provider)}"`,
156
+ ...(option.isDefault ? ['default="true"'] : []),
157
+ ].join(" ");
158
+ return ` <model ${attributes} />`;
159
+ });
160
+ return [
161
+ "<available_subagent_models>",
162
+ ...entries,
163
+ "</available_subagent_models>",
164
+ "These are the models a subagent you dispatch may run on. Pass one slug as the Task tool's `model`.",
165
+ "Omit `model` and the subagent inherits the model you are running on.",
166
+ ].join("\n");
167
+ }
168
+
169
+ /**
170
+ * Decodes a model binding that crossed the Shell seam. The Shell resolves it
171
+ * from the Bot's own configuration and the User's Connection; this Package
172
+ * accepts nothing else, and never anything a Bot supplied.
173
+ */
174
+ export function decodeSubagentModelBindingV1(
175
+ value: unknown,
176
+ label = "subagent model binding",
177
+ ): TaskModelBindingV1 {
178
+ try {
179
+ return decodeTaskModelBindingV1(value, label);
180
+ } catch (error) {
181
+ throw error instanceof SubagentDecodeError
182
+ ? error
183
+ : new SubagentDecodeError(`${label} is invalid`);
184
+ }
185
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeSubagentSlotReceiptV1,
4
+ releaseSubagentSlotV1,
5
+ reserveSubagentSlotV1,
6
+ SUBAGENT_SLOT_LIMIT_V1,
7
+ } from "./quota.js";
8
+ import { createMemorySubagentStorageV1 } from "./testing.js";
9
+
10
+ function request(botId: string, taskId: string) {
11
+ return {
12
+ schemaVersion: 1 as const,
13
+ userId: "user",
14
+ botId,
15
+ taskId,
16
+ reservedAt: "2026-09-01T00:00:00.000Z",
17
+ };
18
+ }
19
+
20
+ describe("the per-User concurrent-subagent bound", () => {
21
+ test("admits up to the bound and refuses the one past it", async () => {
22
+ const storage = createMemorySubagentStorageV1();
23
+ for (let index = 0; index < SUBAGENT_SLOT_LIMIT_V1; index += 1) {
24
+ expect(
25
+ await reserveSubagentSlotV1(storage, request("bot", `tk-${index}`)),
26
+ ).toMatchObject({ status: "reserved" });
27
+ }
28
+ const refused = await reserveSubagentSlotV1(
29
+ storage,
30
+ request("bot", "tk-past"),
31
+ );
32
+ expect(refused).toMatchObject({ status: "refused", held: 8, limit: 8 });
33
+ expect(refused.status === "refused" && refused.reason).toContain(
34
+ "the bound is 8",
35
+ );
36
+ });
37
+
38
+ test("counts across a User's Bots, which is the whole reason it lives here", async () => {
39
+ const storage = createMemorySubagentStorageV1();
40
+ for (let index = 0; index < 4; index += 1) {
41
+ await reserveSubagentSlotV1(storage, request("bot-a", `tk-${index}`));
42
+ }
43
+ for (let index = 0; index < 4; index += 1) {
44
+ await reserveSubagentSlotV1(storage, request("bot-b", `tk-${index}`));
45
+ }
46
+ expect(
47
+ await reserveSubagentSlotV1(storage, request("bot-c", "tk-0")),
48
+ ).toMatchObject({ status: "refused" });
49
+ });
50
+
51
+ test("is idempotent per task: a resumed dispatch takes no second slot", async () => {
52
+ const storage = createMemorySubagentStorageV1();
53
+ await reserveSubagentSlotV1(storage, request("bot", "tk-1"));
54
+ const again = await reserveSubagentSlotV1(storage, request("bot", "tk-1"));
55
+ expect(again).toMatchObject({ status: "reserved", held: 1 });
56
+ });
57
+
58
+ test("a release gives the slot back, and releasing twice is a no-op", async () => {
59
+ const storage = createMemorySubagentStorageV1();
60
+ await reserveSubagentSlotV1(storage, request("bot", "tk-1"));
61
+ expect(
62
+ await releaseSubagentSlotV1(storage, { botId: "bot", taskId: "tk-1" }),
63
+ ).toMatchObject({ held: 0 });
64
+ expect(
65
+ await releaseSubagentSlotV1(storage, { botId: "bot", taskId: "tk-1" }),
66
+ ).toMatchObject({ held: 0 });
67
+ });
68
+ });
69
+
70
+ describe("the receipt that crosses the Durable Object seam", () => {
71
+ test("decodes both statuses and refuses anything else", async () => {
72
+ const storage = createMemorySubagentStorageV1();
73
+ const reserved = await reserveSubagentSlotV1(
74
+ storage,
75
+ request("bot", "tk-1"),
76
+ );
77
+ expect(decodeSubagentSlotReceiptV1(reserved)).toEqual(reserved);
78
+ expect(() =>
79
+ decodeSubagentSlotReceiptV1({ ...reserved, status: "maybe" }),
80
+ ).toThrow(/status is invalid/);
81
+ });
82
+ });