@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/frockbot.json +33 -0
- package/package.json +38 -6
- package/src/agent.test.ts +480 -0
- package/src/agent.ts +1078 -0
- package/src/backend.ts +193 -0
- package/src/index.ts +8 -0
- package/src/manifest.ts +3 -0
- package/src/models.test.ts +175 -0
- package/src/models.ts +185 -0
- package/src/quota.test.ts +82 -0
- package/src/quota.ts +222 -0
- package/src/records.test.ts +245 -0
- package/src/records.ts +649 -0
- package/src/roles.test.ts +60 -0
- package/src/roles.ts +76 -0
- package/src/shared.ts +232 -0
- package/src/storage-keys.ts +148 -0
- package/src/store.test.ts +500 -0
- package/src/store.ts +695 -0
- package/src/testing.ts +58 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/frockbot.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 4,
|
|
3
|
+
"id": "subagents",
|
|
4
|
+
"displayName": "Subagents",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"compatibility": { "frockbot": ">=0.0.1" },
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"shell": ">=0.0.1"
|
|
9
|
+
},
|
|
10
|
+
"contributions": {
|
|
11
|
+
"backend": [{ "entry": "./backend", "host": "gateway" }],
|
|
12
|
+
"runtime": { "entry": "./agent" }
|
|
13
|
+
},
|
|
14
|
+
"configuration": {
|
|
15
|
+
"settings": [],
|
|
16
|
+
"connectionTypes": [],
|
|
17
|
+
"capabilities": [
|
|
18
|
+
{
|
|
19
|
+
"id": "task-dispatch",
|
|
20
|
+
"kind": "tool",
|
|
21
|
+
"connectionTypes": [],
|
|
22
|
+
"admission": { "turnTypes": ["chat", "automation"] }
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "task-lifecycle",
|
|
26
|
+
"kind": "tool",
|
|
27
|
+
"connectionTypes": [],
|
|
28
|
+
"admission": { "turnTypes": ["chat", "automation"] }
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"permissions": ["subagents:dispatch"]
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-subagents",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./agent": "./src/agent.ts",
|
|
9
|
+
"./backend": "./src/backend.ts",
|
|
10
|
+
"./frockbot.json": "./frockbot.json",
|
|
11
|
+
"./manifest": "./src/manifest.ts",
|
|
12
|
+
"./models": "./src/models.ts",
|
|
13
|
+
"./package.json": "./package.json",
|
|
14
|
+
"./quota": "./src/quota.ts",
|
|
15
|
+
"./records": "./src/records.ts",
|
|
16
|
+
"./roles": "./src/roles.ts",
|
|
17
|
+
"./shared": "./src/shared.ts",
|
|
18
|
+
"./storage-keys": "./src/storage-keys.ts",
|
|
19
|
+
"./store": "./src/store.ts",
|
|
20
|
+
"./testing": "./src/testing.ts"
|
|
21
|
+
},
|
|
22
|
+
"frockbot": {
|
|
23
|
+
"manifest": "./frockbot.json"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "bun test src",
|
|
27
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@frockbot/kernel-agent-loop": "0.1.1",
|
|
31
|
+
"@frockbot/kernel-contracts": "0.1.1",
|
|
32
|
+
"cordis": "4.0.0-rc.8"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "1.4.0",
|
|
36
|
+
"typescript": "5.9.3"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
6
41
|
"repository": {
|
|
7
42
|
"type": "git",
|
|
8
43
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
44
|
"directory": "packages/plugin-subagents"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
45
|
}
|
|
14
46
|
}
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createSubagentModelsPromptSectionV1,
|
|
4
|
+
foldPendingTaskMessagesV1,
|
|
5
|
+
createTaskCheckTool,
|
|
6
|
+
createTaskMessageTool,
|
|
7
|
+
createTaskResumeTool,
|
|
8
|
+
createTaskStopTool,
|
|
9
|
+
createTaskTool,
|
|
10
|
+
decodeTaskToolInputV1,
|
|
11
|
+
subagentsAdmissionCeilingV1,
|
|
12
|
+
TASK_DISPATCH_CAPABILITY_V1,
|
|
13
|
+
TASK_LIFECYCLE_CAPABILITY_V1,
|
|
14
|
+
type SubagentDispatchOutcomeV1,
|
|
15
|
+
type SubagentDispatchRequestV1,
|
|
16
|
+
} from "./agent.js";
|
|
17
|
+
import { subagentModelCatalogV1 } from "./models.js";
|
|
18
|
+
import {
|
|
19
|
+
TASK_MESSAGE_MAX_V1,
|
|
20
|
+
TASK_PROMPT_MAX_BYTES_V1,
|
|
21
|
+
type TaskModelBindingV1,
|
|
22
|
+
} from "./records.js";
|
|
23
|
+
import type { ToolExecutionContext } from "@frockbot/kernel-contracts";
|
|
24
|
+
|
|
25
|
+
const BINDING: TaskModelBindingV1 = {
|
|
26
|
+
assignmentId: "asg-1",
|
|
27
|
+
packageId: "provider-ollama-cloud",
|
|
28
|
+
capabilityId: "ollama-cloud-models",
|
|
29
|
+
connectionId: "conn-1",
|
|
30
|
+
provider: "ollama-cloud",
|
|
31
|
+
providerModelId: "glm-5.3-flash:cloud",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function context(): ToolExecutionContext {
|
|
35
|
+
return {
|
|
36
|
+
botId: "bot",
|
|
37
|
+
agentId: "agent",
|
|
38
|
+
sessionId: "user:bot",
|
|
39
|
+
compositionGenerationId: "gen-1",
|
|
40
|
+
effectId: "effect-1",
|
|
41
|
+
turnType: "chat",
|
|
42
|
+
signal: new AbortController().signal,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tool(
|
|
47
|
+
dispatch: (
|
|
48
|
+
request: SubagentDispatchRequestV1,
|
|
49
|
+
) => Promise<SubagentDispatchOutcomeV1>,
|
|
50
|
+
turnType: "chat" | "automation" = "chat",
|
|
51
|
+
) {
|
|
52
|
+
return createTaskTool({
|
|
53
|
+
botId: "bot",
|
|
54
|
+
writer: { sessionId: "user:bot", turnId: "run-1", runId: "run-1" },
|
|
55
|
+
turnType,
|
|
56
|
+
models: () =>
|
|
57
|
+
subagentModelCatalogV1({
|
|
58
|
+
assignments: [BINDING],
|
|
59
|
+
defaultBinding: BINDING,
|
|
60
|
+
turnType,
|
|
61
|
+
}),
|
|
62
|
+
dispatch,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("the Task tool's declaration", () => {
|
|
67
|
+
test("is admitted on chat and automation turns, so depth is one", () => {
|
|
68
|
+
expect(tool(() => Promise.reject(new Error("unused"))).admission).toEqual({
|
|
69
|
+
turnTypes: ["chat", "automation"],
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("is bounded by the manifest ceiling it is contributed under", () => {
|
|
74
|
+
expect(subagentsAdmissionCeilingV1(TASK_DISPATCH_CAPABILITY_V1)).toEqual([
|
|
75
|
+
"chat",
|
|
76
|
+
"automation",
|
|
77
|
+
]);
|
|
78
|
+
expect(subagentsAdmissionCeilingV1("no-such-capability")).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("decoding one Task call", () => {
|
|
83
|
+
test("defaults the type to executor and background to true", () => {
|
|
84
|
+
expect(
|
|
85
|
+
decodeTaskToolInputV1({ description: "Look it up", prompt: "Do it." }),
|
|
86
|
+
).toMatchObject({ type: "executor", background: true, attachments: [] });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("refuses an unknown field rather than ignoring it", () => {
|
|
90
|
+
expect(() =>
|
|
91
|
+
decodeTaskToolInputV1({
|
|
92
|
+
description: "d",
|
|
93
|
+
prompt: "p",
|
|
94
|
+
parentSession: "user:bot",
|
|
95
|
+
}),
|
|
96
|
+
).toThrow(/unknown field "parentSession"/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("refuses an unknown subagent type", () => {
|
|
100
|
+
expect(() =>
|
|
101
|
+
decodeTaskToolInputV1({ description: "d", prompt: "p", type: "wizard" }),
|
|
102
|
+
).toThrow(/type must be one of/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("refuses a prompt past the byte bound", () => {
|
|
106
|
+
expect(() =>
|
|
107
|
+
decodeTaskToolInputV1({
|
|
108
|
+
description: "d",
|
|
109
|
+
prompt: "a".repeat(TASK_PROMPT_MAX_BYTES_V1 + 1),
|
|
110
|
+
}),
|
|
111
|
+
).toThrow(/at most 32768/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("refuses a fifth attachment", () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
decodeTaskToolInputV1({
|
|
117
|
+
description: "d",
|
|
118
|
+
prompt: "p",
|
|
119
|
+
attachments: ["a", "b", "c", "d", "e"],
|
|
120
|
+
}),
|
|
121
|
+
).toThrow(/at most 4 attachments/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("refuses watchVideo with nothing to watch", () => {
|
|
125
|
+
expect(() =>
|
|
126
|
+
decodeTaskToolInputV1({
|
|
127
|
+
description: "d",
|
|
128
|
+
prompt: "p",
|
|
129
|
+
type: "watchVideo",
|
|
130
|
+
}),
|
|
131
|
+
).toThrow(/needs at least one attachment/);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("executing one Task call", () => {
|
|
136
|
+
test("dispatches on the inherited binding when no model is named", async () => {
|
|
137
|
+
let seen: SubagentDispatchRequestV1 | undefined;
|
|
138
|
+
const result = await tool(async (request) => {
|
|
139
|
+
seen = request;
|
|
140
|
+
return {
|
|
141
|
+
status: "dispatched",
|
|
142
|
+
taskId: "tk-1",
|
|
143
|
+
model: request.model.slug,
|
|
144
|
+
};
|
|
145
|
+
}).execute(
|
|
146
|
+
{ description: "Summarise", prompt: "Summarise the changelog." },
|
|
147
|
+
context(),
|
|
148
|
+
);
|
|
149
|
+
expect(seen?.model.binding).toEqual(BINDING);
|
|
150
|
+
expect(result.isError).toBe(false);
|
|
151
|
+
expect(result.content).toContain("tk-1");
|
|
152
|
+
expect(result.content).toContain("do not poll");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("refuses a model slug this Turn was not offered, without dispatching", async () => {
|
|
156
|
+
let dispatched = false;
|
|
157
|
+
const result = await tool(async () => {
|
|
158
|
+
dispatched = true;
|
|
159
|
+
return { status: "dispatched", taskId: "tk-1", model: "x" };
|
|
160
|
+
}).execute(
|
|
161
|
+
{ description: "d", prompt: "p", model: "anthropic/opus" },
|
|
162
|
+
context(),
|
|
163
|
+
);
|
|
164
|
+
expect(dispatched).toBe(false);
|
|
165
|
+
expect(result).toMatchObject({ isError: true });
|
|
166
|
+
expect(result.content).toContain(
|
|
167
|
+
"is not one of this Turn's subagent models",
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("carries the authority's bound refusal back to the model verbatim", async () => {
|
|
172
|
+
const result = await tool(async () => ({
|
|
173
|
+
status: "refused",
|
|
174
|
+
reason:
|
|
175
|
+
"this Bot already has 4 subagents running; the bound is 4. Wait for one to finish.",
|
|
176
|
+
})).execute({ description: "d", prompt: "p" }, context());
|
|
177
|
+
expect(result).toMatchObject({ isError: true });
|
|
178
|
+
expect(result.content).toContain("the bound is 4");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("refuses invalid input before it reaches the authority", async () => {
|
|
182
|
+
let dispatched = false;
|
|
183
|
+
const result = await tool(async () => {
|
|
184
|
+
dispatched = true;
|
|
185
|
+
return { status: "dispatched", taskId: "tk-1", model: "x" };
|
|
186
|
+
}).execute({ description: "d" }, context());
|
|
187
|
+
expect(dispatched).toBe(false);
|
|
188
|
+
expect(result).toMatchObject({ isError: true });
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("the <available_subagent_models> section", () => {
|
|
193
|
+
test("renders the catalog the host offers", async () => {
|
|
194
|
+
const section = createSubagentModelsPromptSectionV1({
|
|
195
|
+
models: () =>
|
|
196
|
+
subagentModelCatalogV1({
|
|
197
|
+
assignments: [BINDING],
|
|
198
|
+
defaultBinding: BINDING,
|
|
199
|
+
turnType: "chat",
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
const rendered = await section.render({
|
|
203
|
+
sessionId: "user:bot",
|
|
204
|
+
provider: "ollama-cloud",
|
|
205
|
+
model: "glm-5.3-flash:cloud",
|
|
206
|
+
turnType: "chat",
|
|
207
|
+
});
|
|
208
|
+
expect(rendered).toContain(
|
|
209
|
+
'slug="provider-ollama-cloud/glm-5.3-flash:cloud"',
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("renders nothing for a Bot with no enabled model Assignment", async () => {
|
|
214
|
+
const section = createSubagentModelsPromptSectionV1({ models: () => [] });
|
|
215
|
+
expect(
|
|
216
|
+
await section.render({
|
|
217
|
+
sessionId: "user:bot",
|
|
218
|
+
provider: "p",
|
|
219
|
+
model: "m",
|
|
220
|
+
turnType: "chat",
|
|
221
|
+
}),
|
|
222
|
+
).toBe("");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("the lifecycle tools", () => {
|
|
227
|
+
test("are admitted on chat and automation turns, under the manifest ceiling", () => {
|
|
228
|
+
expect(subagentsAdmissionCeilingV1(TASK_LIFECYCLE_CAPABILITY_V1)).toEqual([
|
|
229
|
+
"chat",
|
|
230
|
+
"automation",
|
|
231
|
+
]);
|
|
232
|
+
for (const tool of [
|
|
233
|
+
createTaskCheckTool({ check: () => Promise.reject(new Error("unused")) }),
|
|
234
|
+
createTaskMessageTool({
|
|
235
|
+
message: () => Promise.reject(new Error("unused")),
|
|
236
|
+
}),
|
|
237
|
+
createTaskStopTool({ stop: () => Promise.reject(new Error("unused")) }),
|
|
238
|
+
]) {
|
|
239
|
+
expect(tool.admission).toEqual({ turnTypes: ["chat", "automation"] });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("task_check says it is a read, and that nothing here is worth polling", async () => {
|
|
244
|
+
const tool = createTaskCheckTool({
|
|
245
|
+
check: () =>
|
|
246
|
+
Promise.resolve({
|
|
247
|
+
status: "known",
|
|
248
|
+
taskId: "tk-1",
|
|
249
|
+
taskType: "executor",
|
|
250
|
+
description: "read the changelog",
|
|
251
|
+
taskStatus: "running",
|
|
252
|
+
model: "provider-ollama-cloud/glm-5.3-flash:cloud",
|
|
253
|
+
queuedMessages: 2,
|
|
254
|
+
}),
|
|
255
|
+
});
|
|
256
|
+
expect(tool.idempotent).toBe(true);
|
|
257
|
+
const result = await tool.execute({ taskId: "tk-1" }, context());
|
|
258
|
+
expect(result.isError).toBe(false);
|
|
259
|
+
expect(result.content).toContain("is running");
|
|
260
|
+
expect(result.content).toContain("2 of your messages are waiting");
|
|
261
|
+
expect(result.content).toContain("do not poll");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("task_check reports the last summary of a settled task, and does not tell it to wait", async () => {
|
|
265
|
+
const tool = createTaskCheckTool({
|
|
266
|
+
check: () =>
|
|
267
|
+
Promise.resolve({
|
|
268
|
+
status: "known",
|
|
269
|
+
taskId: "tk-1",
|
|
270
|
+
taskType: "executor",
|
|
271
|
+
description: "read the changelog",
|
|
272
|
+
taskStatus: "completed",
|
|
273
|
+
model: "m",
|
|
274
|
+
summary: "It changed on Tuesday.",
|
|
275
|
+
queuedMessages: 0,
|
|
276
|
+
}),
|
|
277
|
+
});
|
|
278
|
+
const result = await tool.execute({ taskId: "tk-1" }, context());
|
|
279
|
+
expect(result.content).toContain("Last summary: It changed on Tuesday.");
|
|
280
|
+
expect(result.content).not.toContain("do not poll");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("task_message refuses a task that is not running, without reaching the queue", async () => {
|
|
284
|
+
let asked = false;
|
|
285
|
+
const tool = createTaskMessageTool({
|
|
286
|
+
message: () => {
|
|
287
|
+
asked = true;
|
|
288
|
+
return Promise.resolve({
|
|
289
|
+
status: "refused",
|
|
290
|
+
reason: 'task "tk-1" is completed and can no longer be messaged',
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
const result = await tool.execute(
|
|
295
|
+
{ taskId: "tk-1", message: "stop reading" },
|
|
296
|
+
context(),
|
|
297
|
+
);
|
|
298
|
+
expect(asked).toBe(true);
|
|
299
|
+
expect(result.isError).toBe(true);
|
|
300
|
+
expect(result.content).toContain("can no longer be messaged");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("task_message refuses an unbounded payload at its own door", async () => {
|
|
304
|
+
let asked = false;
|
|
305
|
+
const tool = createTaskMessageTool({
|
|
306
|
+
message: () => {
|
|
307
|
+
asked = true;
|
|
308
|
+
return Promise.resolve({ status: "queued", taskId: "tk-1", depth: 1 });
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
const result = await tool.execute(
|
|
312
|
+
{ taskId: "tk-1", message: "x".repeat(TASK_MESSAGE_MAX_V1 + 1) },
|
|
313
|
+
context(),
|
|
314
|
+
);
|
|
315
|
+
expect(asked).toBe(false);
|
|
316
|
+
expect(result.isError).toBe(true);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("task_stop is idempotent, because stopping a stopped task is stopping it once", () => {
|
|
320
|
+
expect(
|
|
321
|
+
createTaskStopTool({ stop: () => Promise.reject(new Error("unused")) })
|
|
322
|
+
.idempotent,
|
|
323
|
+
).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("task_resume refuses a model beside a resume, before anything is dispatched", async () => {
|
|
327
|
+
let dispatched = false;
|
|
328
|
+
const tool = createTaskResumeTool({
|
|
329
|
+
writer: { sessionId: "user:bot", turnId: "run-1", runId: "run-1" },
|
|
330
|
+
resume: () => {
|
|
331
|
+
dispatched = true;
|
|
332
|
+
return Promise.resolve({
|
|
333
|
+
status: "dispatched",
|
|
334
|
+
taskId: "tk-2",
|
|
335
|
+
model: "m",
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
const result = await tool.execute(
|
|
340
|
+
{ resume: "tk-1", prompt: "keep going", model: "other/model" },
|
|
341
|
+
context(),
|
|
342
|
+
);
|
|
343
|
+
expect(dispatched).toBe(false);
|
|
344
|
+
expect(result.isError).toBe(true);
|
|
345
|
+
expect(result.content).toContain("does not take a model");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("task_resume passes a running task's refusal straight through", async () => {
|
|
349
|
+
const tool = createTaskResumeTool({
|
|
350
|
+
writer: { sessionId: "user:bot", turnId: "run-1", runId: "run-1" },
|
|
351
|
+
resume: () =>
|
|
352
|
+
Promise.resolve({
|
|
353
|
+
status: "refused",
|
|
354
|
+
reason:
|
|
355
|
+
'task "tk-1" is still running; resume names a subagent that has finished',
|
|
356
|
+
}),
|
|
357
|
+
});
|
|
358
|
+
const result = await tool.execute(
|
|
359
|
+
{ resume: "tk-1", prompt: "keep going" },
|
|
360
|
+
context(),
|
|
361
|
+
);
|
|
362
|
+
expect(result.isError).toBe(true);
|
|
363
|
+
expect(result.content).toContain("still running");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("task_resume defaults to the background, exactly as Task does", async () => {
|
|
367
|
+
let seen: { background: boolean } | undefined;
|
|
368
|
+
const tool = createTaskResumeTool({
|
|
369
|
+
writer: { sessionId: "user:bot", turnId: "run-1", runId: "run-1" },
|
|
370
|
+
resume: (request) => {
|
|
371
|
+
seen = request;
|
|
372
|
+
return Promise.resolve({
|
|
373
|
+
status: "dispatched",
|
|
374
|
+
taskId: "tk-2",
|
|
375
|
+
model: "m",
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
const result = await tool.execute(
|
|
380
|
+
{ resume: "tk-1", prompt: "keep going" },
|
|
381
|
+
context(),
|
|
382
|
+
);
|
|
383
|
+
expect(seen?.background).toBe(true);
|
|
384
|
+
expect(result.content).toContain("Resumed subagent tk-1 as tk-2");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("a blocking dispatch that settled inside the window answers with the summary", async () => {
|
|
388
|
+
const result = await tool(() =>
|
|
389
|
+
Promise.resolve({
|
|
390
|
+
status: "settled",
|
|
391
|
+
taskId: "tk-1",
|
|
392
|
+
model: "m",
|
|
393
|
+
taskStatus: "completed",
|
|
394
|
+
summary: "It changed on Tuesday.",
|
|
395
|
+
}),
|
|
396
|
+
).execute(
|
|
397
|
+
{
|
|
398
|
+
description: "read it",
|
|
399
|
+
prompt: "read the changelog",
|
|
400
|
+
background: false,
|
|
401
|
+
},
|
|
402
|
+
context(),
|
|
403
|
+
);
|
|
404
|
+
expect(result.isError).toBe(false);
|
|
405
|
+
expect(result.content).toContain("It changed on Tuesday.");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("a blocking dispatch that did not settle says it is still running, by id", async () => {
|
|
409
|
+
const result = await tool(() =>
|
|
410
|
+
Promise.resolve({ status: "dispatched", taskId: "tk-1", model: "m" }),
|
|
411
|
+
).execute(
|
|
412
|
+
{
|
|
413
|
+
description: "read it",
|
|
414
|
+
prompt: "read the changelog",
|
|
415
|
+
background: false,
|
|
416
|
+
},
|
|
417
|
+
context(),
|
|
418
|
+
);
|
|
419
|
+
expect(result.isError).toBe(false);
|
|
420
|
+
expect(result.content).toContain("still running, id tk-1");
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Message delivery (G3). A queue nobody reads is an empty queue: GrokBot's
|
|
426
|
+
// `MessageSubagent` influences the *running* child, so the child folds what its
|
|
427
|
+
// parent queued into the next step of its own Turn.
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
describe("delivering queued messages into the child's next step", () => {
|
|
431
|
+
const entered = (
|
|
432
|
+
inputs: Array<{ messageId: string; text: string }> = [],
|
|
433
|
+
) => ({ kind: "enter" as const, inputs });
|
|
434
|
+
|
|
435
|
+
test("folds them in, in seq order, under ids derived from the queue", () => {
|
|
436
|
+
const decision = foldPendingTaskMessagesV1(
|
|
437
|
+
entered(),
|
|
438
|
+
[
|
|
439
|
+
{ seq: 1, message: "second" },
|
|
440
|
+
{ seq: 0, message: "first" },
|
|
441
|
+
],
|
|
442
|
+
"tk-1",
|
|
443
|
+
);
|
|
444
|
+
if (decision.kind !== "enter") throw new Error("the step was rejected");
|
|
445
|
+
expect(decision.inputs.map((input) => input.messageId)).toEqual([
|
|
446
|
+
"task-msg:tk-1:0",
|
|
447
|
+
"task-msg:tk-1:1",
|
|
448
|
+
]);
|
|
449
|
+
expect(decision.inputs[0]?.text).toContain("first");
|
|
450
|
+
expect(decision.inputs[1]?.text).toContain("second");
|
|
451
|
+
// It reads as a message from the dispatcher, because that is what it is:
|
|
452
|
+
// the child has no transcript to place a bare instruction in.
|
|
453
|
+
expect(decision.inputs[0]?.text).toContain("dispatcher");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("keeps the step's own inputs ahead of the delivered ones", () => {
|
|
457
|
+
const decision = foldPendingTaskMessagesV1(
|
|
458
|
+
entered([{ messageId: "m-1", text: "the brief" }]),
|
|
459
|
+
[{ seq: 0, message: "and one more thing" }],
|
|
460
|
+
"tk-1",
|
|
461
|
+
);
|
|
462
|
+
if (decision.kind !== "enter") throw new Error("the step was rejected");
|
|
463
|
+
expect(decision.inputs.map((input) => input.messageId)).toEqual([
|
|
464
|
+
"m-1",
|
|
465
|
+
"task-msg:tk-1:0",
|
|
466
|
+
]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("a step with nothing queued is the step it already was", () => {
|
|
470
|
+
const decision = entered();
|
|
471
|
+
expect(foldPendingTaskMessagesV1(decision, [], "tk-1")).toBe(decision);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("a rejected step stays rejected: delivery never admits a Turn", () => {
|
|
475
|
+
const rejected = { kind: "reject" as const, reason: "no" };
|
|
476
|
+
expect(
|
|
477
|
+
foldPendingTaskMessagesV1(rejected, [{ seq: 0, message: "hi" }], "tk-1"),
|
|
478
|
+
).toBe(rejected);
|
|
479
|
+
});
|
|
480
|
+
});
|