@frockbot/plugin-shell 0.3.1 → 0.3.3
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/package.json +32 -29
- package/src/agent.test.ts +15 -3
- package/src/backend-applets.test.ts +581 -0
- package/src/backend-applets.ts +959 -0
- package/src/backend-authoring.test.ts +61 -19
- package/src/backend-authoring.ts +66 -27
- package/src/backend-completion.ts +4 -2
- package/src/backend-composition.ts +64 -0
- package/src/backend-computer.test.ts +128 -0
- package/src/backend-computer.ts +81 -0
- package/src/backend-configuration.test.ts +8 -1
- package/src/backend-iframe-ui.test.ts +29 -12
- package/src/backend-isolate.ts +31 -5
- package/src/backend-package-catalog.test.ts +13 -8
- package/src/backend-package-catalog.ts +8 -6
- package/src/backend-recovery-integration.test.ts +23 -0
- package/src/backend-recovery.ts +20 -12
- package/src/backend-runner-iframe.test.ts +10 -1
- package/src/backend-runner.ts +9 -2
- package/src/backend-stop.test.ts +6 -6
- package/src/backend-supersede.test.ts +377 -0
- package/src/backend.ts +567 -13
- package/src/client/AppletCanvas.vue +679 -0
- package/src/client/FrockBotApp.vue +195 -21
- package/src/client/PackageEntryTrigger.vue +77 -0
- package/src/client/PackageIframeHost.vue +148 -47
- package/src/client/PackageIframeSettings.vue +8 -6
- package/src/client/PackageSurfacePage.vue +39 -0
- package/src/client/applets-client.test.ts +204 -0
- package/src/client/applets-client.ts +139 -0
- package/src/client/applets-state.ts +64 -0
- package/src/client/index.test.ts +221 -7
- package/src/client/index.ts +398 -6
- package/src/client/package-iframe-entries.test.ts +122 -0
- package/src/client/package-iframe-entries.ts +112 -0
- package/src/client/package-iframe-host-message.test.ts +3 -3
- package/src/client/package-iframe-host-message.ts +3 -3
- package/src/client/styles.css +118 -1
- package/src/composition-views.ts +31 -6
- package/src/run-protocol.test.ts +92 -0
- package/src/run-protocol.ts +193 -17
- package/src/shared.ts +70 -0
- package/src/terminal-records.test.ts +52 -1
- package/src/terminal-records.ts +48 -0
package/src/client/index.ts
CHANGED
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
decodeTaskListViewV1,
|
|
63
63
|
decodeTaskViewV1,
|
|
64
64
|
} from "@frockbot/plugin-subagents/shared";
|
|
65
|
-
import { ref, toRaw, type Ref } from "vue";
|
|
65
|
+
import { defineComponent, h, ref, toRaw, watch, type Ref } from "vue";
|
|
66
66
|
import {
|
|
67
67
|
frockBotWebDataKey,
|
|
68
68
|
type FrockBotWebData,
|
|
@@ -75,19 +75,72 @@ import {
|
|
|
75
75
|
type WebToolActivity,
|
|
76
76
|
} from "../shared.js";
|
|
77
77
|
import FrockBotApp from "./FrockBotApp.vue";
|
|
78
|
+
import PackageEntryTrigger from "./PackageEntryTrigger.vue";
|
|
78
79
|
import PackageIframeSettings from "./PackageIframeSettings.vue";
|
|
80
|
+
import PackageSurfacePage from "./PackageSurfacePage.vue";
|
|
81
|
+
import {
|
|
82
|
+
packageIframeEntriesV1,
|
|
83
|
+
type PackageIframeEntryV1,
|
|
84
|
+
} from "./package-iframe-entries.js";
|
|
85
|
+
import { appletsAvailableV1 } from "./applets-state.js";
|
|
86
|
+
import {
|
|
87
|
+
readAppletBuild,
|
|
88
|
+
readAppletList,
|
|
89
|
+
readAppletSource,
|
|
90
|
+
readAppletUi,
|
|
91
|
+
readAppletViewerToken,
|
|
92
|
+
readFocusedAppletId,
|
|
93
|
+
writeFocusedAppletId,
|
|
94
|
+
} from "./applets-client.js";
|
|
79
95
|
import { modelRuntimeLabel } from "./model-presentation.js";
|
|
80
96
|
import { showClientNotificationV1 } from "./notify.js";
|
|
81
97
|
import "@frockbot/client-core/fonts.css";
|
|
82
98
|
import "./styles.css";
|
|
99
|
+
import { defineClientContribution } from "@frockbot/kernel-contracts/contributions";
|
|
100
|
+
|
|
101
|
+
function presentedToolCall(call: NonNullable<ClientTurnEvent["call"]>): {
|
|
102
|
+
name: string;
|
|
103
|
+
input?: unknown;
|
|
104
|
+
} {
|
|
105
|
+
if (
|
|
106
|
+
call.name === "call_dynamic_tool" &&
|
|
107
|
+
typeof call.input === "object" &&
|
|
108
|
+
call.input !== null &&
|
|
109
|
+
!Array.isArray(call.input)
|
|
110
|
+
) {
|
|
111
|
+
const input = call.input as Record<string, unknown>;
|
|
112
|
+
if (
|
|
113
|
+
typeof input.namespace === "string" &&
|
|
114
|
+
typeof input.toolName === "string"
|
|
115
|
+
) {
|
|
116
|
+
let innerArguments: unknown = {};
|
|
117
|
+
if (typeof input.argumentsJson === "string") {
|
|
118
|
+
try {
|
|
119
|
+
innerArguments = JSON.parse(input.argumentsJson) as unknown;
|
|
120
|
+
} catch {
|
|
121
|
+
innerArguments = {};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
name: `${input.namespace}/${input.toolName}`,
|
|
126
|
+
input: innerArguments,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
name: call.name,
|
|
132
|
+
...(call.input === undefined ? {} : { input: call.input }),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
83
135
|
|
|
84
136
|
function toolsFrom(events: ClientTurnEvent[]): WebToolActivity[] {
|
|
85
137
|
const tools = new Map<string, WebToolActivity>();
|
|
86
138
|
for (const event of events) {
|
|
87
139
|
if (event.type === "tool/call" && event.call) {
|
|
140
|
+
const presented = presentedToolCall(event.call);
|
|
88
141
|
tools.set(event.call.id, {
|
|
89
142
|
id: event.call.id,
|
|
90
|
-
|
|
143
|
+
...presented,
|
|
91
144
|
status: "running",
|
|
92
145
|
});
|
|
93
146
|
}
|
|
@@ -162,7 +215,7 @@ function tasksFrom(events: ClientTurnEvent[]): WebTaskChip[] {
|
|
|
162
215
|
|
|
163
216
|
type DurableRunProjectionState = Pick<
|
|
164
217
|
FrockBotWebData,
|
|
165
|
-
"messages" | "activeRunId" | "activeRun" | "error"
|
|
218
|
+
"messages" | "activeRunId" | "runningRunId" | "activeRun" | "error"
|
|
166
219
|
>;
|
|
167
220
|
|
|
168
221
|
/**
|
|
@@ -201,7 +254,8 @@ function isTerminalRun(run: ClientRun): boolean {
|
|
|
201
254
|
return (
|
|
202
255
|
run.status === "completed" ||
|
|
203
256
|
run.status === "failed" ||
|
|
204
|
-
run.status === "cancelled"
|
|
257
|
+
run.status === "cancelled" ||
|
|
258
|
+
run.status === "superseded"
|
|
205
259
|
);
|
|
206
260
|
}
|
|
207
261
|
|
|
@@ -218,6 +272,23 @@ function assistantMessage(
|
|
|
218
272
|
role: "assistant",
|
|
219
273
|
text: run.responseText ?? "",
|
|
220
274
|
status: "streaming",
|
|
275
|
+
// A Turn that has not started shows nothing of its own: the greyed user
|
|
276
|
+
// message is the whole of what the thread says about it.
|
|
277
|
+
...(run.queued ? { pending: true } : {}),
|
|
278
|
+
tools: toolsFrom(run.events),
|
|
279
|
+
sends: sendsFrom(run.events),
|
|
280
|
+
tasks: tasksFrom(run.events),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (run.status === "superseded") {
|
|
284
|
+
// The same quiet treatment a stopped Turn gets. It keeps everything it
|
|
285
|
+
// already sent; the line only says why it ends where it does.
|
|
286
|
+
return {
|
|
287
|
+
id: `${run.runId}:assistant`,
|
|
288
|
+
runId: run.runId,
|
|
289
|
+
role: "assistant",
|
|
290
|
+
text: run.failure ?? "Interrupted by your next message.",
|
|
291
|
+
status: "aborted",
|
|
221
292
|
tools: toolsFrom(run.events),
|
|
222
293
|
sends: sendsFrom(run.events),
|
|
223
294
|
tasks: tasksFrom(run.events),
|
|
@@ -304,6 +375,9 @@ export function projectDurableRuns(
|
|
|
304
375
|
// Busy state and the banner are separate: a running Turn keeps the composer
|
|
305
376
|
// busy without producing a banner of its own.
|
|
306
377
|
let busyRunId: string | undefined;
|
|
378
|
+
// The Turn Stop targets: the one that is executing, never the one waiting
|
|
379
|
+
// behind it.
|
|
380
|
+
let runningRunId: string | undefined;
|
|
307
381
|
for (const run of runs) {
|
|
308
382
|
const notification = notifications.find(
|
|
309
383
|
(candidate) => candidate.runId === run.runId,
|
|
@@ -323,6 +397,9 @@ export function projectDurableRuns(
|
|
|
323
397
|
? { at: existingUser.at }
|
|
324
398
|
: {}),
|
|
325
399
|
status: "completed",
|
|
400
|
+
// Greyed while its Turn waits, ordinary the moment it is running. The
|
|
401
|
+
// flag comes from durable run state, so a reload draws the same thing.
|
|
402
|
+
...(run.queued ? { pending: true } : {}),
|
|
326
403
|
tools: [],
|
|
327
404
|
sends: [],
|
|
328
405
|
};
|
|
@@ -343,6 +420,7 @@ export function projectDurableRuns(
|
|
|
343
420
|
activeRun = activeRunView(run) ?? activeRun;
|
|
344
421
|
if (run.status === "running" || run.status === "reconciliation-required") {
|
|
345
422
|
busyRunId = run.runId;
|
|
423
|
+
if (!run.queued) runningRunId = run.runId;
|
|
346
424
|
}
|
|
347
425
|
if (notification && isTerminalRun(run)) {
|
|
348
426
|
projected.add(notification.notificationId);
|
|
@@ -360,6 +438,10 @@ export function projectDurableRuns(
|
|
|
360
438
|
else if (state.activeRunId && terminalRunIds.has(state.activeRunId)) {
|
|
361
439
|
state.activeRunId = undefined;
|
|
362
440
|
}
|
|
441
|
+
if (runningRunId) state.runningRunId = runningRunId;
|
|
442
|
+
else if (state.runningRunId && terminalRunIds.has(state.runningRunId)) {
|
|
443
|
+
state.runningRunId = undefined;
|
|
444
|
+
}
|
|
363
445
|
if (activeRun) state.activeRun = activeRun;
|
|
364
446
|
else if (
|
|
365
447
|
state.activeRun &&
|
|
@@ -1108,6 +1190,22 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1108
1190
|
approvals: [],
|
|
1109
1191
|
tasks: [],
|
|
1110
1192
|
packageUi: undefined,
|
|
1193
|
+
applets: [],
|
|
1194
|
+
focusedAppletId: undefined,
|
|
1195
|
+
appletViewer: undefined,
|
|
1196
|
+
appletSource: undefined,
|
|
1197
|
+
appletBuild: undefined,
|
|
1198
|
+
appletCanvas: "idle",
|
|
1199
|
+
/*
|
|
1200
|
+
* The focused Applet, joined with the list the User owns. A getter rather
|
|
1201
|
+
* than a stored field so the two can never disagree: the id is what the
|
|
1202
|
+
* Bot Durable Object recorded, and this is what that id currently names.
|
|
1203
|
+
*/
|
|
1204
|
+
get focusedApplet() {
|
|
1205
|
+
const appletId = web.value.focusedAppletId;
|
|
1206
|
+
if (!appletId) return undefined;
|
|
1207
|
+
return web.value.applets.find((applet) => applet.appletId === appletId);
|
|
1208
|
+
},
|
|
1111
1209
|
async selectBot(botId: string): Promise<void> {
|
|
1112
1210
|
// Re-selecting the open Bot is not a switch: aborting the live Turn and
|
|
1113
1211
|
// clearing the transcript would discard state the User is watching.
|
|
@@ -1123,10 +1221,19 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1123
1221
|
web.value.messages = [];
|
|
1124
1222
|
web.value.activeRun = undefined;
|
|
1125
1223
|
web.value.activeRunId = undefined;
|
|
1224
|
+
web.value.runningRunId = undefined;
|
|
1126
1225
|
web.value.skillCatalog = [];
|
|
1127
1226
|
web.value.approvals = [];
|
|
1128
1227
|
web.value.tasks = [];
|
|
1129
1228
|
web.value.packageUi = undefined;
|
|
1229
|
+
// The focus is per Session, so switching Bots drops what the previous
|
|
1230
|
+
// Bot's canvas was showing rather than carrying it across.
|
|
1231
|
+
web.value.focusedAppletId = undefined;
|
|
1232
|
+
web.value.appletViewer = undefined;
|
|
1233
|
+
web.value.appletSource = undefined;
|
|
1234
|
+
web.value.appletBuild = undefined;
|
|
1235
|
+
web.value.appletCanvas = "idle";
|
|
1236
|
+
web.value.appletCanvasError = undefined;
|
|
1130
1237
|
const url = URL.parse(window.location.href);
|
|
1131
1238
|
if (url) {
|
|
1132
1239
|
url.searchParams.set("bot", botId);
|
|
@@ -1234,6 +1341,162 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1234
1341
|
}
|
|
1235
1342
|
}
|
|
1236
1343
|
},
|
|
1344
|
+
/*
|
|
1345
|
+
* The Applets the User owns.
|
|
1346
|
+
*
|
|
1347
|
+
* Account-shaped, so this is read once per selection rather than per Bot,
|
|
1348
|
+
* and a deployment with no Applet routes reads as an empty list instead of
|
|
1349
|
+
* an error over the conversation.
|
|
1350
|
+
*/
|
|
1351
|
+
async loadApplets(): Promise<void> {
|
|
1352
|
+
const read = ctx.transport.hostedRequest;
|
|
1353
|
+
if (!read || !appletsAvailableV1(web.value.packageUi)) return;
|
|
1354
|
+
const generation = selectionGeneration;
|
|
1355
|
+
try {
|
|
1356
|
+
const applets = await readAppletList(read);
|
|
1357
|
+
if (generation !== selectionGeneration) return;
|
|
1358
|
+
web.value.applets = applets;
|
|
1359
|
+
} catch {
|
|
1360
|
+
if (generation === selectionGeneration) web.value.applets = [];
|
|
1361
|
+
}
|
|
1362
|
+
},
|
|
1363
|
+
async loadFocusedApplet(): Promise<void> {
|
|
1364
|
+
const read = ctx.transport.hostedRequest;
|
|
1365
|
+
const botId = web.value.activeBotId;
|
|
1366
|
+
if (!read || !botId || !appletsAvailableV1(web.value.packageUi)) return;
|
|
1367
|
+
const generation = selectionGeneration;
|
|
1368
|
+
try {
|
|
1369
|
+
const appletId = await readFocusedAppletId(read, botId);
|
|
1370
|
+
if (
|
|
1371
|
+
generation !== selectionGeneration ||
|
|
1372
|
+
web.value.activeBotId !== botId
|
|
1373
|
+
)
|
|
1374
|
+
return;
|
|
1375
|
+
web.value.focusedAppletId = appletId;
|
|
1376
|
+
} catch {
|
|
1377
|
+
if (
|
|
1378
|
+
generation === selectionGeneration &&
|
|
1379
|
+
web.value.activeBotId === botId
|
|
1380
|
+
)
|
|
1381
|
+
web.value.focusedAppletId = null;
|
|
1382
|
+
}
|
|
1383
|
+
await web.value.refreshAppletCanvas();
|
|
1384
|
+
},
|
|
1385
|
+
async setFocusedApplet(appletId: string | null): Promise<void> {
|
|
1386
|
+
const post = ctx.transport.hostedRequest;
|
|
1387
|
+
const botId = web.value.activeBotId;
|
|
1388
|
+
if (!post || !botId || !appletsAvailableV1(web.value.packageUi)) return;
|
|
1389
|
+
const generation = selectionGeneration;
|
|
1390
|
+
try {
|
|
1391
|
+
const recorded = await writeFocusedAppletId(post, botId, appletId);
|
|
1392
|
+
if (
|
|
1393
|
+
generation !== selectionGeneration ||
|
|
1394
|
+
web.value.activeBotId !== botId
|
|
1395
|
+
)
|
|
1396
|
+
return;
|
|
1397
|
+
// What the canvas shows is the focus the backend recorded, never the
|
|
1398
|
+
// one the click asked for.
|
|
1399
|
+
web.value.focusedAppletId = recorded;
|
|
1400
|
+
web.value.appletViewer = undefined;
|
|
1401
|
+
web.value.appletSource = undefined;
|
|
1402
|
+
web.value.appletBuild = undefined;
|
|
1403
|
+
web.value.appletCanvasError = undefined;
|
|
1404
|
+
// Focusing is also when the list is re-read: a publish that landed
|
|
1405
|
+
// between selections is why the canvas has an Applet to show at all,
|
|
1406
|
+
// and a stale list would leave it in the building state forever.
|
|
1407
|
+
await web.value.loadApplets();
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
if (
|
|
1410
|
+
generation !== selectionGeneration ||
|
|
1411
|
+
web.value.activeBotId !== botId
|
|
1412
|
+
)
|
|
1413
|
+
return;
|
|
1414
|
+
web.value.appletCanvas = "failed";
|
|
1415
|
+
web.value.appletCanvasError =
|
|
1416
|
+
error instanceof Error
|
|
1417
|
+
? error.message
|
|
1418
|
+
: "Could not focus that Applet";
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
await web.value.refreshAppletCanvas();
|
|
1422
|
+
},
|
|
1423
|
+
/*
|
|
1424
|
+
* What the canvas draws for the focused Applet.
|
|
1425
|
+
*
|
|
1426
|
+
* The source read is the building state and never waits on the Computer:
|
|
1427
|
+
* the Workspace store is read, so a hibernated Computer costs nothing. The
|
|
1428
|
+
* viewer credential is only fetched once a generation is active, because
|
|
1429
|
+
* there is nothing to view before one is.
|
|
1430
|
+
*/
|
|
1431
|
+
async refreshAppletCanvas(): Promise<void> {
|
|
1432
|
+
const read = ctx.transport.hostedRequest;
|
|
1433
|
+
const appletId = web.value.focusedAppletId;
|
|
1434
|
+
const botId = web.value.activeBotId;
|
|
1435
|
+
if (!read || !appletId || !botId) {
|
|
1436
|
+
web.value.appletCanvas = "idle";
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
const generation = selectionGeneration;
|
|
1440
|
+
const stale = () =>
|
|
1441
|
+
generation !== selectionGeneration ||
|
|
1442
|
+
web.value.focusedAppletId !== appletId;
|
|
1443
|
+
if (web.value.appletViewer?.appletId !== appletId) {
|
|
1444
|
+
web.value.appletCanvas = "loading";
|
|
1445
|
+
}
|
|
1446
|
+
web.value.appletCanvasError = undefined;
|
|
1447
|
+
try {
|
|
1448
|
+
const [source, build] = await Promise.all([
|
|
1449
|
+
readAppletSource(read, botId, appletId),
|
|
1450
|
+
readAppletBuild(read, botId, appletId).catch(() => ({
|
|
1451
|
+
status: "unknown" as const,
|
|
1452
|
+
})),
|
|
1453
|
+
]);
|
|
1454
|
+
if (stale()) return;
|
|
1455
|
+
web.value.appletSource = source;
|
|
1456
|
+
web.value.appletBuild = build;
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
if (stale()) return;
|
|
1459
|
+
web.value.appletCanvas = "failed";
|
|
1460
|
+
web.value.appletCanvasError =
|
|
1461
|
+
error instanceof Error ? error.message : "Could not read this Applet";
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const applet = web.value.applets.find(
|
|
1465
|
+
(candidate) => candidate.appletId === appletId,
|
|
1466
|
+
);
|
|
1467
|
+
if (!applet?.currentGenerationId) {
|
|
1468
|
+
// No active generation is the building state, not a failure.
|
|
1469
|
+
if (!stale()) {
|
|
1470
|
+
web.value.appletViewer = undefined;
|
|
1471
|
+
web.value.appletCanvas = "ready";
|
|
1472
|
+
}
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
try {
|
|
1476
|
+
const [ui, token] = await Promise.all([
|
|
1477
|
+
readAppletUi(read, appletId),
|
|
1478
|
+
readAppletViewerToken(read, appletId),
|
|
1479
|
+
]);
|
|
1480
|
+
if (stale()) return;
|
|
1481
|
+
web.value.appletViewer = {
|
|
1482
|
+
appletId,
|
|
1483
|
+
token: token.token,
|
|
1484
|
+
expiresAt: token.expiresAt,
|
|
1485
|
+
socketUrl: token.socketUrl,
|
|
1486
|
+
uiUrl: ui.uiUrl,
|
|
1487
|
+
generationId: ui.generationId ?? applet.currentGenerationId,
|
|
1488
|
+
};
|
|
1489
|
+
web.value.appletCanvas = "ready";
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
if (stale()) return;
|
|
1492
|
+
// A published Applet whose viewer cannot be opened keeps the code view
|
|
1493
|
+
// up and says so; it never shows an empty frame pretending to work.
|
|
1494
|
+
web.value.appletViewer = undefined;
|
|
1495
|
+
web.value.appletCanvas = "failed";
|
|
1496
|
+
web.value.appletCanvasError =
|
|
1497
|
+
error instanceof Error ? error.message : "Could not open this Applet";
|
|
1498
|
+
}
|
|
1499
|
+
},
|
|
1237
1500
|
async callPackageUiTool(
|
|
1238
1501
|
contribution: PackageIframeContributionViewV1,
|
|
1239
1502
|
name: string,
|
|
@@ -1365,6 +1628,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1365
1628
|
updateSettingsLoadError("bot");
|
|
1366
1629
|
await deliverNotifications(botId, generation);
|
|
1367
1630
|
await web.value.loadPackageUi();
|
|
1631
|
+
if (
|
|
1632
|
+
generation !== selectionGeneration ||
|
|
1633
|
+
web.value.activeBotId !== botId
|
|
1634
|
+
)
|
|
1635
|
+
return;
|
|
1636
|
+
await web.value.loadApplets();
|
|
1637
|
+
if (
|
|
1638
|
+
generation !== selectionGeneration ||
|
|
1639
|
+
web.value.activeBotId !== botId
|
|
1640
|
+
)
|
|
1641
|
+
return;
|
|
1642
|
+
await web.value.loadFocusedApplet();
|
|
1368
1643
|
} catch (error) {
|
|
1369
1644
|
if (
|
|
1370
1645
|
generation !== selectionGeneration ||
|
|
@@ -2002,12 +2277,20 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2002
2277
|
text: string,
|
|
2003
2278
|
skills?: readonly SkillRefV1[],
|
|
2004
2279
|
): Promise<SendPromptResult> {
|
|
2005
|
-
if (web.value.activeRunId) return { accepted: false, error: "busy" };
|
|
2006
2280
|
const botId = web.value.activeBotId;
|
|
2007
2281
|
if (!botId) return { accepted: false, error: "no-bot" };
|
|
2008
2282
|
const generation = selectionGeneration;
|
|
2009
2283
|
const pendingRunId = crypto.randomUUID();
|
|
2010
2284
|
const optimisticAt = new Date().toISOString();
|
|
2285
|
+
// Every send carries the intent, because "do this instead" is what a
|
|
2286
|
+
// person means by pressing send and it does not depend on what this
|
|
2287
|
+
// client had managed to observe first. Whether a run was showing as
|
|
2288
|
+
// active is a race — the composer unlocks the instant a Turn settles,
|
|
2289
|
+
// and a fast typist beats the next poll — so gating the intent on
|
|
2290
|
+
// `activeRunId` made the Bot refuse a message the User had every right
|
|
2291
|
+
// to send. The observed run rides along as provenance when there is one.
|
|
2292
|
+
const observed = web.value.activeRunId;
|
|
2293
|
+
const supersedes = observed ? { runId: observed } : {};
|
|
2011
2294
|
web.value.activeRunId = pendingRunId;
|
|
2012
2295
|
web.value.error = undefined;
|
|
2013
2296
|
web.value.messages.push(
|
|
@@ -2018,6 +2301,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2018
2301
|
text,
|
|
2019
2302
|
at: optimisticAt,
|
|
2020
2303
|
status: "completed",
|
|
2304
|
+
// Greyed until its own Turn is admitted and running. Optimistic
|
|
2305
|
+
// only: the durable projection replaces it by run id.
|
|
2306
|
+
...(observed ? { pending: true } : {}),
|
|
2021
2307
|
tools: [],
|
|
2022
2308
|
sends: [],
|
|
2023
2309
|
},
|
|
@@ -2028,6 +2314,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2028
2314
|
text: "",
|
|
2029
2315
|
at: optimisticAt,
|
|
2030
2316
|
status: "streaming",
|
|
2317
|
+
...(observed ? { pending: true } : {}),
|
|
2031
2318
|
tools: [],
|
|
2032
2319
|
sends: [],
|
|
2033
2320
|
},
|
|
@@ -2048,6 +2335,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2048
2335
|
requestController.signal,
|
|
2049
2336
|
pendingRunId,
|
|
2050
2337
|
skills,
|
|
2338
|
+
supersedes,
|
|
2051
2339
|
);
|
|
2052
2340
|
if (
|
|
2053
2341
|
generation !== selectionGeneration ||
|
|
@@ -2176,7 +2464,13 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2176
2464
|
},
|
|
2177
2465
|
async stopRun(): Promise<void> {
|
|
2178
2466
|
const botId = web.value.activeBotId;
|
|
2179
|
-
|
|
2467
|
+
// The Turn that is executing, never the message waiting behind it: Stop
|
|
2468
|
+
// cancels what the Bot is doing and does not discard what the User just
|
|
2469
|
+
// sent.
|
|
2470
|
+
const runId =
|
|
2471
|
+
web.value.activeRun?.runId ??
|
|
2472
|
+
web.value.runningRunId ??
|
|
2473
|
+
web.value.activeRunId;
|
|
2180
2474
|
if (!botId || !runId) return;
|
|
2181
2475
|
if (!ctx.transport.stopRun) {
|
|
2182
2476
|
web.value.settingsError = "Stop is unavailable";
|
|
@@ -2224,6 +2518,90 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2224
2518
|
},
|
|
2225
2519
|
} satisfies Partial<ShellWebData>) as unknown as Ref<ShellWebData>;
|
|
2226
2520
|
|
|
2521
|
+
/*
|
|
2522
|
+
* Declarative Package entries.
|
|
2523
|
+
*
|
|
2524
|
+
* An entry is manifest data, so the sidebar control and the surface it opens
|
|
2525
|
+
* are registered from the Bot's Composition rather than by Package code:
|
|
2526
|
+
* nothing a Package ships executes in the app origin. The registrations are
|
|
2527
|
+
* disposed and rebuilt whenever the catalog changes, so switching Bots never
|
|
2528
|
+
* leaves the previous Bot's entries in the sidebar.
|
|
2529
|
+
*/
|
|
2530
|
+
let entryDisposers: Array<() => void> = [];
|
|
2531
|
+
|
|
2532
|
+
function syncPackageEntries(entries: PackageIframeEntryV1[]): void {
|
|
2533
|
+
for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
|
|
2534
|
+
entryDisposers = entries.flatMap((entry) => {
|
|
2535
|
+
const trigger = defineComponent({
|
|
2536
|
+
name: `PackageEntry_${entry.contribution.packageId}_${entry.entry.id}`,
|
|
2537
|
+
setup: () => () => h(PackageEntryTrigger, { entry }),
|
|
2538
|
+
});
|
|
2539
|
+
const page = defineComponent({
|
|
2540
|
+
name: `PackageSurface_${entry.contribution.packageId}_${entry.page.id}`,
|
|
2541
|
+
setup: () => () => h(PackageSurfacePage, { entry }),
|
|
2542
|
+
});
|
|
2543
|
+
return [
|
|
2544
|
+
surfaces.register({
|
|
2545
|
+
id: entry.surfaceId,
|
|
2546
|
+
title: entry.entry.label,
|
|
2547
|
+
component: page,
|
|
2548
|
+
}),
|
|
2549
|
+
ctx.slot({
|
|
2550
|
+
slot: entry.entry.slot,
|
|
2551
|
+
order: entry.order,
|
|
2552
|
+
key: entry.surfaceId,
|
|
2553
|
+
component: trigger,
|
|
2554
|
+
}),
|
|
2555
|
+
];
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
const stopEntrySync = watch(
|
|
2560
|
+
() => packageIframeEntriesV1(web.value.packageUi),
|
|
2561
|
+
(entries) => syncPackageEntries(entries),
|
|
2562
|
+
{ deep: true },
|
|
2563
|
+
);
|
|
2564
|
+
|
|
2565
|
+
/*
|
|
2566
|
+
* The canvas follows the Turn.
|
|
2567
|
+
*
|
|
2568
|
+
* A Turn that creates, publishes, reverts, or deletes an Applet changes what
|
|
2569
|
+
* the canvas should show, and the durable answer is only readable once the
|
|
2570
|
+
* Turn has settled — so the moment `activeRunId` clears, the list and the
|
|
2571
|
+
* focus are read back. While a Turn is running with an Applet focused, the
|
|
2572
|
+
* source is re-read on a cadence so the code view shows files as the Bot
|
|
2573
|
+
* writes them; the read is the Workspace store and wakes nothing.
|
|
2574
|
+
*/
|
|
2575
|
+
const APPLET_SOURCE_FOLLOW_MS = 2_000;
|
|
2576
|
+
let sourceFollow: ReturnType<typeof setInterval> | undefined;
|
|
2577
|
+
const stopSourceFollow = (): void => {
|
|
2578
|
+
if (sourceFollow !== undefined) clearInterval(sourceFollow);
|
|
2579
|
+
sourceFollow = undefined;
|
|
2580
|
+
};
|
|
2581
|
+
const stopRunFollow = watch(
|
|
2582
|
+
() => web.value.activeRunId,
|
|
2583
|
+
(runId, previous) => {
|
|
2584
|
+
stopSourceFollow();
|
|
2585
|
+
if (!appletsAvailableV1(web.value.packageUi)) return;
|
|
2586
|
+
if (runId) {
|
|
2587
|
+
if (!web.value.focusedAppletId) return;
|
|
2588
|
+
sourceFollow = setInterval(() => {
|
|
2589
|
+
if (!web.value.focusedAppletId || !web.value.activeRunId) {
|
|
2590
|
+
stopSourceFollow();
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
void web.value.refreshAppletCanvas();
|
|
2594
|
+
}, APPLET_SOURCE_FOLLOW_MS);
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
if (!previous) return;
|
|
2598
|
+
void (async () => {
|
|
2599
|
+
await web.value.loadApplets();
|
|
2600
|
+
await web.value.loadFocusedApplet();
|
|
2601
|
+
})();
|
|
2602
|
+
},
|
|
2603
|
+
);
|
|
2604
|
+
|
|
2227
2605
|
return [
|
|
2228
2606
|
ctx.provide(clientSurfaceRegistryKey, surfaces),
|
|
2229
2607
|
// The shared client projection is updated by the contracts lane. This cast
|
|
@@ -2240,6 +2618,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2240
2618
|
component: PackageIframeSettings,
|
|
2241
2619
|
}),
|
|
2242
2620
|
() => {
|
|
2621
|
+
stopEntrySync();
|
|
2622
|
+
stopRunFollow();
|
|
2623
|
+
stopSourceFollow();
|
|
2624
|
+
for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
|
|
2243
2625
|
activeRequest?.abort();
|
|
2244
2626
|
admissionObserver?.abort();
|
|
2245
2627
|
runObserver?.abort();
|
|
@@ -2259,3 +2641,13 @@ function replaceMessage(
|
|
|
2259
2641
|
}
|
|
2260
2642
|
|
|
2261
2643
|
export default shellClientPlugin;
|
|
2644
|
+
|
|
2645
|
+
/**
|
|
2646
|
+
* The manifest's `client` entry, resolved by specifier. The application looks
|
|
2647
|
+
* this descriptor up in its Contribution table; it never branches on which
|
|
2648
|
+
* Package it belongs to.
|
|
2649
|
+
*/
|
|
2650
|
+
export const clientContribution = defineClientContribution<ClientPlugin>({
|
|
2651
|
+
specifier: "@frockbot/plugin-shell/client",
|
|
2652
|
+
plugin: shellClientPlugin,
|
|
2653
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
PackageIframeCatalogV1,
|
|
4
|
+
PackageIframeContributionViewV1,
|
|
5
|
+
} from "@frockbot/kernel-contracts";
|
|
6
|
+
import {
|
|
7
|
+
packageIframeEntriesV1,
|
|
8
|
+
packageIframePagesForSlotV1,
|
|
9
|
+
packageIframeSurfaceIdV1,
|
|
10
|
+
PACKAGE_IFRAME_ENTRY_DEFAULT_ORDER_V1,
|
|
11
|
+
} from "./package-iframe-entries.js";
|
|
12
|
+
|
|
13
|
+
const artifact = {
|
|
14
|
+
contentHash: "a".repeat(64),
|
|
15
|
+
size: 128,
|
|
16
|
+
mediaType: "text/html" as const,
|
|
17
|
+
bundlerVersion: "frockbot-inline-html@1",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function contribution(
|
|
21
|
+
packageId: string,
|
|
22
|
+
entryOrder: number | undefined,
|
|
23
|
+
pageId = "list",
|
|
24
|
+
): PackageIframeContributionViewV1 {
|
|
25
|
+
return {
|
|
26
|
+
packageId,
|
|
27
|
+
displayName: packageId,
|
|
28
|
+
provenance: "Bot-authored",
|
|
29
|
+
pages: [
|
|
30
|
+
{
|
|
31
|
+
id: pageId,
|
|
32
|
+
artifact,
|
|
33
|
+
mounts: [{ slot: `frockbot.surface:${pageId}` }],
|
|
34
|
+
},
|
|
35
|
+
{ id: "canvas", artifact, mounts: [{ slot: "frockbot.right-panel" }] },
|
|
36
|
+
],
|
|
37
|
+
entries: [
|
|
38
|
+
{
|
|
39
|
+
id: "open",
|
|
40
|
+
slot: "frockbot.sidebar-actions",
|
|
41
|
+
...(entryOrder === undefined ? {} : { order: entryOrder }),
|
|
42
|
+
label: packageId,
|
|
43
|
+
icon: "applets",
|
|
44
|
+
opens: { kind: "surface", page: pageId },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
declaredTools: ["applet_focus"],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function catalog(
|
|
52
|
+
contributions: PackageIframeContributionViewV1[],
|
|
53
|
+
): PackageIframeCatalogV1 {
|
|
54
|
+
return {
|
|
55
|
+
schemaVersion: 1,
|
|
56
|
+
botId: "bot-1",
|
|
57
|
+
generationId: "generation-1",
|
|
58
|
+
artifactOrigin: "https://ui.example.com",
|
|
59
|
+
contributions,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("declarative Package entries", () => {
|
|
64
|
+
test("orders entries by their declared order, above Connectors at 10", () => {
|
|
65
|
+
const entries = packageIframeEntriesV1(
|
|
66
|
+
catalog([
|
|
67
|
+
contribution("later", 20, "later"),
|
|
68
|
+
contribution("applets", 5),
|
|
69
|
+
contribution("middle", 8, "middle"),
|
|
70
|
+
]),
|
|
71
|
+
);
|
|
72
|
+
expect(entries.map((entry) => entry.contribution.packageId)).toEqual([
|
|
73
|
+
"applets",
|
|
74
|
+
"middle",
|
|
75
|
+
"later",
|
|
76
|
+
]);
|
|
77
|
+
// The Applets entry sits above the Connectors trigger, which registers at
|
|
78
|
+
// order 10 in the same slot.
|
|
79
|
+
expect(entries[0]!.order).toBe(5);
|
|
80
|
+
expect(entries[0]!.order).toBeLessThan(10);
|
|
81
|
+
expect(entries[2]!.order).toBeGreaterThan(10);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("an entry with no order takes the default and ties break on Package id", () => {
|
|
85
|
+
const entries = packageIframeEntriesV1(
|
|
86
|
+
catalog([
|
|
87
|
+
contribution("zulu", undefined, "zulu"),
|
|
88
|
+
contribution("alpha", undefined, "alpha"),
|
|
89
|
+
]),
|
|
90
|
+
);
|
|
91
|
+
expect(entries.map((entry) => entry.contribution.packageId)).toEqual([
|
|
92
|
+
"alpha",
|
|
93
|
+
"zulu",
|
|
94
|
+
]);
|
|
95
|
+
expect(entries[0]!.order).toBe(PACKAGE_IFRAME_ENTRY_DEFAULT_ORDER_V1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("an entry names the page it opens and the surface that hosts it", () => {
|
|
99
|
+
const [entry] = packageIframeEntriesV1(
|
|
100
|
+
catalog([contribution("applets", 5)]),
|
|
101
|
+
);
|
|
102
|
+
expect(entry!.page.id).toBe("list");
|
|
103
|
+
expect(entry!.slot).toBe("frockbot.surface:list");
|
|
104
|
+
expect(entry!.surfaceId).toBe(packageIframeSurfaceIdV1("applets", "list"));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("no catalog is no entries rather than a failure", () => {
|
|
108
|
+
expect(packageIframeEntriesV1(undefined)).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("the right-panel slot resolves the page the canvas hosts", () => {
|
|
112
|
+
const pages = packageIframePagesForSlotV1(
|
|
113
|
+
catalog([contribution("applets", 5)]),
|
|
114
|
+
"frockbot.right-panel",
|
|
115
|
+
);
|
|
116
|
+
expect(pages).toHaveLength(1);
|
|
117
|
+
expect(pages[0]!.page.id).toBe("canvas");
|
|
118
|
+
expect(
|
|
119
|
+
packageIframePagesForSlotV1(undefined, "frockbot.right-panel"),
|
|
120
|
+
).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
});
|