@frockbot/plugin-shell 0.1.3 → 0.2.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.
Files changed (41) hide show
  1. package/frockbot.json +4 -0
  2. package/package.json +30 -28
  3. package/src/agent.ts +10 -13
  4. package/src/backend-authoring.test.ts +356 -2
  5. package/src/backend-authoring.ts +585 -20
  6. package/src/backend-composition-input.test.ts +65 -0
  7. package/src/backend-composition-input.ts +58 -0
  8. package/src/backend-composition.ts +23 -5
  9. package/src/backend-configuration.test.ts +549 -1468
  10. package/src/backend-contracts.test.ts +28 -0
  11. package/src/backend-contracts.ts +3 -1
  12. package/src/backend-execution.ts +0 -4
  13. package/src/backend-iframe-ui.test.ts +131 -0
  14. package/src/backend-image.test.ts +8 -8
  15. package/src/backend-image.ts +13 -13
  16. package/src/backend-isolate.test.ts +230 -89
  17. package/src/backend-isolate.ts +103 -200
  18. package/src/backend-package-catalog.test.ts +451 -0
  19. package/src/backend-package-catalog.ts +923 -0
  20. package/src/backend-recovery-integration.test.ts +17 -67
  21. package/src/backend-routines.ts +1 -1
  22. package/src/backend-runner-iframe.test.ts +74 -0
  23. package/src/backend-runner.ts +192 -0
  24. package/src/backend.ts +1198 -1781
  25. package/src/client/FrockBotApp.vue +44 -73
  26. package/src/client/PackageIframeHost.vue +218 -0
  27. package/src/client/PackageIframeSettings.vue +52 -0
  28. package/src/client/SendPayloadView.vue +0 -60
  29. package/src/client/index.test.ts +439 -380
  30. package/src/client/index.ts +163 -257
  31. package/src/client/model-presentation.test.ts +21 -8
  32. package/src/client/model-presentation.ts +14 -7
  33. package/src/client/package-iframe-host-message.test.ts +41 -0
  34. package/src/client/package-iframe-host-message.ts +27 -0
  35. package/src/client/styles.css +0 -27
  36. package/src/composition-views.ts +54 -0
  37. package/src/settings-links.test.ts +2 -10
  38. package/src/settings-links.ts +1 -13
  39. package/src/shared.ts +10 -13
  40. package/src/backend-assignment.test.ts +0 -161
  41. package/src/backend-assignment.ts +0 -274
@@ -105,6 +105,11 @@ describe("Bot recovery", () => {
105
105
  revision: 1,
106
106
  profile: { name: "User" },
107
107
  packages: [
108
+ {
109
+ packageId: "custom-models",
110
+ version: "0.0.1",
111
+ state: "installed",
112
+ },
108
113
  {
109
114
  packageId: "provider-ollama-cloud",
110
115
  version: "0.0.1",
@@ -140,6 +145,10 @@ describe("Bot recovery", () => {
140
145
  },
141
146
  },
142
147
  ],
148
+ platformModel: {
149
+ connectionId: "ollama-1",
150
+ providerModelId: "glm-5.3-flash:cloud",
151
+ },
143
152
  };
144
153
  const leasedRequests: Array<Record<string, unknown>> = [];
145
154
  const settledEffects: string[] = [];
@@ -148,35 +157,6 @@ describe("Bot recovery", () => {
148
157
  readConfiguration: () => Promise.resolve(structuredClone(userSettings)),
149
158
  listBots: () =>
150
159
  Promise.resolve({ schemaVersion: 1 as const, revision: 0, bots: [] }),
151
- getConnection: () =>
152
- Promise.resolve(structuredClone(userSettings.connections[0])),
153
- executeConnectionDependency: (request: { action: string }) => {
154
- if (request.action === "claim") {
155
- return Promise.resolve({
156
- schemaVersion: 1 as const,
157
- status: "claimed" as const,
158
- });
159
- }
160
- if (request.action === "acknowledge") {
161
- return Promise.resolve({
162
- schemaVersion: 1 as const,
163
- status: "acknowledged" as const,
164
- });
165
- }
166
- if (request.action === "read") {
167
- return Promise.resolve({
168
- schemaVersion: 1 as const,
169
- status: "acknowledged" as const,
170
- });
171
- }
172
- return Promise.resolve({
173
- schemaVersion: 1 as const,
174
- status: "released" as const,
175
- });
176
- },
177
- claimConnectionDependency: () => Promise.resolve(true),
178
- acknowledgeConnectionDependency: () => Promise.resolve(true),
179
- compensateConnectionDependency: () => Promise.resolve(true),
180
160
  leaseModelCredential: (input: unknown) => {
181
161
  leasedRequests.push(input as Record<string, unknown>);
182
162
  const request = input as { effectId: string };
@@ -241,32 +221,8 @@ describe("Bot recovery", () => {
241
221
  const configured = host();
242
222
  await configured.materializeSettings(
243
223
  { userId: "user-1", botId: "primary" },
244
- {
245
- name: "Ollama Bot",
246
- model: {
247
- connectionId: "ollama-1",
248
- providerModelId: "glm-5.3-flash:cloud",
249
- },
250
- },
224
+ { name: "Ollama Bot" },
251
225
  );
252
- await configured.executeConfiguration({
253
- schemaVersion: 1,
254
- userId: "user-1",
255
- botId: "primary",
256
- command: {
257
- schemaVersion: 1,
258
- type: "bot/assign-capability",
259
- commandId: "assign-ollama-model",
260
- botId: "primary",
261
- expectedRevision: 0,
262
- assignment: {
263
- assignmentId: "ollama-model",
264
- packageId: "provider-ollama-cloud",
265
- capabilityId: "ollama-cloud-models",
266
- connectionId: "ollama-1",
267
- },
268
- },
269
- });
270
226
  const first = await host().run({
271
227
  userId: "user-1",
272
228
  botId: "primary",
@@ -1353,6 +1309,11 @@ describe("Bot recovery", () => {
1353
1309
  revision: 1,
1354
1310
  profile: { name: "User" },
1355
1311
  packages: [
1312
+ {
1313
+ packageId: "custom-models",
1314
+ version: "0.0.1",
1315
+ state: "installed",
1316
+ },
1356
1317
  {
1357
1318
  packageId: "provider-ollama-cloud",
1358
1319
  version: "0.0.1",
@@ -1371,23 +1332,12 @@ describe("Bot recovery", () => {
1371
1332
  safeMetadata: {},
1372
1333
  },
1373
1334
  ],
1374
- };
1375
- const settings = {
1376
- ...initializeBotSettingsV1("primary"),
1377
- model: {
1335
+ platformModel: {
1378
1336
  connectionId: "ollama-race",
1379
1337
  providerModelId: "model:cloud",
1380
1338
  },
1381
- assignments: [
1382
- {
1383
- assignmentId: "model-race",
1384
- packageId: "provider-ollama-cloud",
1385
- capabilityId: "ollama-cloud-models",
1386
- connectionId: "ollama-race",
1387
- state: "enabled" as const,
1388
- },
1389
- ],
1390
1339
  };
1340
+ const settings = initializeBotSettingsV1("primary");
1391
1341
  await storage.put("bot-configuration", settings);
1392
1342
  const contribution = createShellBotBackendContribution({
1393
1343
  state: { storage } as unknown as DurableObjectState,
@@ -1,7 +1,7 @@
1
1
  // The Bot Durable Object's half of the Routines seam.
2
2
  //
3
3
  // "The Bot's Durable Object is the authority for everything Bot-scoped: …
4
- // durable scheduling, Routines, Assignments." The Routines Package holds the
4
+ // durable scheduling, Routines, and Composition." The Routines Package holds the
5
5
  // records, the codecs, the command semantics and the scheduler; this module
6
6
  // supplies the two things the Package cannot own — the Durable Object's storage,
7
7
  // and the one call that admits a Turn.
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Context } from "cordis";
3
+ import { SessionStore } from "@frockbot/kernel-contracts";
4
+ import { ToolRegistry } from "@frockbot/plugin-tools";
5
+ import type { ShellMountedComposition } from "./backend-composition.js";
6
+ import { executeDirectToolTurn } from "./backend-runner.js";
7
+
8
+ describe("Package iframe direct tool Turn", () => {
9
+ test("journals intent before execution and returns the result in the ordinary Session log", async () => {
10
+ const root = new Context();
11
+ await root.plugin(SessionStore);
12
+ await root.plugin(ToolRegistry);
13
+ const session = root.sessions.create("user:bot");
14
+ let calls = 0;
15
+ root.tools.register({
16
+ name: "weather_lookup",
17
+ description: "Weather",
18
+ inputSchema: {},
19
+ idempotent: true,
20
+ execute: async () => {
21
+ calls += 1;
22
+ expect(session.events.at(-1)?.type).toBe("tool/call");
23
+ return { content: '{"temperature":21}', isError: false };
24
+ },
25
+ });
26
+ const previous = [...session.events];
27
+ const generation = {
28
+ schemaVersion: 1 as const,
29
+ generationId: "generation-1",
30
+ artifactSetHash: "a".repeat(64),
31
+ createdAt: "2026-09-02T00:00:00.000Z",
32
+ origin: { kind: "bootstrap" as const },
33
+ members: [],
34
+ status: "active" as const,
35
+ };
36
+ const composition = {
37
+ root,
38
+ generation,
39
+ runtime: {
40
+ root,
41
+ agent: { agent: { session, botId: "bot", id: "bot" } },
42
+ },
43
+ verify: () => Promise.resolve(),
44
+ dispose: () => root.fiber.dispose(),
45
+ } as unknown as ShellMountedComposition;
46
+
47
+ const result = await executeDirectToolTurn({
48
+ command: {
49
+ runId: "command-1",
50
+ sessionId: "user:bot",
51
+ acceptedAt: "2026-09-02T00:00:00.000Z",
52
+ text: "Weather · weather_lookup",
53
+ directTool: {
54
+ generationId: "generation-1",
55
+ packageId: "weather-page",
56
+ name: "weather_lookup",
57
+ input: { city: "Sydney" },
58
+ },
59
+ },
60
+ previousEvents: previous,
61
+ composition,
62
+ admitEffect: () => Promise.resolve(true),
63
+ signal: new AbortController().signal,
64
+ });
65
+
66
+ expect(calls).toBe(1);
67
+ expect(result.events.map((event) => event.type)).toEqual(
68
+ expect.arrayContaining(["tool/call", "tool/result", "turn/end"]),
69
+ );
70
+ expect(
71
+ result.events.find((event) => event.type === "tool/result"),
72
+ ).toMatchObject({ name: "weather_lookup", content: '{"temperature":21}' });
73
+ });
74
+ });
@@ -155,6 +155,198 @@ export interface ExecuteBotTurnOptions {
155
155
  resume?: boolean;
156
156
  }
157
157
 
158
+ export interface ExecuteDirectToolTurnOptions {
159
+ command: BotTurnCommand & {
160
+ directTool: NonNullable<BotTurnCommand["directTool"]>;
161
+ };
162
+ previousEvents: readonly SessionEvent[];
163
+ composition: ShellMountedComposition;
164
+ admitEffect(effect: AgentEffectAdmission): Promise<boolean>;
165
+ signal: AbortSignal;
166
+ }
167
+
168
+ /**
169
+ * Runs a Package-page tool as one ordinary durable Turn, without a model call.
170
+ * The same Session journal and effect fence make retry/recovery identical to a
171
+ * model-selected tool occurrence.
172
+ */
173
+ export async function executeDirectToolTurn(
174
+ options: ExecuteDirectToolTurnOptions,
175
+ ): Promise<BotTurnCompletion> {
176
+ const { command, composition, previousEvents, admitEffect, signal } = options;
177
+ const session = composition.runtime.agent.agent.session;
178
+ const call = {
179
+ id: command.runId,
180
+ name: command.directTool.name,
181
+ input: command.directTool.input,
182
+ };
183
+ try {
184
+ let turnStart = [...session.events].findLast(
185
+ (event) =>
186
+ event.type === "turn/start" &&
187
+ !session.events.some(
188
+ (candidate) =>
189
+ candidate.type === "turn/end" && candidate.turn === event.turn,
190
+ ),
191
+ );
192
+ if (!turnStart || turnStart.type !== "turn/start") {
193
+ const turn = session.nextTurn();
194
+ const messageId = `iframe:${command.runId}`;
195
+ session.appendBatch([
196
+ { type: "input/queued", messageId, text: command.text },
197
+ { type: "turn/start", turn },
198
+ {
199
+ type: "composition/pinned",
200
+ turn,
201
+ generationId: composition.generation.generationId,
202
+ artifactSetHash: composition.generation.artifactSetHash,
203
+ },
204
+ { type: "turn/admission", turn, turnType: "chat" },
205
+ { type: "input/admitted", messageId, turn },
206
+ { type: "step/start", turn, step: 1 },
207
+ { type: "user/message", turn, step: 1, messageId, text: command.text },
208
+ {
209
+ type: "assistant/message",
210
+ turn,
211
+ step: 1,
212
+ requestId: `iframe:${command.runId}`,
213
+ text: "",
214
+ toolCalls: [call],
215
+ },
216
+ ]);
217
+ await session.flush();
218
+ turnStart = session.events.findLast(
219
+ (event) => event.type === "turn/start" && event.turn === turn,
220
+ );
221
+ }
222
+ if (!turnStart || turnStart.type !== "turn/start") {
223
+ throw new Error("Package UI tool Turn has no durable start");
224
+ }
225
+ const turn = turnStart.turn;
226
+ const occurrenceId = `tool:${turn}:1:0`;
227
+ const journal = validateToolOccurrenceJournal(session.events);
228
+ const existing = journal.get(occurrenceId);
229
+ if (!existing) throw new Error("Package UI tool occurrence is unavailable");
230
+
231
+ if (!existing.result) {
232
+ const context = {
233
+ botId: composition.runtime.agent.agent.botId,
234
+ agentId: composition.runtime.agent.agent.id,
235
+ sessionId: command.sessionId,
236
+ compositionGenerationId: composition.generation.generationId,
237
+ effectId: occurrenceId,
238
+ toolCall: call,
239
+ turnType: "chat" as const,
240
+ signal,
241
+ };
242
+ const preparation = await composition.root.tools.prepare(call, context);
243
+ let result;
244
+ if (existing.intent) {
245
+ if (preparation.kind !== "ready") {
246
+ throw new BotTurnReconciliationRequiredError(
247
+ `Tool effect "${occurrenceId}" cannot be reconciled because its definition is unavailable`,
248
+ appendedSessionEvents(previousEvents, session.events),
249
+ );
250
+ }
251
+ const reconciled = await composition.root.tools.reconcilePrepared(
252
+ preparation,
253
+ context,
254
+ );
255
+ if (reconciled.status === "unavailable") {
256
+ throw new BotTurnReconciliationRequiredError(
257
+ reconciled.reason,
258
+ appendedSessionEvents(previousEvents, session.events),
259
+ );
260
+ }
261
+ result = reconciled.result;
262
+ } else {
263
+ session.append({
264
+ type: "tool/call",
265
+ turn,
266
+ step: 1,
267
+ occurrenceId,
268
+ name: call.name,
269
+ input: call.input,
270
+ });
271
+ await session.flush();
272
+ if (preparation.kind === "denied") {
273
+ result = preparation.result;
274
+ } else {
275
+ if (!(await admitEffect({ kind: "tool", effectId: occurrenceId }))) {
276
+ session.appendBatch([
277
+ {
278
+ type: "tool/result",
279
+ turn,
280
+ step: 1,
281
+ occurrenceId,
282
+ name: call.name,
283
+ content: "Cancelled before tool execution started.",
284
+ isError: true,
285
+ status: "interrupted",
286
+ },
287
+ { type: "step/end", turn, step: 1, outcome: "cancelled" },
288
+ { type: "turn/end", turn, outcome: "cancelled" },
289
+ ]);
290
+ await session.flush();
291
+ throw new Error("Package UI tool effect was fenced by Stop");
292
+ }
293
+ try {
294
+ result = await composition.root.tools.executePrepared(
295
+ preparation,
296
+ context,
297
+ );
298
+ } catch (error) {
299
+ if (signal.aborted || !preparation.idempotent) {
300
+ throw new BotTurnReconciliationRequiredError(
301
+ `Tool effect "${occurrenceId}" outcome is uncertain`,
302
+ appendedSessionEvents(previousEvents, session.events),
303
+ );
304
+ }
305
+ result = {
306
+ content:
307
+ error instanceof Error
308
+ ? error.message
309
+ : "Tool execution failed",
310
+ isError: true,
311
+ };
312
+ }
313
+ }
314
+ }
315
+ session.append({
316
+ type: "tool/result",
317
+ turn,
318
+ step: 1,
319
+ occurrenceId,
320
+ name: call.name,
321
+ content: result.content,
322
+ isError: result.isError,
323
+ status: "completed",
324
+ ...(result.attachments?.length
325
+ ? { attachments: result.attachments }
326
+ : {}),
327
+ });
328
+ await session.flush();
329
+ }
330
+ const hasTerminal = session.events.some(
331
+ (event) => event.type === "turn/end" && event.turn === turn,
332
+ );
333
+ if (!hasTerminal) {
334
+ session.appendBatch([
335
+ { type: "step/end", turn, step: 1, outcome: "completed" },
336
+ { type: "turn/end", turn, outcome: "completed" },
337
+ ]);
338
+ await session.flush();
339
+ }
340
+ return {
341
+ runId: command.runId,
342
+ text: "",
343
+ events: appendedSessionEvents(previousEvents, session.events),
344
+ };
345
+ } finally {
346
+ await composition.dispose();
347
+ }
348
+ }
349
+
158
350
  export async function executeBotTurn(
159
351
  options: ExecuteBotTurnOptions,
160
352
  ): Promise<BotTurnCompletion> {