@frockbot/plugin-shell 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 +68 -0
- package/package.json +87 -6
- package/src/agent.test.ts +372 -0
- package/src/agent.ts +335 -0
- package/src/approvals.test.ts +224 -0
- package/src/approvals.ts +530 -0
- package/src/backend-assignment.test.ts +161 -0
- package/src/backend-assignment.ts +274 -0
- package/src/backend-authoring.test.ts +518 -0
- package/src/backend-authoring.ts +531 -0
- package/src/backend-bot-identity.test.ts +215 -0
- package/src/backend-completion.test.ts +289 -0
- package/src/backend-completion.ts +95 -0
- package/src/backend-composition.ts +242 -0
- package/src/backend-computer.ts +76 -0
- package/src/backend-configuration.test.ts +1757 -0
- package/src/backend-contracts.test.ts +189 -0
- package/src/backend-contracts.ts +44 -0
- package/src/backend-debug.test.ts +202 -0
- package/src/backend-execution.ts +55 -0
- package/src/backend-flock.ts +96 -0
- package/src/backend-image.test.ts +115 -0
- package/src/backend-image.ts +180 -0
- package/src/backend-isolate.test.ts +238 -0
- package/src/backend-isolate.ts +409 -0
- package/src/backend-machine.ts +144 -0
- package/src/backend-memory.ts +89 -0
- package/src/backend-recovery-integration.test.ts +1575 -0
- package/src/backend-recovery.ts +106 -0
- package/src/backend-routines.ts +375 -0
- package/src/backend-runner.ts +251 -0
- package/src/backend-skills.test.ts +126 -0
- package/src/backend-skills.ts +198 -0
- package/src/backend-stop.test.ts +356 -0
- package/src/backend-subagents.ts +459 -0
- package/src/backend.ts +6035 -0
- package/src/client/FrockBotApp.vue +1026 -0
- package/src/client/SendPayloadView.vue +337 -0
- package/src/client/composer-draft.test.ts +31 -0
- package/src/client/composer-draft.ts +35 -0
- package/src/client/cordis-client-shim.d.ts +15 -0
- package/src/client/index.test.ts +2548 -0
- package/src/client/index.ts +2346 -0
- package/src/client/model-presentation.test.ts +35 -0
- package/src/client/model-presentation.ts +19 -0
- package/src/client/notify.test.ts +89 -0
- package/src/client/notify.ts +101 -0
- package/src/client/skill-invocation.test.ts +143 -0
- package/src/client/skill-invocation.ts +175 -0
- package/src/client/styles.css +1043 -0
- package/src/composition-views.ts +118 -0
- package/src/debug-protocol.test.ts +80 -0
- package/src/debug-protocol.ts +165 -0
- package/src/env.d.ts +10 -0
- package/src/history.test.ts +163 -0
- package/src/history.ts +108 -0
- package/src/host.ts +20 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/run-cursor.ts +28 -0
- package/src/run-protocol.test.ts +1281 -0
- package/src/run-protocol.ts +1417 -0
- package/src/settings-links.test.ts +106 -0
- package/src/settings-links.ts +289 -0
- package/src/shared.ts +338 -0
- package/src/skill-protocol.ts +117 -0
- package/src/terminal-records.test.ts +217 -0
- package/src/terminal-records.ts +150 -0
- package/src/unread.test.ts +362 -0
- package/src/unread.ts +675 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +32 -0
- package/README.md +0 -3
|
@@ -0,0 +1,2548 @@
|
|
|
1
|
+
import { plugin } from "bun";
|
|
2
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
3
|
+
import type { ClientRun } from "@frockbot/client-core";
|
|
4
|
+
import {
|
|
5
|
+
initializeBotSettingsV1,
|
|
6
|
+
type UserSettingsViewV1,
|
|
7
|
+
} from "@frockbot/configuration-core";
|
|
8
|
+
|
|
9
|
+
// Bun has no single-file-component loader, so every Vue module the client
|
|
10
|
+
// graph reaches stands in as an empty component; these tests exercise the
|
|
11
|
+
// projection and command functions, not the rendered shell.
|
|
12
|
+
plugin({
|
|
13
|
+
name: "shell-client-vue-test-loader",
|
|
14
|
+
setup(build) {
|
|
15
|
+
build.onLoad({ filter: /\.vue$/ }, () => ({
|
|
16
|
+
contents: "export default {};",
|
|
17
|
+
loader: "js",
|
|
18
|
+
}));
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
decodePluginCatalog,
|
|
24
|
+
projectCompletedRuns,
|
|
25
|
+
projectDurableRuns,
|
|
26
|
+
shellClientPlugin,
|
|
27
|
+
} = await import("./index.js");
|
|
28
|
+
import type { FrockBotWebData } from "../shared.js";
|
|
29
|
+
import type { Ref } from "vue";
|
|
30
|
+
|
|
31
|
+
const originalLocalStorage = Object.getOwnPropertyDescriptor(
|
|
32
|
+
globalThis,
|
|
33
|
+
"localStorage",
|
|
34
|
+
);
|
|
35
|
+
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
|
36
|
+
const originalDocument = Object.getOwnPropertyDescriptor(
|
|
37
|
+
globalThis,
|
|
38
|
+
"document",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
async function secretDerivations(secret: string): Promise<string[]> {
|
|
42
|
+
const digest = new Uint8Array(
|
|
43
|
+
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret)),
|
|
44
|
+
);
|
|
45
|
+
const hex = Array.from(digest, (byte) =>
|
|
46
|
+
byte.toString(16).padStart(2, "0"),
|
|
47
|
+
).join("");
|
|
48
|
+
return [
|
|
49
|
+
hex,
|
|
50
|
+
hex.toUpperCase(),
|
|
51
|
+
btoa(String.fromCharCode(...digest)),
|
|
52
|
+
btoa(String.fromCharCode(...digest))
|
|
53
|
+
.replace(/\+/g, "-")
|
|
54
|
+
.replace(/\//g, "_")
|
|
55
|
+
.replace(/=+$/, ""),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function installMemoryStorage(): void {
|
|
60
|
+
const values = new Map<string, string>();
|
|
61
|
+
Object.defineProperty(globalThis, "localStorage", {
|
|
62
|
+
configurable: true,
|
|
63
|
+
value: {
|
|
64
|
+
getItem: (key: string) => values.get(key) ?? null,
|
|
65
|
+
setItem: (key: string, value: string) => values.set(key, value),
|
|
66
|
+
removeItem: (key: string) => values.delete(key),
|
|
67
|
+
clear: () => values.clear(),
|
|
68
|
+
key: (index: number) => [...values.keys()][index] ?? null,
|
|
69
|
+
get length() {
|
|
70
|
+
return values.size;
|
|
71
|
+
},
|
|
72
|
+
} satisfies Storage,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
if (originalDocument) {
|
|
78
|
+
Object.defineProperty(globalThis, "document", originalDocument);
|
|
79
|
+
} else {
|
|
80
|
+
Reflect.deleteProperty(globalThis, "document");
|
|
81
|
+
}
|
|
82
|
+
if (originalLocalStorage) {
|
|
83
|
+
Object.defineProperty(globalThis, "localStorage", originalLocalStorage);
|
|
84
|
+
} else {
|
|
85
|
+
Reflect.deleteProperty(globalThis, "localStorage");
|
|
86
|
+
}
|
|
87
|
+
if (originalWindow) {
|
|
88
|
+
Object.defineProperty(globalThis, "window", originalWindow);
|
|
89
|
+
} else {
|
|
90
|
+
Reflect.deleteProperty(globalThis, "window");
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("application manifest protocol", () => {
|
|
95
|
+
const emptyManifest = {
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
deployment: { userId: "user-1", applicationHash: "hash-1" },
|
|
98
|
+
applicationHash: "hash-1",
|
|
99
|
+
packages: [],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
test("requires the exact owned manifest response", () => {
|
|
103
|
+
expect(decodePluginCatalog(emptyManifest)).toEqual([]);
|
|
104
|
+
// The artifact the gateway loaded and the plan it compiled are hashed
|
|
105
|
+
// separately, so a hosted manifest always carries two different digests.
|
|
106
|
+
expect(
|
|
107
|
+
decodePluginCatalog({
|
|
108
|
+
...emptyManifest,
|
|
109
|
+
deployment: { userId: "user-1", applicationHash: "sha256-of-bytes" },
|
|
110
|
+
applicationHash: "sha256-of-plan",
|
|
111
|
+
}),
|
|
112
|
+
).toEqual([]);
|
|
113
|
+
expect(
|
|
114
|
+
decodePluginCatalog({
|
|
115
|
+
...emptyManifest,
|
|
116
|
+
packages: [
|
|
117
|
+
{
|
|
118
|
+
id: "provider-ollama-cloud",
|
|
119
|
+
displayName: "Ollama Cloud",
|
|
120
|
+
version: "0.0.1",
|
|
121
|
+
contributions: ["backend", "runtime", "client"],
|
|
122
|
+
configuration: {
|
|
123
|
+
settings: [],
|
|
124
|
+
capabilities: [
|
|
125
|
+
{
|
|
126
|
+
id: "ollama-cloud-models",
|
|
127
|
+
kind: "model",
|
|
128
|
+
connectionTypes: ["ollama-cloud-account"],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
connectionTypes: [
|
|
132
|
+
{
|
|
133
|
+
id: "ollama-cloud-account",
|
|
134
|
+
displayName: "Ollama Cloud account",
|
|
135
|
+
allowMultiple: true,
|
|
136
|
+
authorization: {
|
|
137
|
+
kind: "api-key",
|
|
138
|
+
driverId: "ollama-api-key",
|
|
139
|
+
},
|
|
140
|
+
capabilities: ["ollama-cloud-models"],
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
}),
|
|
147
|
+
).toEqual([
|
|
148
|
+
expect.objectContaining({
|
|
149
|
+
packageId: "provider-ollama-cloud",
|
|
150
|
+
capabilities: [
|
|
151
|
+
{
|
|
152
|
+
id: "ollama-cloud-models",
|
|
153
|
+
kind: "model",
|
|
154
|
+
connectionTypes: ["ollama-cloud-account"],
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
connectionTypes: [
|
|
158
|
+
expect.objectContaining({ id: "ollama-cloud-account" }),
|
|
159
|
+
],
|
|
160
|
+
}),
|
|
161
|
+
]);
|
|
162
|
+
// A Package without configuration arrives without the key.
|
|
163
|
+
expect(
|
|
164
|
+
decodePluginCatalog({
|
|
165
|
+
...emptyManifest,
|
|
166
|
+
packages: [
|
|
167
|
+
{
|
|
168
|
+
id: "ui-theme",
|
|
169
|
+
displayName: "Theme",
|
|
170
|
+
version: "0.0.1",
|
|
171
|
+
contributions: ["client"],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
}),
|
|
175
|
+
).toEqual([]);
|
|
176
|
+
// A tool Package a User installs and assigns with no credential at all:
|
|
177
|
+
// one Capability, no Connection Type. It stays in the catalog, because
|
|
178
|
+
// needing no Connection is not the same as offering nothing.
|
|
179
|
+
expect(
|
|
180
|
+
decodePluginCatalog({
|
|
181
|
+
...emptyManifest,
|
|
182
|
+
packages: [
|
|
183
|
+
{
|
|
184
|
+
id: "web",
|
|
185
|
+
displayName: "Web",
|
|
186
|
+
version: "0.0.1",
|
|
187
|
+
contributions: ["runtime"],
|
|
188
|
+
configuration: {
|
|
189
|
+
settings: [],
|
|
190
|
+
connectionTypes: [],
|
|
191
|
+
capabilities: [
|
|
192
|
+
{ id: "web-fetch", kind: "tool", connectionTypes: [] },
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
}),
|
|
198
|
+
).toEqual([
|
|
199
|
+
expect.objectContaining({
|
|
200
|
+
packageId: "web",
|
|
201
|
+
capabilities: [{ id: "web-fetch", kind: "tool", connectionTypes: [] }],
|
|
202
|
+
connectionTypes: [],
|
|
203
|
+
}),
|
|
204
|
+
]);
|
|
205
|
+
// The same Package with a turn-type admission ceiling: manifest v4 is what
|
|
206
|
+
// the catalog wraps the served configuration as, so the field decodes.
|
|
207
|
+
expect(
|
|
208
|
+
decodePluginCatalog({
|
|
209
|
+
...emptyManifest,
|
|
210
|
+
packages: [
|
|
211
|
+
{
|
|
212
|
+
id: "web",
|
|
213
|
+
displayName: "Web",
|
|
214
|
+
version: "0.0.1",
|
|
215
|
+
contributions: ["runtime"],
|
|
216
|
+
configuration: {
|
|
217
|
+
settings: [],
|
|
218
|
+
connectionTypes: [],
|
|
219
|
+
capabilities: [
|
|
220
|
+
{
|
|
221
|
+
id: "web-fetch",
|
|
222
|
+
kind: "tool",
|
|
223
|
+
connectionTypes: [],
|
|
224
|
+
admission: { turnTypes: ["chat", "automation"] },
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
}),
|
|
231
|
+
).toHaveLength(1);
|
|
232
|
+
for (const manifest of [
|
|
233
|
+
{ packages: [] },
|
|
234
|
+
{ ...emptyManifest, schemaVersion: 2 },
|
|
235
|
+
{ ...emptyManifest, schemaVersion: "1" },
|
|
236
|
+
{ ...emptyManifest, unexpected: true },
|
|
237
|
+
{ ...emptyManifest, packages: [42] },
|
|
238
|
+
]) {
|
|
239
|
+
expect(() => decodePluginCatalog(manifest)).toThrow();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("composer hydration context", () => {
|
|
245
|
+
test("hides Connection controls when the platform cannot authorize", async () => {
|
|
246
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
247
|
+
await shellClientPlugin({
|
|
248
|
+
transport: {
|
|
249
|
+
connectionsAvailable: false,
|
|
250
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
251
|
+
},
|
|
252
|
+
slot: () => () => {},
|
|
253
|
+
inject: () => {
|
|
254
|
+
throw new Error("unexpected client provider injection");
|
|
255
|
+
},
|
|
256
|
+
provide: (_key, value) => {
|
|
257
|
+
provided = value as Ref<FrockBotWebData>;
|
|
258
|
+
return () => {};
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
expect(provided?.value.connectionsAvailable).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("does not treat a query-selected Bot as backend authority", async () => {
|
|
265
|
+
Object.defineProperty(globalThis, "window", {
|
|
266
|
+
configurable: true,
|
|
267
|
+
value: { location: { href: "https://app.example/?bot=work" } },
|
|
268
|
+
});
|
|
269
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
270
|
+
await shellClientPlugin({
|
|
271
|
+
transport: {
|
|
272
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
273
|
+
},
|
|
274
|
+
slot: () => () => {},
|
|
275
|
+
inject: () => {
|
|
276
|
+
throw new Error("unexpected client provider injection");
|
|
277
|
+
},
|
|
278
|
+
provide: (_key, value) => {
|
|
279
|
+
provided = value as Ref<FrockBotWebData>;
|
|
280
|
+
return () => {};
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
284
|
+
|
|
285
|
+
expect(provided.value.composerContext).toBeUndefined();
|
|
286
|
+
expect(provided.value.activeBotId).toBeUndefined();
|
|
287
|
+
expect(provided.value.botSettings).toBeUndefined();
|
|
288
|
+
expect(provided.value.modelReady).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("hosted Assignment commands", () => {
|
|
293
|
+
test("uses distinct atomic Assign, Replace, and Unassign commands", async () => {
|
|
294
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
295
|
+
const commands: unknown[] = [];
|
|
296
|
+
await shellClientPlugin({
|
|
297
|
+
transport: {
|
|
298
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
299
|
+
readConfiguration: () =>
|
|
300
|
+
Promise.resolve(initializeBotSettingsV1("primary")),
|
|
301
|
+
executeConfiguration: (command) => {
|
|
302
|
+
commands.push(command);
|
|
303
|
+
return Promise.resolve({
|
|
304
|
+
schemaVersion: 1,
|
|
305
|
+
commandId: command.commandId,
|
|
306
|
+
revision: command.expectedRevision + 1,
|
|
307
|
+
status: "applied",
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
slot: () => () => {},
|
|
312
|
+
inject: () => {
|
|
313
|
+
throw new Error("unexpected client provider injection");
|
|
314
|
+
},
|
|
315
|
+
provide: (_key, value) => {
|
|
316
|
+
provided = value as Ref<FrockBotWebData>;
|
|
317
|
+
return () => {};
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
321
|
+
provided.value.activeBotId = "primary";
|
|
322
|
+
provided.value.botSettings = initializeBotSettingsV1("primary");
|
|
323
|
+
const assignment = {
|
|
324
|
+
assignmentId: "mail",
|
|
325
|
+
packageId: "mail",
|
|
326
|
+
capabilityId: "send",
|
|
327
|
+
connectionId: "mail-1",
|
|
328
|
+
};
|
|
329
|
+
await provided.value.assignCapability(assignment);
|
|
330
|
+
provided.value.botSettings = {
|
|
331
|
+
...initializeBotSettingsV1("primary"),
|
|
332
|
+
revision: 1,
|
|
333
|
+
};
|
|
334
|
+
await provided.value.replaceCapability(assignment);
|
|
335
|
+
provided.value.botSettings = {
|
|
336
|
+
...initializeBotSettingsV1("primary"),
|
|
337
|
+
revision: 2,
|
|
338
|
+
};
|
|
339
|
+
await provided.value.unassignCapability("mail");
|
|
340
|
+
expect(commands).toMatchObject([
|
|
341
|
+
{ type: "bot/assign-capability", expectedRevision: 0, assignment },
|
|
342
|
+
{ type: "bot/replace-capability", expectedRevision: 1, assignment },
|
|
343
|
+
{
|
|
344
|
+
type: "bot/unassign-capability",
|
|
345
|
+
expectedRevision: 2,
|
|
346
|
+
assignmentId: "mail",
|
|
347
|
+
},
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("surfaces rejected and retrying Assignment receipts", async () => {
|
|
352
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
353
|
+
const receipts = [
|
|
354
|
+
{
|
|
355
|
+
schemaVersion: 1 as const,
|
|
356
|
+
commandId: "rejected",
|
|
357
|
+
revision: 0,
|
|
358
|
+
status: "rejected" as const,
|
|
359
|
+
failure: "Connection is unavailable",
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
schemaVersion: 1 as const,
|
|
363
|
+
commandId: "pending",
|
|
364
|
+
revision: 0,
|
|
365
|
+
status: "pending" as const,
|
|
366
|
+
},
|
|
367
|
+
];
|
|
368
|
+
await shellClientPlugin({
|
|
369
|
+
transport: {
|
|
370
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
371
|
+
readConfiguration: () =>
|
|
372
|
+
Promise.resolve(initializeBotSettingsV1("primary")),
|
|
373
|
+
executeConfiguration: () => Promise.resolve(receipts.shift()!),
|
|
374
|
+
},
|
|
375
|
+
slot: () => () => {},
|
|
376
|
+
inject: () => {
|
|
377
|
+
throw new Error("unexpected client provider injection");
|
|
378
|
+
},
|
|
379
|
+
provide: (_key, value) => {
|
|
380
|
+
provided = value as Ref<FrockBotWebData>;
|
|
381
|
+
return () => {};
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
385
|
+
provided.value.activeBotId = "primary";
|
|
386
|
+
provided.value.botSettings = initializeBotSettingsV1("primary");
|
|
387
|
+
const assignment = {
|
|
388
|
+
assignmentId: "mail",
|
|
389
|
+
packageId: "mail",
|
|
390
|
+
capabilityId: "send",
|
|
391
|
+
connectionId: "mail-1",
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
await expect(provided.value.assignCapability(assignment)).rejects.toThrow(
|
|
395
|
+
"Connection is unavailable",
|
|
396
|
+
);
|
|
397
|
+
expect(provided.value.settingsError).toBe("Connection is unavailable");
|
|
398
|
+
await expect(provided.value.assignCapability(assignment)).resolves.toBe(
|
|
399
|
+
undefined,
|
|
400
|
+
);
|
|
401
|
+
expect(provided.value.settingsError).toBe(
|
|
402
|
+
"Assignment operation is retrying.",
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe("Bot selection", () => {
|
|
408
|
+
test("passes explicit Bot IDs and ignores stale hydration", async () => {
|
|
409
|
+
Object.defineProperty(globalThis, "window", {
|
|
410
|
+
configurable: true,
|
|
411
|
+
value: {
|
|
412
|
+
location: { href: "https://app.example/" },
|
|
413
|
+
history: { replaceState: () => undefined },
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
let resolveOld!: (
|
|
417
|
+
value: ReturnType<typeof initializeBotSettingsV1>,
|
|
418
|
+
) => void;
|
|
419
|
+
let resolveNew!: (
|
|
420
|
+
value: ReturnType<typeof initializeBotSettingsV1>,
|
|
421
|
+
) => void;
|
|
422
|
+
const oldSettings = new Promise<ReturnType<typeof initializeBotSettingsV1>>(
|
|
423
|
+
(resolve) => {
|
|
424
|
+
resolveOld = resolve;
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
const newSettings = new Promise<ReturnType<typeof initializeBotSettingsV1>>(
|
|
428
|
+
(resolve) => {
|
|
429
|
+
resolveNew = resolve;
|
|
430
|
+
},
|
|
431
|
+
);
|
|
432
|
+
const requested: string[] = [];
|
|
433
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
434
|
+
await shellClientPlugin({
|
|
435
|
+
transport: {
|
|
436
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
437
|
+
readConfiguration: (query) => {
|
|
438
|
+
if (query.type === "user/get")
|
|
439
|
+
throw new Error("unexpected User query");
|
|
440
|
+
requested.push(query.botId);
|
|
441
|
+
return query.botId === "old" ? oldSettings : newSettings;
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
slot: () => () => {},
|
|
445
|
+
inject: () => {
|
|
446
|
+
throw new Error("unexpected client provider injection");
|
|
447
|
+
},
|
|
448
|
+
provide: (_key, value) => {
|
|
449
|
+
provided = value as Ref<FrockBotWebData>;
|
|
450
|
+
return () => {};
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
454
|
+
const oldLoad = provided.value.selectBot("old");
|
|
455
|
+
const newLoad = provided.value.selectBot("new");
|
|
456
|
+
resolveNew(initializeBotSettingsV1("new"));
|
|
457
|
+
await newLoad;
|
|
458
|
+
resolveOld(initializeBotSettingsV1("old"));
|
|
459
|
+
await oldLoad;
|
|
460
|
+
expect(requested).toEqual(["old", "new"]);
|
|
461
|
+
expect(provided.value.activeBotId).toBe("new");
|
|
462
|
+
expect(provided.value.botSettings?.botId).toBe("new");
|
|
463
|
+
});
|
|
464
|
+
test("preserves load failures and ignores stale User settings", async () => {
|
|
465
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
466
|
+
const older = Promise.withResolvers<UserSettingsViewV1>();
|
|
467
|
+
const newer = Promise.withResolvers<UserSettingsViewV1>();
|
|
468
|
+
let userReads = 0;
|
|
469
|
+
await shellClientPlugin({
|
|
470
|
+
transport: {
|
|
471
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
472
|
+
readConfiguration: (query) => {
|
|
473
|
+
if (query.type === "bot/get") {
|
|
474
|
+
return Promise.reject(new Error("Bot settings unavailable"));
|
|
475
|
+
}
|
|
476
|
+
userReads += 1;
|
|
477
|
+
return userReads === 1 ? older.promise : newer.promise;
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
slot: () => () => {},
|
|
481
|
+
inject: () => {
|
|
482
|
+
throw new Error("unexpected client provider injection");
|
|
483
|
+
},
|
|
484
|
+
provide: (_key, value) => {
|
|
485
|
+
provided = value as Ref<FrockBotWebData>;
|
|
486
|
+
return () => {};
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
490
|
+
provided.value.activeBotId = "primary";
|
|
491
|
+
const botLoad = provided.value.loadBotSettings();
|
|
492
|
+
const olderLoad = provided.value.loadUserSettings();
|
|
493
|
+
const newerLoad = provided.value.loadUserSettings();
|
|
494
|
+
newer.resolve({
|
|
495
|
+
schemaVersion: 1,
|
|
496
|
+
revision: 2,
|
|
497
|
+
profile: { name: "Newer" },
|
|
498
|
+
packages: [],
|
|
499
|
+
connections: [],
|
|
500
|
+
});
|
|
501
|
+
await newerLoad;
|
|
502
|
+
older.resolve({
|
|
503
|
+
schemaVersion: 1,
|
|
504
|
+
revision: 1,
|
|
505
|
+
profile: { name: "Older" },
|
|
506
|
+
packages: [],
|
|
507
|
+
connections: [],
|
|
508
|
+
});
|
|
509
|
+
await Promise.all([botLoad, olderLoad]);
|
|
510
|
+
|
|
511
|
+
expect(provided.value.userSettings?.profile.name).toBe("Newer");
|
|
512
|
+
expect(provided.value.settingsError).toBe("Bot settings unavailable");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("commits a catalog without overwriting newer User settings", async () => {
|
|
516
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
517
|
+
const catalogManifest = Promise.withResolvers<unknown>();
|
|
518
|
+
const catalogUser = Promise.withResolvers<UserSettingsViewV1>();
|
|
519
|
+
const directUser = Promise.withResolvers<UserSettingsViewV1>();
|
|
520
|
+
let userReads = 0;
|
|
521
|
+
await shellClientPlugin({
|
|
522
|
+
transport: {
|
|
523
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
524
|
+
readApplicationManifest: () => catalogManifest.promise,
|
|
525
|
+
readConfiguration: () => {
|
|
526
|
+
userReads += 1;
|
|
527
|
+
return userReads === 1 ? catalogUser.promise : directUser.promise;
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
slot: () => () => {},
|
|
531
|
+
inject: () => {
|
|
532
|
+
throw new Error("unexpected client provider injection");
|
|
533
|
+
},
|
|
534
|
+
provide: (_key, value) => {
|
|
535
|
+
provided = value as Ref<FrockBotWebData>;
|
|
536
|
+
return () => {};
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
540
|
+
provided.value.pluginCatalog = [
|
|
541
|
+
{
|
|
542
|
+
packageId: "stale-package",
|
|
543
|
+
displayName: "Stale",
|
|
544
|
+
version: "0.0.1",
|
|
545
|
+
capabilities: [],
|
|
546
|
+
connectionTypes: [],
|
|
547
|
+
},
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
const catalogLoad = provided.value.loadPluginCatalog();
|
|
551
|
+
const userLoad = provided.value.loadUserSettings();
|
|
552
|
+
directUser.resolve({
|
|
553
|
+
schemaVersion: 1,
|
|
554
|
+
revision: 2,
|
|
555
|
+
profile: { name: "Newer" },
|
|
556
|
+
packages: [],
|
|
557
|
+
connections: [],
|
|
558
|
+
});
|
|
559
|
+
await userLoad;
|
|
560
|
+
catalogUser.resolve({
|
|
561
|
+
schemaVersion: 1,
|
|
562
|
+
revision: 1,
|
|
563
|
+
profile: { name: "Older" },
|
|
564
|
+
packages: [],
|
|
565
|
+
connections: [],
|
|
566
|
+
});
|
|
567
|
+
catalogManifest.resolve({
|
|
568
|
+
schemaVersion: 1,
|
|
569
|
+
deployment: { userId: "user-1", applicationHash: "hash-1" },
|
|
570
|
+
applicationHash: "hash-1",
|
|
571
|
+
packages: [],
|
|
572
|
+
});
|
|
573
|
+
await catalogLoad;
|
|
574
|
+
|
|
575
|
+
expect(provided.value.pluginCatalog).toEqual([]);
|
|
576
|
+
expect(provided.value.userSettings?.profile.name).toBe("Newer");
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("labels an explicitly bound Ollama Bot by its provider", async () => {
|
|
580
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
581
|
+
const bot = {
|
|
582
|
+
...initializeBotSettingsV1("ollama-bot"),
|
|
583
|
+
model: {
|
|
584
|
+
connectionId: "ollama-work",
|
|
585
|
+
providerModelId: "glm-5.3-flash:cloud",
|
|
586
|
+
},
|
|
587
|
+
assignments: [
|
|
588
|
+
{
|
|
589
|
+
assignmentId: "ollama-model",
|
|
590
|
+
packageId: "provider-ollama-cloud",
|
|
591
|
+
capabilityId: "ollama-cloud-models",
|
|
592
|
+
connectionId: "ollama-work",
|
|
593
|
+
state: "enabled" as const,
|
|
594
|
+
},
|
|
595
|
+
],
|
|
596
|
+
};
|
|
597
|
+
const user: UserSettingsViewV1 = {
|
|
598
|
+
schemaVersion: 1,
|
|
599
|
+
revision: 1,
|
|
600
|
+
profile: { name: "User" },
|
|
601
|
+
packages: [
|
|
602
|
+
{
|
|
603
|
+
packageId: "provider-ollama-cloud",
|
|
604
|
+
version: "0.0.1",
|
|
605
|
+
state: "installed",
|
|
606
|
+
},
|
|
607
|
+
],
|
|
608
|
+
connections: [
|
|
609
|
+
{
|
|
610
|
+
connectionId: "ollama-work",
|
|
611
|
+
packageId: "provider-ollama-cloud",
|
|
612
|
+
connectionTypeId: "ollama-cloud-account",
|
|
613
|
+
displayName: "Work",
|
|
614
|
+
state: "ready",
|
|
615
|
+
providerType: "ollama-cloud",
|
|
616
|
+
safeMetadata: {},
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
};
|
|
620
|
+
await shellClientPlugin({
|
|
621
|
+
transport: {
|
|
622
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
623
|
+
readConfiguration: (query) =>
|
|
624
|
+
Promise.resolve(query.type === "user/get" ? user : bot),
|
|
625
|
+
},
|
|
626
|
+
slot: () => () => {},
|
|
627
|
+
inject: () => {
|
|
628
|
+
throw new Error("unexpected client provider injection");
|
|
629
|
+
},
|
|
630
|
+
provide: (_key, value) => {
|
|
631
|
+
provided = value as Ref<FrockBotWebData>;
|
|
632
|
+
return () => {};
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
636
|
+
provided.value.activeBotId = bot.botId;
|
|
637
|
+
provided.value.pluginCatalog = [
|
|
638
|
+
{
|
|
639
|
+
packageId: "provider-ollama-cloud",
|
|
640
|
+
displayName: "Ollama Cloud",
|
|
641
|
+
version: "0.0.1",
|
|
642
|
+
capabilities: [
|
|
643
|
+
{
|
|
644
|
+
id: "ollama-cloud-models",
|
|
645
|
+
kind: "model",
|
|
646
|
+
connectionTypes: ["ollama-cloud-account"],
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
id: "ollama-cloud-tools",
|
|
650
|
+
kind: "tool",
|
|
651
|
+
connectionTypes: ["ollama-cloud-account"],
|
|
652
|
+
},
|
|
653
|
+
],
|
|
654
|
+
connectionTypes: [
|
|
655
|
+
{
|
|
656
|
+
id: "ollama-cloud-account",
|
|
657
|
+
displayName: "Ollama Cloud account",
|
|
658
|
+
allowMultiple: true,
|
|
659
|
+
authorizationKind: "api-key",
|
|
660
|
+
capabilities: ["ollama-cloud-models", "ollama-cloud-tools"],
|
|
661
|
+
},
|
|
662
|
+
],
|
|
663
|
+
},
|
|
664
|
+
];
|
|
665
|
+
|
|
666
|
+
await provided.value.loadBotSettings();
|
|
667
|
+
await provided.value.loadUserSettings();
|
|
668
|
+
|
|
669
|
+
expect(provided.value.modelLabel).toBe(
|
|
670
|
+
"glm-5.3-flash:cloud · Ollama Cloud",
|
|
671
|
+
);
|
|
672
|
+
expect(provided.value.modelReady).toBe(true);
|
|
673
|
+
expect(provided.value.modelSource).toBe("bot");
|
|
674
|
+
|
|
675
|
+
bot.assignments[0] = {
|
|
676
|
+
...bot.assignments[0]!,
|
|
677
|
+
capabilityId: "ollama-cloud-tools",
|
|
678
|
+
};
|
|
679
|
+
await provided.value.loadBotSettings();
|
|
680
|
+
expect(provided.value.modelReady).toBe(false);
|
|
681
|
+
|
|
682
|
+
// A Bot without a model of its own follows the User's default, and the
|
|
683
|
+
// label never says so: the Bot simply runs on that model.
|
|
684
|
+
bot.model = undefined as unknown as typeof bot.model;
|
|
685
|
+
user.newBotModelTemplate = {
|
|
686
|
+
connectionId: "ollama-work",
|
|
687
|
+
providerModelId: "llama-3:cloud",
|
|
688
|
+
};
|
|
689
|
+
user.newBotModelTemplateSource = "auto";
|
|
690
|
+
await provided.value.loadUserSettings();
|
|
691
|
+
await provided.value.loadBotSettings();
|
|
692
|
+
expect(provided.value.modelSource).toBe("default");
|
|
693
|
+
expect(provided.value.modelLabel).toBe("llama-3:cloud · Ollama Cloud");
|
|
694
|
+
expect(provided.value.modelReady).toBe(true);
|
|
695
|
+
|
|
696
|
+
user.newBotModelTemplate = undefined;
|
|
697
|
+
user.newBotModelTemplateSource = undefined;
|
|
698
|
+
await provided.value.loadUserSettings();
|
|
699
|
+
expect(provided.value.modelSource).toBe("none");
|
|
700
|
+
expect(provided.value.modelLabel).toBe("No default model");
|
|
701
|
+
expect(provided.value.modelReady).toBe(false);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("assigns a newly connected model capability before model selection", async () => {
|
|
705
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
706
|
+
let bot = initializeBotSettingsV1("ollama-bot");
|
|
707
|
+
const commands: Array<{ type: string; expectedRevision: number }> = [];
|
|
708
|
+
const user: UserSettingsViewV1 = {
|
|
709
|
+
schemaVersion: 1,
|
|
710
|
+
revision: 1,
|
|
711
|
+
profile: { name: "User" },
|
|
712
|
+
packages: [
|
|
713
|
+
{
|
|
714
|
+
packageId: "provider-ollama-cloud",
|
|
715
|
+
version: "0.0.1",
|
|
716
|
+
state: "installed",
|
|
717
|
+
},
|
|
718
|
+
],
|
|
719
|
+
connections: [
|
|
720
|
+
{
|
|
721
|
+
connectionId: "ollama-work",
|
|
722
|
+
packageId: "provider-ollama-cloud",
|
|
723
|
+
connectionTypeId: "ollama-cloud-account",
|
|
724
|
+
displayName: "Work",
|
|
725
|
+
state: "ready",
|
|
726
|
+
providerType: "ollama-cloud",
|
|
727
|
+
safeMetadata: {},
|
|
728
|
+
},
|
|
729
|
+
],
|
|
730
|
+
};
|
|
731
|
+
await shellClientPlugin({
|
|
732
|
+
transport: {
|
|
733
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
734
|
+
readConfiguration: (query) =>
|
|
735
|
+
Promise.resolve(query.type === "user/get" ? user : bot),
|
|
736
|
+
executeConfiguration: (command) => {
|
|
737
|
+
if (!("botId" in command)) throw new Error("unexpected User command");
|
|
738
|
+
commands.push({
|
|
739
|
+
type: command.type,
|
|
740
|
+
expectedRevision: command.expectedRevision,
|
|
741
|
+
});
|
|
742
|
+
if (command.type === "bot/assign-capability") {
|
|
743
|
+
bot = {
|
|
744
|
+
...bot,
|
|
745
|
+
revision: 1,
|
|
746
|
+
model: command.model,
|
|
747
|
+
assignments: [{ ...command.assignment, state: "enabled" }],
|
|
748
|
+
};
|
|
749
|
+
} else if (command.type === "bot/select-model") {
|
|
750
|
+
bot = { ...bot, revision: bot.revision + 1, model: command.model };
|
|
751
|
+
} else if (command.type === "bot/unbind-model") {
|
|
752
|
+
bot = {
|
|
753
|
+
...bot,
|
|
754
|
+
revision: bot.revision + 1,
|
|
755
|
+
model: undefined,
|
|
756
|
+
assignments: bot.assignments.map((assignment) =>
|
|
757
|
+
assignment.assignmentId === command.assignmentId
|
|
758
|
+
? { ...assignment, state: "unavailable" }
|
|
759
|
+
: assignment,
|
|
760
|
+
),
|
|
761
|
+
};
|
|
762
|
+
} else {
|
|
763
|
+
throw new Error("unexpected Bot command");
|
|
764
|
+
}
|
|
765
|
+
return Promise.resolve({
|
|
766
|
+
schemaVersion: 1,
|
|
767
|
+
commandId: command.commandId,
|
|
768
|
+
revision: bot.revision,
|
|
769
|
+
status: "applied" as const,
|
|
770
|
+
});
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
slot: () => () => {},
|
|
774
|
+
inject: () => {
|
|
775
|
+
throw new Error("unexpected client provider injection");
|
|
776
|
+
},
|
|
777
|
+
provide: (_key, value) => {
|
|
778
|
+
provided = value as Ref<FrockBotWebData>;
|
|
779
|
+
return () => {};
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
783
|
+
provided.value.activeBotId = bot.botId;
|
|
784
|
+
provided.value.botSettings = bot;
|
|
785
|
+
provided.value.userSettings = user;
|
|
786
|
+
provided.value.pluginCatalog = [
|
|
787
|
+
{
|
|
788
|
+
packageId: "provider-ollama-cloud",
|
|
789
|
+
displayName: "Ollama Cloud",
|
|
790
|
+
version: "0.0.1",
|
|
791
|
+
capabilities: [
|
|
792
|
+
{
|
|
793
|
+
id: "ollama-cloud-models",
|
|
794
|
+
kind: "model",
|
|
795
|
+
connectionTypes: ["ollama-cloud-account"],
|
|
796
|
+
},
|
|
797
|
+
],
|
|
798
|
+
connectionTypes: [
|
|
799
|
+
{
|
|
800
|
+
id: "ollama-cloud-account",
|
|
801
|
+
displayName: "Ollama Cloud account",
|
|
802
|
+
allowMultiple: true,
|
|
803
|
+
authorizationKind: "api-key",
|
|
804
|
+
capabilities: ["ollama-cloud-models"],
|
|
805
|
+
},
|
|
806
|
+
],
|
|
807
|
+
},
|
|
808
|
+
];
|
|
809
|
+
|
|
810
|
+
await provided.value.saveBotModel({
|
|
811
|
+
connectionId: "ollama-work",
|
|
812
|
+
providerModelId: "glm-5.3-flash:cloud",
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
expect(commands).toEqual([
|
|
816
|
+
{ type: "bot/assign-capability", expectedRevision: 0 },
|
|
817
|
+
]);
|
|
818
|
+
expect(bot).toMatchObject({
|
|
819
|
+
revision: 1,
|
|
820
|
+
model: {
|
|
821
|
+
connectionId: "ollama-work",
|
|
822
|
+
providerModelId: "glm-5.3-flash:cloud",
|
|
823
|
+
},
|
|
824
|
+
assignments: [
|
|
825
|
+
{
|
|
826
|
+
packageId: "provider-ollama-cloud",
|
|
827
|
+
capabilityId: "ollama-cloud-models",
|
|
828
|
+
connectionId: "ollama-work",
|
|
829
|
+
state: "enabled",
|
|
830
|
+
},
|
|
831
|
+
],
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
bot = {
|
|
835
|
+
...bot,
|
|
836
|
+
assignments: bot.assignments.map((assignment) => ({
|
|
837
|
+
...assignment,
|
|
838
|
+
state: "unavailable",
|
|
839
|
+
})),
|
|
840
|
+
};
|
|
841
|
+
provided.value.botSettings = bot;
|
|
842
|
+
|
|
843
|
+
await provided.value.clearBotModel();
|
|
844
|
+
|
|
845
|
+
expect(commands.at(-1)).toEqual({
|
|
846
|
+
type: "bot/unbind-model",
|
|
847
|
+
expectedRevision: 1,
|
|
848
|
+
});
|
|
849
|
+
expect(bot).toMatchObject({
|
|
850
|
+
revision: 2,
|
|
851
|
+
model: undefined,
|
|
852
|
+
assignments: [{ state: "unavailable" }],
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("does not resubmit an unchanged unavailable model", async () => {
|
|
857
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
858
|
+
const bot = {
|
|
859
|
+
...initializeBotSettingsV1("ollama-bot"),
|
|
860
|
+
model: {
|
|
861
|
+
connectionId: "revoked-connection",
|
|
862
|
+
providerModelId: "glm-5.3-flash:cloud",
|
|
863
|
+
},
|
|
864
|
+
assignments: [],
|
|
865
|
+
};
|
|
866
|
+
let executed = false;
|
|
867
|
+
await shellClientPlugin({
|
|
868
|
+
transport: {
|
|
869
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
870
|
+
executeConfiguration: () => {
|
|
871
|
+
executed = true;
|
|
872
|
+
throw new Error("model was resubmitted");
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
slot: () => () => {},
|
|
876
|
+
inject: () => {
|
|
877
|
+
throw new Error("unexpected client provider injection");
|
|
878
|
+
},
|
|
879
|
+
provide: (_key, value) => {
|
|
880
|
+
provided = value as Ref<FrockBotWebData>;
|
|
881
|
+
return () => {};
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
885
|
+
provided.value.activeBotId = bot.botId;
|
|
886
|
+
provided.value.botSettings = bot;
|
|
887
|
+
provided.value.userSettings = {
|
|
888
|
+
schemaVersion: 1,
|
|
889
|
+
revision: 1,
|
|
890
|
+
profile: { name: "User" },
|
|
891
|
+
packages: [],
|
|
892
|
+
connections: [],
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
await provided.value.saveBotModel(bot.model);
|
|
896
|
+
|
|
897
|
+
expect(executed).toBe(false);
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
describe("detached Turn projection", () => {
|
|
902
|
+
test("projects a completed run before it can be acknowledged", () => {
|
|
903
|
+
const messages: Parameters<typeof projectCompletedRuns>[0] = [];
|
|
904
|
+
const projected = projectCompletedRuns(
|
|
905
|
+
messages,
|
|
906
|
+
[
|
|
907
|
+
{
|
|
908
|
+
notificationId: "notification-run-1",
|
|
909
|
+
runId: "run-1",
|
|
910
|
+
createdAt: "2026-08-28T00:00:00.000Z",
|
|
911
|
+
title: "Bot replied",
|
|
912
|
+
body: "Done.",
|
|
913
|
+
},
|
|
914
|
+
],
|
|
915
|
+
[
|
|
916
|
+
{
|
|
917
|
+
runId: "run-1",
|
|
918
|
+
input: "Finish the task",
|
|
919
|
+
events: [],
|
|
920
|
+
status: "completed",
|
|
921
|
+
responseText: "Finished exactly.",
|
|
922
|
+
},
|
|
923
|
+
],
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
expect(projected.has("notification-run-1")).toBe(true);
|
|
927
|
+
expect(messages).toMatchObject([
|
|
928
|
+
{ role: "user", text: "Finish the task" },
|
|
929
|
+
{ role: "assistant", text: "Finished exactly." },
|
|
930
|
+
]);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
test("projects detached completions when notifications are disabled", () => {
|
|
934
|
+
const messages: Parameters<typeof projectCompletedRuns>[0] = [];
|
|
935
|
+
const projected = projectCompletedRuns(
|
|
936
|
+
messages,
|
|
937
|
+
[],
|
|
938
|
+
[
|
|
939
|
+
{
|
|
940
|
+
runId: "run-without-notification",
|
|
941
|
+
input: "Continue while detached",
|
|
942
|
+
events: [],
|
|
943
|
+
status: "completed",
|
|
944
|
+
responseText: "Completed while detached",
|
|
945
|
+
},
|
|
946
|
+
],
|
|
947
|
+
);
|
|
948
|
+
|
|
949
|
+
expect(projected.size).toBe(0);
|
|
950
|
+
expect(messages.map((message) => message.text)).toEqual([
|
|
951
|
+
"Continue while detached",
|
|
952
|
+
"Completed while detached",
|
|
953
|
+
]);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("replaces a local placeholder with the durable completion", () => {
|
|
957
|
+
const messages: Parameters<typeof projectCompletedRuns>[0] = [
|
|
958
|
+
{
|
|
959
|
+
id: "local-user",
|
|
960
|
+
runId: "run-1",
|
|
961
|
+
role: "user",
|
|
962
|
+
text: "Keep working",
|
|
963
|
+
status: "completed",
|
|
964
|
+
tools: [],
|
|
965
|
+
sends: [],
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
id: "local-assistant",
|
|
969
|
+
runId: "run-1",
|
|
970
|
+
role: "assistant",
|
|
971
|
+
text: "Request stopped locally.",
|
|
972
|
+
status: "aborted",
|
|
973
|
+
tools: [],
|
|
974
|
+
sends: [],
|
|
975
|
+
},
|
|
976
|
+
];
|
|
977
|
+
|
|
978
|
+
projectCompletedRuns(
|
|
979
|
+
messages,
|
|
980
|
+
[],
|
|
981
|
+
[
|
|
982
|
+
{
|
|
983
|
+
runId: "run-1",
|
|
984
|
+
input: "Keep working",
|
|
985
|
+
events: [],
|
|
986
|
+
status: "completed",
|
|
987
|
+
responseText: "Finished durably.",
|
|
988
|
+
},
|
|
989
|
+
],
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
expect(messages).toHaveLength(2);
|
|
993
|
+
expect(messages[1]).toMatchObject({
|
|
994
|
+
role: "assistant",
|
|
995
|
+
text: "Finished durably.",
|
|
996
|
+
status: "completed",
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test("projects durable failures visibly", () => {
|
|
1001
|
+
const messages: Parameters<typeof projectCompletedRuns>[0] = [];
|
|
1002
|
+
|
|
1003
|
+
projectCompletedRuns(
|
|
1004
|
+
messages,
|
|
1005
|
+
[],
|
|
1006
|
+
[
|
|
1007
|
+
{
|
|
1008
|
+
runId: "failed-run",
|
|
1009
|
+
input: "Do something risky",
|
|
1010
|
+
events: [],
|
|
1011
|
+
status: "failed",
|
|
1012
|
+
failure: "Provider reconciliation is required",
|
|
1013
|
+
},
|
|
1014
|
+
],
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
expect(messages).toMatchObject([
|
|
1018
|
+
{ role: "user", status: "completed" },
|
|
1019
|
+
{
|
|
1020
|
+
role: "assistant",
|
|
1021
|
+
text: "Provider reconciliation is required",
|
|
1022
|
+
status: "error",
|
|
1023
|
+
},
|
|
1024
|
+
]);
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
describe("active durable Turn projection", () => {
|
|
1029
|
+
test("keeps two optimistic Turns in admission order through projection and reload", async () => {
|
|
1030
|
+
Object.defineProperty(globalThis, "window", {
|
|
1031
|
+
configurable: true,
|
|
1032
|
+
value: {
|
|
1033
|
+
location: { href: "https://app.example/?bot=primary" },
|
|
1034
|
+
history: { replaceState: () => undefined },
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
1037
|
+
const runs: ClientRun[] = [];
|
|
1038
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
1039
|
+
await shellClientPlugin({
|
|
1040
|
+
transport: {
|
|
1041
|
+
turn: (_botId, input) => {
|
|
1042
|
+
const ordinal = runs.length + 1;
|
|
1043
|
+
const runId = `run-${ordinal}`;
|
|
1044
|
+
const responseText = `answer-${ordinal}`;
|
|
1045
|
+
runs.push({
|
|
1046
|
+
runId,
|
|
1047
|
+
admittedAt: `2026-09-01T00:0${ordinal}:00.000Z`,
|
|
1048
|
+
input,
|
|
1049
|
+
events: [],
|
|
1050
|
+
status: "completed",
|
|
1051
|
+
responseText,
|
|
1052
|
+
});
|
|
1053
|
+
return Promise.resolve({ runId, text: responseText, events: [] });
|
|
1054
|
+
},
|
|
1055
|
+
readConfiguration: (query) =>
|
|
1056
|
+
Promise.resolve(
|
|
1057
|
+
query.type === "bot/get"
|
|
1058
|
+
? initializeBotSettingsV1(query.botId)
|
|
1059
|
+
: {
|
|
1060
|
+
schemaVersion: 1 as const,
|
|
1061
|
+
revision: 0,
|
|
1062
|
+
profile: { name: "Test User" },
|
|
1063
|
+
packages: [],
|
|
1064
|
+
connections: [],
|
|
1065
|
+
},
|
|
1066
|
+
),
|
|
1067
|
+
listRuns: () => Promise.resolve(runs),
|
|
1068
|
+
listNotifications: () => Promise.resolve([]),
|
|
1069
|
+
},
|
|
1070
|
+
slot: () => () => {},
|
|
1071
|
+
inject: () => {
|
|
1072
|
+
throw new Error("unexpected client provider injection");
|
|
1073
|
+
},
|
|
1074
|
+
provide: (_key, value) => {
|
|
1075
|
+
provided = value as Ref<FrockBotWebData>;
|
|
1076
|
+
return () => {};
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
1080
|
+
const shell = provided.value;
|
|
1081
|
+
|
|
1082
|
+
const renderedMessages = () =>
|
|
1083
|
+
shell.messages
|
|
1084
|
+
.map((message, index) => ({ message, index }))
|
|
1085
|
+
.sort((left, right) => {
|
|
1086
|
+
const byTime = (left.message.at ?? "").localeCompare(
|
|
1087
|
+
right.message.at ?? "",
|
|
1088
|
+
);
|
|
1089
|
+
return byTime || left.index - right.index;
|
|
1090
|
+
})
|
|
1091
|
+
.map(({ message }) => ({
|
|
1092
|
+
id: message.id,
|
|
1093
|
+
role: message.role,
|
|
1094
|
+
text: message.text,
|
|
1095
|
+
at: message.at,
|
|
1096
|
+
}));
|
|
1097
|
+
const expected: ReturnType<typeof renderedMessages> = [
|
|
1098
|
+
{
|
|
1099
|
+
id: "run-1:user",
|
|
1100
|
+
role: "user",
|
|
1101
|
+
text: "question-1",
|
|
1102
|
+
at: "2026-09-01T00:01:00.000Z",
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
id: "run-1:assistant",
|
|
1106
|
+
role: "assistant",
|
|
1107
|
+
text: "answer-1",
|
|
1108
|
+
at: "2026-09-01T00:01:00.000Z",
|
|
1109
|
+
},
|
|
1110
|
+
{
|
|
1111
|
+
id: "run-2:user",
|
|
1112
|
+
role: "user",
|
|
1113
|
+
text: "question-2",
|
|
1114
|
+
at: "2026-09-01T00:02:00.000Z",
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
id: "run-2:assistant",
|
|
1118
|
+
role: "assistant",
|
|
1119
|
+
text: "answer-2",
|
|
1120
|
+
at: "2026-09-01T00:02:00.000Z",
|
|
1121
|
+
},
|
|
1122
|
+
];
|
|
1123
|
+
|
|
1124
|
+
await shell.selectBot("primary");
|
|
1125
|
+
const firstSend = shell.sendPrompt("question-1");
|
|
1126
|
+
const [optimisticUser, optimisticAssistant] = shell.messages.slice(-2);
|
|
1127
|
+
expect(optimisticUser).toMatchObject({
|
|
1128
|
+
id: `${optimisticUser?.runId}:user`,
|
|
1129
|
+
role: "user",
|
|
1130
|
+
text: "question-1",
|
|
1131
|
+
});
|
|
1132
|
+
expect(optimisticAssistant).toMatchObject({
|
|
1133
|
+
id: `${optimisticAssistant?.runId}:assistant`,
|
|
1134
|
+
role: "assistant",
|
|
1135
|
+
text: "",
|
|
1136
|
+
});
|
|
1137
|
+
expect(optimisticUser?.at).toBeDefined();
|
|
1138
|
+
expect(optimisticAssistant?.at).toBe(optimisticUser?.at);
|
|
1139
|
+
await firstSend;
|
|
1140
|
+
await shell.sendPrompt("question-2");
|
|
1141
|
+
expect(renderedMessages()).toEqual(expected);
|
|
1142
|
+
|
|
1143
|
+
// Bot selection follows the same clear-and-project path as a reload.
|
|
1144
|
+
await shell.selectBot("primary");
|
|
1145
|
+
expect(renderedMessages()).toEqual(expected);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
test("restores busy state and replaces a stale running placeholder", () => {
|
|
1149
|
+
const state: Pick<
|
|
1150
|
+
FrockBotWebData,
|
|
1151
|
+
"messages" | "activeRunId" | "activeRun" | "error"
|
|
1152
|
+
> = {
|
|
1153
|
+
activeRunId: "run-1",
|
|
1154
|
+
error: "Observer disconnected",
|
|
1155
|
+
messages: [
|
|
1156
|
+
{
|
|
1157
|
+
id: "local-user",
|
|
1158
|
+
runId: "run-1",
|
|
1159
|
+
role: "user",
|
|
1160
|
+
text: "Keep going",
|
|
1161
|
+
status: "completed",
|
|
1162
|
+
tools: [],
|
|
1163
|
+
sends: [],
|
|
1164
|
+
},
|
|
1165
|
+
{
|
|
1166
|
+
id: "local-assistant",
|
|
1167
|
+
runId: "run-1",
|
|
1168
|
+
role: "assistant",
|
|
1169
|
+
text: "Request stopped locally.",
|
|
1170
|
+
status: "aborted",
|
|
1171
|
+
tools: [],
|
|
1172
|
+
sends: [],
|
|
1173
|
+
},
|
|
1174
|
+
],
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
projectDurableRuns(
|
|
1178
|
+
state,
|
|
1179
|
+
[],
|
|
1180
|
+
[
|
|
1181
|
+
{
|
|
1182
|
+
runId: "run-1",
|
|
1183
|
+
input: "Keep going",
|
|
1184
|
+
events: [],
|
|
1185
|
+
status: "running",
|
|
1186
|
+
},
|
|
1187
|
+
],
|
|
1188
|
+
);
|
|
1189
|
+
|
|
1190
|
+
expect(state.activeRunId).toBe("run-1");
|
|
1191
|
+
expect(state.error).toBeUndefined();
|
|
1192
|
+
// A running Turn is shown by the animated avatar, never by a banner.
|
|
1193
|
+
expect(state.activeRun).toBeUndefined();
|
|
1194
|
+
expect(state.messages).toHaveLength(2);
|
|
1195
|
+
expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
test("projects dispatched subagents as chips, and skips one it cannot draw", () => {
|
|
1199
|
+
const state: Pick<
|
|
1200
|
+
FrockBotWebData,
|
|
1201
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1202
|
+
> = { messages: [] };
|
|
1203
|
+
|
|
1204
|
+
projectDurableRuns(
|
|
1205
|
+
state,
|
|
1206
|
+
[],
|
|
1207
|
+
[
|
|
1208
|
+
{
|
|
1209
|
+
runId: "run-task",
|
|
1210
|
+
input: "Read the notes",
|
|
1211
|
+
status: "completed",
|
|
1212
|
+
responseText: "",
|
|
1213
|
+
events: [
|
|
1214
|
+
{
|
|
1215
|
+
type: "task/dispatched",
|
|
1216
|
+
taskId: "tk-1",
|
|
1217
|
+
taskType: "executor",
|
|
1218
|
+
description: "Read the release notes",
|
|
1219
|
+
model: "provider-ollama-cloud/glm-5.3-flash:cloud",
|
|
1220
|
+
background: true,
|
|
1221
|
+
},
|
|
1222
|
+
// A chip a client older than the Bot would receive half-formed. It
|
|
1223
|
+
// is skipped, never drawn with a field it does not have.
|
|
1224
|
+
{ type: "task/dispatched", taskId: "tk-2" },
|
|
1225
|
+
],
|
|
1226
|
+
},
|
|
1227
|
+
],
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
expect(state.messages[1]?.tasks).toEqual([
|
|
1231
|
+
{
|
|
1232
|
+
taskId: "tk-1",
|
|
1233
|
+
taskType: "executor",
|
|
1234
|
+
description: "Read the release notes",
|
|
1235
|
+
model: "provider-ollama-cloud/glm-5.3-flash:cloud",
|
|
1236
|
+
background: true,
|
|
1237
|
+
},
|
|
1238
|
+
]);
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
test("projects sends, and says so rather than throwing on one it cannot draw", () => {
|
|
1242
|
+
const state: Pick<
|
|
1243
|
+
FrockBotWebData,
|
|
1244
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1245
|
+
> = { messages: [] };
|
|
1246
|
+
|
|
1247
|
+
projectDurableRuns(
|
|
1248
|
+
state,
|
|
1249
|
+
[],
|
|
1250
|
+
[
|
|
1251
|
+
{
|
|
1252
|
+
runId: "run-send",
|
|
1253
|
+
input: "Book it",
|
|
1254
|
+
status: "completed",
|
|
1255
|
+
responseText: "",
|
|
1256
|
+
events: [
|
|
1257
|
+
{ type: "send/to-user", payload: { type: "text", text: "On it." } },
|
|
1258
|
+
{
|
|
1259
|
+
type: "send/to-user",
|
|
1260
|
+
payload: {
|
|
1261
|
+
type: "widget",
|
|
1262
|
+
widget: { prompt: "Which day?", options: ["Tue"] },
|
|
1263
|
+
},
|
|
1264
|
+
},
|
|
1265
|
+
// A payload shape this bundle does not know, exactly as a client
|
|
1266
|
+
// older than the Bot would receive one.
|
|
1267
|
+
{ type: "send/to-user", payload: { type: "hologram" } },
|
|
1268
|
+
],
|
|
1269
|
+
},
|
|
1270
|
+
],
|
|
1271
|
+
);
|
|
1272
|
+
|
|
1273
|
+
expect(state.messages[1]?.sends).toEqual([
|
|
1274
|
+
{ kind: "payload", payload: { type: "text", text: "On it." } },
|
|
1275
|
+
{
|
|
1276
|
+
kind: "payload",
|
|
1277
|
+
payload: {
|
|
1278
|
+
type: "widget",
|
|
1279
|
+
widget: { prompt: "Which day?", options: ["Tue"] },
|
|
1280
|
+
},
|
|
1281
|
+
},
|
|
1282
|
+
{ kind: "unsupported" },
|
|
1283
|
+
]);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
test("streams running text without a banner and clears busy state", () => {
|
|
1287
|
+
const state: Pick<
|
|
1288
|
+
FrockBotWebData,
|
|
1289
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1290
|
+
> = { messages: [] };
|
|
1291
|
+
|
|
1292
|
+
projectDurableRuns(
|
|
1293
|
+
state,
|
|
1294
|
+
[],
|
|
1295
|
+
[{ runId: "run-2", input: "Explain", events: [], status: "running" }],
|
|
1296
|
+
);
|
|
1297
|
+
expect(state.activeRunId).toBe("run-2");
|
|
1298
|
+
expect(state.activeRun).toBeUndefined();
|
|
1299
|
+
expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
|
|
1300
|
+
|
|
1301
|
+
projectDurableRuns(
|
|
1302
|
+
state,
|
|
1303
|
+
[],
|
|
1304
|
+
[
|
|
1305
|
+
{
|
|
1306
|
+
runId: "run-2",
|
|
1307
|
+
input: "Explain",
|
|
1308
|
+
events: [],
|
|
1309
|
+
status: "running",
|
|
1310
|
+
responseText: "Because",
|
|
1311
|
+
},
|
|
1312
|
+
],
|
|
1313
|
+
);
|
|
1314
|
+
expect(state.activeRunId).toBe("run-2");
|
|
1315
|
+
expect(state.messages[1]).toMatchObject({
|
|
1316
|
+
text: "Because",
|
|
1317
|
+
status: "streaming",
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
projectDurableRuns(
|
|
1321
|
+
state,
|
|
1322
|
+
[],
|
|
1323
|
+
[
|
|
1324
|
+
{
|
|
1325
|
+
runId: "run-2",
|
|
1326
|
+
input: "Explain",
|
|
1327
|
+
events: [],
|
|
1328
|
+
status: "completed",
|
|
1329
|
+
responseText: "Because it is.",
|
|
1330
|
+
},
|
|
1331
|
+
],
|
|
1332
|
+
);
|
|
1333
|
+
expect(state.activeRunId).toBeUndefined();
|
|
1334
|
+
expect(state.activeRun).toBeUndefined();
|
|
1335
|
+
expect(state.messages[1]).toMatchObject({
|
|
1336
|
+
text: "Because it is.",
|
|
1337
|
+
status: "completed",
|
|
1338
|
+
});
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
test("projects reconciliation-required recovery state", () => {
|
|
1342
|
+
const reconciliation: Pick<
|
|
1343
|
+
FrockBotWebData,
|
|
1344
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1345
|
+
> = { messages: [] };
|
|
1346
|
+
projectDurableRuns(
|
|
1347
|
+
reconciliation,
|
|
1348
|
+
[],
|
|
1349
|
+
[
|
|
1350
|
+
{
|
|
1351
|
+
runId: "run-reconciliation",
|
|
1352
|
+
input: "Continue",
|
|
1353
|
+
events: [],
|
|
1354
|
+
status: "reconciliation-required",
|
|
1355
|
+
failure: "Provider result needs confirmation",
|
|
1356
|
+
recovery: {
|
|
1357
|
+
action: "resume",
|
|
1358
|
+
message: "Provider result needs confirmation",
|
|
1359
|
+
},
|
|
1360
|
+
},
|
|
1361
|
+
],
|
|
1362
|
+
);
|
|
1363
|
+
expect(reconciliation.activeRun).toEqual({
|
|
1364
|
+
runId: "run-reconciliation",
|
|
1365
|
+
status: "reconciliation-required",
|
|
1366
|
+
message: "Provider result needs confirmation",
|
|
1367
|
+
canResume: true,
|
|
1368
|
+
});
|
|
1369
|
+
expect(reconciliation.messages[1]).toMatchObject({
|
|
1370
|
+
text: "Provider result needs confirmation",
|
|
1371
|
+
status: "reconciliation-required",
|
|
1372
|
+
});
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
test("keeps busy state until the durable run becomes terminal", () => {
|
|
1376
|
+
const state: Pick<
|
|
1377
|
+
FrockBotWebData,
|
|
1378
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1379
|
+
> = { messages: [] };
|
|
1380
|
+
projectDurableRuns(
|
|
1381
|
+
state,
|
|
1382
|
+
[],
|
|
1383
|
+
[
|
|
1384
|
+
{
|
|
1385
|
+
runId: "run-1",
|
|
1386
|
+
input: "Continue",
|
|
1387
|
+
events: [],
|
|
1388
|
+
status: "reconciliation-required",
|
|
1389
|
+
recovery: {
|
|
1390
|
+
action: "resume",
|
|
1391
|
+
message: "Provider reconciliation is required",
|
|
1392
|
+
},
|
|
1393
|
+
},
|
|
1394
|
+
],
|
|
1395
|
+
);
|
|
1396
|
+
projectDurableRuns(
|
|
1397
|
+
state,
|
|
1398
|
+
[],
|
|
1399
|
+
[
|
|
1400
|
+
{
|
|
1401
|
+
runId: "run-1",
|
|
1402
|
+
input: "Continue",
|
|
1403
|
+
events: [],
|
|
1404
|
+
status: "completed",
|
|
1405
|
+
responseText: "Done",
|
|
1406
|
+
},
|
|
1407
|
+
],
|
|
1408
|
+
);
|
|
1409
|
+
|
|
1410
|
+
expect(state.activeRunId).toBeUndefined();
|
|
1411
|
+
expect(state.activeRun).toBeUndefined();
|
|
1412
|
+
expect(state.messages[1]).toMatchObject({
|
|
1413
|
+
text: "Done",
|
|
1414
|
+
status: "completed",
|
|
1415
|
+
});
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
test("uses the hosted reconciliation action and projects its result", async () => {
|
|
1419
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
1420
|
+
let status: "reconciliation-required" | "completed" =
|
|
1421
|
+
"reconciliation-required";
|
|
1422
|
+
const reconciled: string[] = [];
|
|
1423
|
+
await shellClientPlugin({
|
|
1424
|
+
transport: {
|
|
1425
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
1426
|
+
readConfiguration: () =>
|
|
1427
|
+
Promise.resolve(initializeBotSettingsV1("default")),
|
|
1428
|
+
listRuns: () =>
|
|
1429
|
+
Promise.resolve([
|
|
1430
|
+
{
|
|
1431
|
+
runId: "run-1",
|
|
1432
|
+
input: "Continue",
|
|
1433
|
+
events: [],
|
|
1434
|
+
status,
|
|
1435
|
+
...(status === "completed" ? { responseText: "Done" } : {}),
|
|
1436
|
+
...(status === "reconciliation-required"
|
|
1437
|
+
? {
|
|
1438
|
+
recovery: {
|
|
1439
|
+
action: "resume" as const,
|
|
1440
|
+
message: "Provider confirmation required",
|
|
1441
|
+
},
|
|
1442
|
+
}
|
|
1443
|
+
: {}),
|
|
1444
|
+
},
|
|
1445
|
+
]),
|
|
1446
|
+
listNotifications: () =>
|
|
1447
|
+
Promise.reject(new Error("notifications unavailable")),
|
|
1448
|
+
reconcileRun: (_botId, runId) => {
|
|
1449
|
+
reconciled.push(runId);
|
|
1450
|
+
status = "completed";
|
|
1451
|
+
return Promise.resolve({ runId, text: "Done", events: [] });
|
|
1452
|
+
},
|
|
1453
|
+
},
|
|
1454
|
+
slot: () => () => {},
|
|
1455
|
+
inject: () => {
|
|
1456
|
+
throw new Error("unexpected client provider injection");
|
|
1457
|
+
},
|
|
1458
|
+
provide: (_key, value) => {
|
|
1459
|
+
provided = value as Ref<FrockBotWebData>;
|
|
1460
|
+
return () => {};
|
|
1461
|
+
},
|
|
1462
|
+
});
|
|
1463
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
1464
|
+
provided.value.activeBotId = "default";
|
|
1465
|
+
provided.value.composerContext = "default";
|
|
1466
|
+
|
|
1467
|
+
await provided.value.loadBotSettings();
|
|
1468
|
+
expect(provided.value.activeRunId).toBe("run-1");
|
|
1469
|
+
await provided.value.resumeRun("run-1");
|
|
1470
|
+
|
|
1471
|
+
expect(reconciled).toEqual(["run-1"]);
|
|
1472
|
+
expect(provided.value.activeRunId).toBeUndefined();
|
|
1473
|
+
expect(provided.value.messages[1]).toMatchObject({
|
|
1474
|
+
text: "Done",
|
|
1475
|
+
status: "completed",
|
|
1476
|
+
});
|
|
1477
|
+
});
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
describe("Bot selection", () => {
|
|
1481
|
+
test("re-selecting the active Bot leaves the conversation alone", async () => {
|
|
1482
|
+
Object.defineProperty(globalThis, "window", {
|
|
1483
|
+
configurable: true,
|
|
1484
|
+
value: {
|
|
1485
|
+
location: { href: "https://app.example/" },
|
|
1486
|
+
history: { replaceState: () => {} },
|
|
1487
|
+
},
|
|
1488
|
+
});
|
|
1489
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
1490
|
+
let configurationReads = 0;
|
|
1491
|
+
let stops = 0;
|
|
1492
|
+
await shellClientPlugin({
|
|
1493
|
+
transport: {
|
|
1494
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
1495
|
+
readConfiguration: (query) => {
|
|
1496
|
+
configurationReads += 1;
|
|
1497
|
+
return Promise.resolve(
|
|
1498
|
+
query.type === "bot/get"
|
|
1499
|
+
? initializeBotSettingsV1(query.botId)
|
|
1500
|
+
: {
|
|
1501
|
+
schemaVersion: 1 as const,
|
|
1502
|
+
revision: 0,
|
|
1503
|
+
profile: { name: "Test User" },
|
|
1504
|
+
packages: [],
|
|
1505
|
+
connections: [],
|
|
1506
|
+
},
|
|
1507
|
+
);
|
|
1508
|
+
},
|
|
1509
|
+
listRuns: () => Promise.resolve([]),
|
|
1510
|
+
listNotifications: () => Promise.resolve([]),
|
|
1511
|
+
stopRun: () => {
|
|
1512
|
+
stops += 1;
|
|
1513
|
+
return Promise.reject(new Error("must not command a Stop"));
|
|
1514
|
+
},
|
|
1515
|
+
},
|
|
1516
|
+
slot: () => () => {},
|
|
1517
|
+
inject: () => {
|
|
1518
|
+
throw new Error("unexpected client provider injection");
|
|
1519
|
+
},
|
|
1520
|
+
provide: (_key, value) => {
|
|
1521
|
+
provided = value as Ref<FrockBotWebData>;
|
|
1522
|
+
return () => {};
|
|
1523
|
+
},
|
|
1524
|
+
});
|
|
1525
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
1526
|
+
|
|
1527
|
+
await provided.value.selectBot("default");
|
|
1528
|
+
provided.value.messages.push({
|
|
1529
|
+
id: "run-1:assistant",
|
|
1530
|
+
runId: "run-1",
|
|
1531
|
+
role: "assistant",
|
|
1532
|
+
text: "Still streaming",
|
|
1533
|
+
status: "streaming",
|
|
1534
|
+
tools: [],
|
|
1535
|
+
sends: [],
|
|
1536
|
+
});
|
|
1537
|
+
provided.value.activeRunId = "run-1";
|
|
1538
|
+
const readsBeforeReselect = configurationReads;
|
|
1539
|
+
|
|
1540
|
+
await provided.value.selectBot("default");
|
|
1541
|
+
|
|
1542
|
+
expect(configurationReads).toBe(readsBeforeReselect);
|
|
1543
|
+
expect(provided.value.messages).toHaveLength(1);
|
|
1544
|
+
expect(provided.value.messages[0]?.text).toBe("Still streaming");
|
|
1545
|
+
expect(provided.value.activeRunId).toBe("run-1");
|
|
1546
|
+
expect(stops).toBe(0);
|
|
1547
|
+
});
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
describe("hosted Stop", () => {
|
|
1551
|
+
test("sends one durable command and projects accepted, reconciling, then cancelled", async () => {
|
|
1552
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
1553
|
+
const commands: {
|
|
1554
|
+
botId: string;
|
|
1555
|
+
runId: string;
|
|
1556
|
+
commandId: string;
|
|
1557
|
+
}[] = [];
|
|
1558
|
+
const projections: ClientRun[] = [
|
|
1559
|
+
{
|
|
1560
|
+
runId: "run-1",
|
|
1561
|
+
input: "Continue",
|
|
1562
|
+
events: [],
|
|
1563
|
+
status: "running",
|
|
1564
|
+
stopRequestedAt: "2026-08-30T00:00:01.000Z",
|
|
1565
|
+
},
|
|
1566
|
+
{
|
|
1567
|
+
runId: "run-1",
|
|
1568
|
+
input: "Continue",
|
|
1569
|
+
events: [],
|
|
1570
|
+
status: "reconciliation-required",
|
|
1571
|
+
stopRequestedAt: "2026-08-30T00:00:01.000Z",
|
|
1572
|
+
recovery: { action: "resume", message: "Provider confirmation" },
|
|
1573
|
+
},
|
|
1574
|
+
{
|
|
1575
|
+
runId: "run-1",
|
|
1576
|
+
input: "Continue",
|
|
1577
|
+
events: [],
|
|
1578
|
+
status: "cancelled",
|
|
1579
|
+
stopRequestedAt: "2026-08-30T00:00:01.000Z",
|
|
1580
|
+
failure: "Stopped by an authenticated Stop command.",
|
|
1581
|
+
},
|
|
1582
|
+
];
|
|
1583
|
+
await shellClientPlugin({
|
|
1584
|
+
transport: {
|
|
1585
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
1586
|
+
readConfiguration: () =>
|
|
1587
|
+
Promise.resolve(initializeBotSettingsV1("default")),
|
|
1588
|
+
listRuns: () =>
|
|
1589
|
+
Promise.resolve([
|
|
1590
|
+
{
|
|
1591
|
+
runId: "run-1",
|
|
1592
|
+
input: "Continue",
|
|
1593
|
+
events: [],
|
|
1594
|
+
status: "running",
|
|
1595
|
+
},
|
|
1596
|
+
]),
|
|
1597
|
+
listNotifications: () => Promise.resolve([]),
|
|
1598
|
+
stopRun: (botId, runId, commandId) => {
|
|
1599
|
+
commands.push({ botId, runId, commandId });
|
|
1600
|
+
return Promise.resolve(projections[commands.length - 1]);
|
|
1601
|
+
},
|
|
1602
|
+
},
|
|
1603
|
+
slot: () => () => {},
|
|
1604
|
+
inject: () => {
|
|
1605
|
+
throw new Error("unexpected client provider injection");
|
|
1606
|
+
},
|
|
1607
|
+
provide: (_key, value) => {
|
|
1608
|
+
provided = value as Ref<FrockBotWebData>;
|
|
1609
|
+
return () => {};
|
|
1610
|
+
},
|
|
1611
|
+
});
|
|
1612
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
1613
|
+
provided.value.activeBotId = "default";
|
|
1614
|
+
provided.value.composerContext = "default";
|
|
1615
|
+
await provided.value.loadBotSettings();
|
|
1616
|
+
expect(provided.value.activeRunId).toBe("run-1");
|
|
1617
|
+
|
|
1618
|
+
await provided.value.stopRun();
|
|
1619
|
+
expect(provided.value.activeRun).toMatchObject({
|
|
1620
|
+
runId: "run-1",
|
|
1621
|
+
status: "running",
|
|
1622
|
+
message: "Stop accepted; waiting for durable settlement.",
|
|
1623
|
+
canResume: false,
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
await provided.value.stopRun();
|
|
1627
|
+
expect(provided.value.activeRun).toMatchObject({
|
|
1628
|
+
status: "reconciliation-required",
|
|
1629
|
+
message:
|
|
1630
|
+
"Stop accepted; reconciling the provider outcome before cancelling.",
|
|
1631
|
+
canResume: false,
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
await provided.value.stopRun();
|
|
1635
|
+
expect(provided.value.activeRun).toBeUndefined();
|
|
1636
|
+
expect(provided.value.activeRunId).toBeUndefined();
|
|
1637
|
+
expect(provided.value.messages[1]).toMatchObject({
|
|
1638
|
+
text: "Stopped by an authenticated Stop command.",
|
|
1639
|
+
status: "aborted",
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
// Repeated Stops replay exactly one durable command identifier.
|
|
1643
|
+
expect(commands).toHaveLength(3);
|
|
1644
|
+
expect(new Set(commands.map((command) => command.commandId)).size).toBe(1);
|
|
1645
|
+
expect(commands[0]).toMatchObject({ botId: "default", runId: "run-1" });
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
test("observes an accepted Stop until the durable run is terminal", async () => {
|
|
1649
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
1650
|
+
let lookups = 0;
|
|
1651
|
+
await shellClientPlugin({
|
|
1652
|
+
transport: {
|
|
1653
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
1654
|
+
stopRun: () =>
|
|
1655
|
+
Promise.resolve({
|
|
1656
|
+
runId: "run-1",
|
|
1657
|
+
input: "Continue",
|
|
1658
|
+
events: [],
|
|
1659
|
+
status: "running",
|
|
1660
|
+
stopRequestedAt: "2026-08-30T00:00:01.000Z",
|
|
1661
|
+
}),
|
|
1662
|
+
lookupRun: () => {
|
|
1663
|
+
lookups += 1;
|
|
1664
|
+
return Promise.resolve({
|
|
1665
|
+
runId: "run-1",
|
|
1666
|
+
input: "Continue",
|
|
1667
|
+
events: [],
|
|
1668
|
+
status: "cancelled",
|
|
1669
|
+
stopRequestedAt: "2026-08-30T00:00:01.000Z",
|
|
1670
|
+
failure: "Stopped by an authenticated Stop command.",
|
|
1671
|
+
});
|
|
1672
|
+
},
|
|
1673
|
+
},
|
|
1674
|
+
slot: () => () => {},
|
|
1675
|
+
inject: () => {
|
|
1676
|
+
throw new Error("unexpected client provider injection");
|
|
1677
|
+
},
|
|
1678
|
+
provide: (_key, value) => {
|
|
1679
|
+
provided = value as Ref<FrockBotWebData>;
|
|
1680
|
+
return () => {};
|
|
1681
|
+
},
|
|
1682
|
+
});
|
|
1683
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
1684
|
+
provided.value.activeBotId = "default";
|
|
1685
|
+
provided.value.activeRunId = "run-1";
|
|
1686
|
+
provided.value.activeRun = {
|
|
1687
|
+
runId: "run-1",
|
|
1688
|
+
status: "running",
|
|
1689
|
+
message: "Running",
|
|
1690
|
+
canResume: false,
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
await provided.value.stopRun();
|
|
1694
|
+
|
|
1695
|
+
expect(lookups).toBe(1);
|
|
1696
|
+
expect(provided.value.activeRun).toBeUndefined();
|
|
1697
|
+
expect(provided.value.activeRunId).toBeUndefined();
|
|
1698
|
+
expect(provided.value.messages.at(-1)).toMatchObject({
|
|
1699
|
+
runId: "run-1",
|
|
1700
|
+
text: "Stopped by an authenticated Stop command.",
|
|
1701
|
+
status: "aborted",
|
|
1702
|
+
});
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
test("detaches without commanding the backend when switching Bots", async () => {
|
|
1706
|
+
Object.defineProperty(globalThis, "window", {
|
|
1707
|
+
configurable: true,
|
|
1708
|
+
value: {
|
|
1709
|
+
location: { href: "https://app.example/" },
|
|
1710
|
+
history: { replaceState: () => {} },
|
|
1711
|
+
},
|
|
1712
|
+
});
|
|
1713
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
1714
|
+
let stops = 0;
|
|
1715
|
+
await shellClientPlugin({
|
|
1716
|
+
transport: {
|
|
1717
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
1718
|
+
readConfiguration: () =>
|
|
1719
|
+
Promise.resolve(initializeBotSettingsV1("other")),
|
|
1720
|
+
listRuns: () => Promise.resolve([]),
|
|
1721
|
+
listNotifications: () => Promise.resolve([]),
|
|
1722
|
+
stopRun: () => {
|
|
1723
|
+
stops += 1;
|
|
1724
|
+
return Promise.reject(new Error("must not command a Stop"));
|
|
1725
|
+
},
|
|
1726
|
+
},
|
|
1727
|
+
slot: () => () => {},
|
|
1728
|
+
inject: () => {
|
|
1729
|
+
throw new Error("unexpected client provider injection");
|
|
1730
|
+
},
|
|
1731
|
+
provide: (_key, value) => {
|
|
1732
|
+
provided = value as Ref<FrockBotWebData>;
|
|
1733
|
+
return () => {};
|
|
1734
|
+
},
|
|
1735
|
+
});
|
|
1736
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
1737
|
+
provided.value.activeBotId = "default";
|
|
1738
|
+
provided.value.activeRunId = "run-1";
|
|
1739
|
+
|
|
1740
|
+
await provided.value.abort();
|
|
1741
|
+
await provided.value.selectBot("other");
|
|
1742
|
+
|
|
1743
|
+
expect(stops).toBe(0);
|
|
1744
|
+
expect(provided.value.activeBotId).toBe("other");
|
|
1745
|
+
expect(provided.value.activeRunId).toBeUndefined();
|
|
1746
|
+
});
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
describe("uncertain Turn admission", () => {
|
|
1750
|
+
test("clears retry state and listeners after durable terminal state", async () => {
|
|
1751
|
+
Object.defineProperty(globalThis, "window", {
|
|
1752
|
+
configurable: true,
|
|
1753
|
+
value: { location: { href: "https://app.example/?bot=primary" } },
|
|
1754
|
+
});
|
|
1755
|
+
const originalAddEventListener = AbortSignal.prototype.addEventListener;
|
|
1756
|
+
const originalRemoveEventListener =
|
|
1757
|
+
AbortSignal.prototype.removeEventListener;
|
|
1758
|
+
let outstandingAbortListeners = 0;
|
|
1759
|
+
AbortSignal.prototype.addEventListener = function (
|
|
1760
|
+
type: string,
|
|
1761
|
+
callback: EventListenerOrEventListenerObject,
|
|
1762
|
+
options?: boolean | AddEventListenerOptions,
|
|
1763
|
+
) {
|
|
1764
|
+
if (type === "abort") outstandingAbortListeners += 1;
|
|
1765
|
+
return originalAddEventListener.call(this, type, callback, options);
|
|
1766
|
+
};
|
|
1767
|
+
AbortSignal.prototype.removeEventListener = function (
|
|
1768
|
+
type: string,
|
|
1769
|
+
callback: EventListenerOrEventListenerObject,
|
|
1770
|
+
options?: boolean | EventListenerOptions,
|
|
1771
|
+
) {
|
|
1772
|
+
if (type === "abort") outstandingAbortListeners -= 1;
|
|
1773
|
+
return originalRemoveEventListener.call(this, type, callback, options);
|
|
1774
|
+
};
|
|
1775
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
1776
|
+
let lookups = 0;
|
|
1777
|
+
await shellClientPlugin({
|
|
1778
|
+
transport: {
|
|
1779
|
+
turn: () => Promise.reject(new Error("response lost")),
|
|
1780
|
+
lookupRun: (_botId, runId) => {
|
|
1781
|
+
lookups += 1;
|
|
1782
|
+
if (lookups === 1) {
|
|
1783
|
+
return Promise.reject(new Error("lookup unavailable"));
|
|
1784
|
+
}
|
|
1785
|
+
return Promise.resolve({
|
|
1786
|
+
runId,
|
|
1787
|
+
admittedAt: "2026-08-29T00:00:00.000Z",
|
|
1788
|
+
input: "continue",
|
|
1789
|
+
status: lookups === 2 ? "running" : "completed",
|
|
1790
|
+
events: [],
|
|
1791
|
+
...(lookups === 2 ? {} : { responseText: "Done durably" }),
|
|
1792
|
+
});
|
|
1793
|
+
},
|
|
1794
|
+
fenceRunAdmission: () =>
|
|
1795
|
+
Promise.reject(new Error("fence must not be called")),
|
|
1796
|
+
},
|
|
1797
|
+
slot: () => () => {},
|
|
1798
|
+
inject: () => {
|
|
1799
|
+
throw new Error("unexpected client provider injection");
|
|
1800
|
+
},
|
|
1801
|
+
provide: (_key, value) => {
|
|
1802
|
+
provided = value as Ref<FrockBotWebData>;
|
|
1803
|
+
return () => {};
|
|
1804
|
+
},
|
|
1805
|
+
});
|
|
1806
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
1807
|
+
provided.value.activeBotId = "primary";
|
|
1808
|
+
provided.value.composerContext = "primary";
|
|
1809
|
+
|
|
1810
|
+
let result: Awaited<ReturnType<FrockBotWebData["sendPrompt"]>>;
|
|
1811
|
+
try {
|
|
1812
|
+
result = await provided.value.sendPrompt("continue");
|
|
1813
|
+
} finally {
|
|
1814
|
+
AbortSignal.prototype.addEventListener = originalAddEventListener;
|
|
1815
|
+
AbortSignal.prototype.removeEventListener = originalRemoveEventListener;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
expect(result.accepted).toBe(true);
|
|
1819
|
+
expect(lookups).toBe(3);
|
|
1820
|
+
expect(provided.value.activeRunId).toBeUndefined();
|
|
1821
|
+
expect(provided.value.activeRun).toBeUndefined();
|
|
1822
|
+
expect(provided.value.settingsError).toBeUndefined();
|
|
1823
|
+
expect(outstandingAbortListeners).toBe(0);
|
|
1824
|
+
expect(provided.value.messages.at(-1)).toMatchObject({
|
|
1825
|
+
text: "Done durably",
|
|
1826
|
+
status: "completed",
|
|
1827
|
+
});
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
test("detaches a rejected Turn without starting a stale observer after Bot switch", async () => {
|
|
1831
|
+
Object.defineProperty(globalThis, "window", {
|
|
1832
|
+
configurable: true,
|
|
1833
|
+
value: {
|
|
1834
|
+
location: { href: "https://app.example/?bot=primary" },
|
|
1835
|
+
history: { replaceState: () => undefined },
|
|
1836
|
+
},
|
|
1837
|
+
});
|
|
1838
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
1839
|
+
let lookups = 0;
|
|
1840
|
+
await shellClientPlugin({
|
|
1841
|
+
transport: {
|
|
1842
|
+
turn: (_botId, _text, signal) =>
|
|
1843
|
+
new Promise((_resolve, reject) => {
|
|
1844
|
+
signal.addEventListener(
|
|
1845
|
+
"abort",
|
|
1846
|
+
() => reject(new DOMException("switched", "AbortError")),
|
|
1847
|
+
{ once: true },
|
|
1848
|
+
);
|
|
1849
|
+
}),
|
|
1850
|
+
readConfiguration: (query) =>
|
|
1851
|
+
Promise.resolve(
|
|
1852
|
+
initializeBotSettingsV1(
|
|
1853
|
+
"botId" in query ? query.botId : "secondary",
|
|
1854
|
+
),
|
|
1855
|
+
),
|
|
1856
|
+
lookupRun: () => {
|
|
1857
|
+
lookups += 1;
|
|
1858
|
+
return Promise.resolve(undefined);
|
|
1859
|
+
},
|
|
1860
|
+
fenceRunAdmission: () => Promise.resolve(undefined),
|
|
1861
|
+
},
|
|
1862
|
+
slot: () => () => {},
|
|
1863
|
+
inject: () => {
|
|
1864
|
+
throw new Error("unexpected client provider injection");
|
|
1865
|
+
},
|
|
1866
|
+
provide: (_key, value) => {
|
|
1867
|
+
provided = value as Ref<FrockBotWebData>;
|
|
1868
|
+
return () => {};
|
|
1869
|
+
},
|
|
1870
|
+
});
|
|
1871
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
1872
|
+
provided.value.activeBotId = "primary";
|
|
1873
|
+
provided.value.composerContext = "primary";
|
|
1874
|
+
|
|
1875
|
+
const oldTurn = provided.value.sendPrompt("continue");
|
|
1876
|
+
await Promise.resolve();
|
|
1877
|
+
await provided.value.selectBot("secondary");
|
|
1878
|
+
expect(await oldTurn).toMatchObject({ accepted: true });
|
|
1879
|
+
expect(lookups).toBe(0);
|
|
1880
|
+
expect(provided.value.activeBotId).toBe("secondary");
|
|
1881
|
+
expect(provided.value.activeRunId).toBeUndefined();
|
|
1882
|
+
expect(provided.value.activeRun).toBeUndefined();
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
test("continues admission reconciliation after stopping the local request", async () => {
|
|
1886
|
+
Object.defineProperty(globalThis, "window", {
|
|
1887
|
+
configurable: true,
|
|
1888
|
+
value: { location: { href: "https://app.example/?bot=primary" } },
|
|
1889
|
+
});
|
|
1890
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
1891
|
+
let lookups = 0;
|
|
1892
|
+
await shellClientPlugin({
|
|
1893
|
+
transport: {
|
|
1894
|
+
turn: (_botId, _text, signal) =>
|
|
1895
|
+
new Promise((_resolve, reject) => {
|
|
1896
|
+
signal.addEventListener(
|
|
1897
|
+
"abort",
|
|
1898
|
+
() => reject(new DOMException("stopped", "AbortError")),
|
|
1899
|
+
{ once: true },
|
|
1900
|
+
);
|
|
1901
|
+
}),
|
|
1902
|
+
lookupRun: (_botId, runId) => {
|
|
1903
|
+
lookups += 1;
|
|
1904
|
+
return Promise.resolve({
|
|
1905
|
+
runId,
|
|
1906
|
+
admittedAt: "2026-08-29T00:00:00.000Z",
|
|
1907
|
+
input: "continue",
|
|
1908
|
+
status: lookups === 1 ? "running" : "completed",
|
|
1909
|
+
events: [],
|
|
1910
|
+
...(lookups === 1 ? {} : { responseText: "Finished later" }),
|
|
1911
|
+
});
|
|
1912
|
+
},
|
|
1913
|
+
fenceRunAdmission: () => Promise.resolve(undefined),
|
|
1914
|
+
},
|
|
1915
|
+
slot: () => () => {},
|
|
1916
|
+
inject: () => {
|
|
1917
|
+
throw new Error("unexpected client provider injection");
|
|
1918
|
+
},
|
|
1919
|
+
provide: (_key, value) => {
|
|
1920
|
+
provided = value as Ref<FrockBotWebData>;
|
|
1921
|
+
return () => {};
|
|
1922
|
+
},
|
|
1923
|
+
});
|
|
1924
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
1925
|
+
provided.value.activeBotId = "primary";
|
|
1926
|
+
provided.value.composerContext = "primary";
|
|
1927
|
+
|
|
1928
|
+
const pending = provided.value.sendPrompt("continue");
|
|
1929
|
+
await Promise.resolve();
|
|
1930
|
+
await provided.value.abort();
|
|
1931
|
+
const result = await pending;
|
|
1932
|
+
|
|
1933
|
+
expect(result.accepted).toBe(true);
|
|
1934
|
+
expect(lookups).toBe(2);
|
|
1935
|
+
expect(provided.value.activeRunId).toBeUndefined();
|
|
1936
|
+
expect(provided.value.activeRun).toBeUndefined();
|
|
1937
|
+
expect(provided.value.messages.at(-1)).toMatchObject({
|
|
1938
|
+
text: "Finished later",
|
|
1939
|
+
status: "completed",
|
|
1940
|
+
});
|
|
1941
|
+
});
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
describe("Connection operation reconciliation", () => {
|
|
1945
|
+
test("reuses API-key command identity after an ambiguous response loss", async () => {
|
|
1946
|
+
installMemoryStorage();
|
|
1947
|
+
Object.defineProperty(globalThis, "window", {
|
|
1948
|
+
configurable: true,
|
|
1949
|
+
value: { location: { href: "https://app.example/?bot=primary" } },
|
|
1950
|
+
});
|
|
1951
|
+
const commandIds: string[] = [];
|
|
1952
|
+
const requestBodies: string[] = [];
|
|
1953
|
+
let attempts = 0;
|
|
1954
|
+
const mount = async (): Promise<Ref<FrockBotWebData>> => {
|
|
1955
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
1956
|
+
await shellClientPlugin({
|
|
1957
|
+
transport: {
|
|
1958
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
1959
|
+
readAuthenticatedUserId: () => Promise.resolve("user-a"),
|
|
1960
|
+
executeConnection: (command) => {
|
|
1961
|
+
commandIds.push(command.commandId);
|
|
1962
|
+
requestBodies.push(JSON.stringify(command));
|
|
1963
|
+
attempts += 1;
|
|
1964
|
+
if (attempts === 1) {
|
|
1965
|
+
return Promise.reject(new Error("response lost"));
|
|
1966
|
+
}
|
|
1967
|
+
return Promise.resolve({
|
|
1968
|
+
schemaVersion: 1,
|
|
1969
|
+
commandId: command.commandId,
|
|
1970
|
+
connectionId: "connection-1",
|
|
1971
|
+
status: "applied",
|
|
1972
|
+
});
|
|
1973
|
+
},
|
|
1974
|
+
},
|
|
1975
|
+
slot: () => () => {},
|
|
1976
|
+
inject: () => {
|
|
1977
|
+
throw new Error("unexpected client provider injection");
|
|
1978
|
+
},
|
|
1979
|
+
provide: (_key, value) => {
|
|
1980
|
+
provided = value as Ref<FrockBotWebData>;
|
|
1981
|
+
return () => {};
|
|
1982
|
+
},
|
|
1983
|
+
});
|
|
1984
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
1985
|
+
return provided;
|
|
1986
|
+
};
|
|
1987
|
+
const input = {
|
|
1988
|
+
packageId: "provider-ollama-cloud",
|
|
1989
|
+
connectionTypeId: "ollama-cloud-account",
|
|
1990
|
+
label: "Work",
|
|
1991
|
+
apiKey: "super-secret-api-key",
|
|
1992
|
+
};
|
|
1993
|
+
|
|
1994
|
+
const first = await mount();
|
|
1995
|
+
await expect(first.value.createApiKeyConnection(input)).rejects.toThrow(
|
|
1996
|
+
"response lost",
|
|
1997
|
+
);
|
|
1998
|
+
const retained =
|
|
1999
|
+
globalThis.localStorage.getItem(
|
|
2000
|
+
"frockbot.pending-connection-operations.v1",
|
|
2001
|
+
) ?? "";
|
|
2002
|
+
expect(retained).not.toContain(input.apiKey);
|
|
2003
|
+
for (const derived of await secretDerivations(input.apiKey)) {
|
|
2004
|
+
expect(retained).not.toContain(derived);
|
|
2005
|
+
}
|
|
2006
|
+
const second = await mount();
|
|
2007
|
+
await second.value.createApiKeyConnection(input);
|
|
2008
|
+
|
|
2009
|
+
expect(commandIds).toHaveLength(2);
|
|
2010
|
+
expect(new Set(commandIds).size).toBe(1);
|
|
2011
|
+
expect(requestBodies).toHaveLength(2);
|
|
2012
|
+
for (const body of requestBodies) {
|
|
2013
|
+
const envelope = JSON.parse(body) as Record<string, unknown>;
|
|
2014
|
+
expect(envelope.apiKey).toBe(input.apiKey);
|
|
2015
|
+
const withoutSecret = JSON.stringify({ ...envelope, apiKey: undefined });
|
|
2016
|
+
for (const derived of await secretDerivations(input.apiKey)) {
|
|
2017
|
+
expect(withoutSecret).not.toContain(derived);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
test("mints a fresh operation identity for a settled submission", async () => {
|
|
2023
|
+
installMemoryStorage();
|
|
2024
|
+
Object.defineProperty(globalThis, "window", {
|
|
2025
|
+
configurable: true,
|
|
2026
|
+
value: { location: { href: "https://app.example/?bot=primary" } },
|
|
2027
|
+
});
|
|
2028
|
+
const commandIds: string[] = [];
|
|
2029
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2030
|
+
await shellClientPlugin({
|
|
2031
|
+
transport: {
|
|
2032
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
2033
|
+
readAuthenticatedUserId: () => Promise.resolve("user-a"),
|
|
2034
|
+
executeConnection: (command) => {
|
|
2035
|
+
commandIds.push(command.commandId);
|
|
2036
|
+
return Promise.resolve({
|
|
2037
|
+
schemaVersion: 1,
|
|
2038
|
+
commandId: command.commandId,
|
|
2039
|
+
connectionId: "connection-1",
|
|
2040
|
+
status: "applied",
|
|
2041
|
+
});
|
|
2042
|
+
},
|
|
2043
|
+
},
|
|
2044
|
+
slot: () => () => {},
|
|
2045
|
+
inject: () => {
|
|
2046
|
+
throw new Error("unexpected client provider injection");
|
|
2047
|
+
},
|
|
2048
|
+
provide: (_key, value) => {
|
|
2049
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2050
|
+
return () => {};
|
|
2051
|
+
},
|
|
2052
|
+
});
|
|
2053
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2054
|
+
const input = {
|
|
2055
|
+
packageId: "provider-ollama-cloud",
|
|
2056
|
+
connectionTypeId: "ollama-cloud-account",
|
|
2057
|
+
label: "Work",
|
|
2058
|
+
apiKey: "super-secret-api-key",
|
|
2059
|
+
};
|
|
2060
|
+
|
|
2061
|
+
await provided.value.createApiKeyConnection(input);
|
|
2062
|
+
await provided.value.createApiKeyConnection({
|
|
2063
|
+
...input,
|
|
2064
|
+
apiKey: "another-secret-api-key",
|
|
2065
|
+
});
|
|
2066
|
+
|
|
2067
|
+
expect(commandIds).toHaveLength(2);
|
|
2068
|
+
expect(commandIds[1]).not.toBe(commandIds[0]);
|
|
2069
|
+
expect(
|
|
2070
|
+
globalThis.localStorage.getItem(
|
|
2071
|
+
"frockbot.pending-connection-operations.v1",
|
|
2072
|
+
),
|
|
2073
|
+
).toBe("{}");
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
test("retires a lost rotation from its durable command receipt", async () => {
|
|
2077
|
+
installMemoryStorage();
|
|
2078
|
+
Object.defineProperty(globalThis, "window", {
|
|
2079
|
+
configurable: true,
|
|
2080
|
+
value: { location: { href: "https://app.example/?bot=primary" } },
|
|
2081
|
+
});
|
|
2082
|
+
let generation = "generation-1";
|
|
2083
|
+
const commandIds: string[] = [];
|
|
2084
|
+
const receipts = new Map<
|
|
2085
|
+
string,
|
|
2086
|
+
{
|
|
2087
|
+
schemaVersion: 1;
|
|
2088
|
+
commandId: string;
|
|
2089
|
+
connectionId: string;
|
|
2090
|
+
status: "applied";
|
|
2091
|
+
}
|
|
2092
|
+
>();
|
|
2093
|
+
let lostResponses = 2;
|
|
2094
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2095
|
+
await shellClientPlugin({
|
|
2096
|
+
transport: {
|
|
2097
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
2098
|
+
readAuthenticatedUserId: () => Promise.resolve("user-a"),
|
|
2099
|
+
readConfiguration: () =>
|
|
2100
|
+
Promise.resolve({
|
|
2101
|
+
schemaVersion: 1,
|
|
2102
|
+
revision: 1,
|
|
2103
|
+
profile: { name: "User" },
|
|
2104
|
+
packages: [],
|
|
2105
|
+
connections: [
|
|
2106
|
+
{
|
|
2107
|
+
connectionId: "connection-1",
|
|
2108
|
+
packageId: "provider-ollama-cloud",
|
|
2109
|
+
connectionTypeId: "ollama-cloud-account",
|
|
2110
|
+
displayName: "Work",
|
|
2111
|
+
state: "ready",
|
|
2112
|
+
providerType: "ollama-cloud",
|
|
2113
|
+
generation,
|
|
2114
|
+
safeMetadata: {},
|
|
2115
|
+
},
|
|
2116
|
+
],
|
|
2117
|
+
}),
|
|
2118
|
+
executeConnection: (command) => {
|
|
2119
|
+
commandIds.push(command.commandId);
|
|
2120
|
+
generation = `generation-${commandIds.length + 1}`;
|
|
2121
|
+
receipts.set(command.commandId, {
|
|
2122
|
+
schemaVersion: 1,
|
|
2123
|
+
commandId: command.commandId,
|
|
2124
|
+
connectionId: "connection-1",
|
|
2125
|
+
status: "applied",
|
|
2126
|
+
});
|
|
2127
|
+
if (lostResponses > 0) {
|
|
2128
|
+
lostResponses -= 1;
|
|
2129
|
+
return Promise.reject(new Error("response lost"));
|
|
2130
|
+
}
|
|
2131
|
+
return Promise.resolve(receipts.get(command.commandId)!);
|
|
2132
|
+
},
|
|
2133
|
+
lookupConnectionCommand: (_packageId, commandId) =>
|
|
2134
|
+
Promise.resolve(receipts.get(commandId)),
|
|
2135
|
+
},
|
|
2136
|
+
slot: () => () => {},
|
|
2137
|
+
inject: () => {
|
|
2138
|
+
throw new Error("unexpected client provider injection");
|
|
2139
|
+
},
|
|
2140
|
+
provide: (_key, value) => {
|
|
2141
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2142
|
+
return () => {};
|
|
2143
|
+
},
|
|
2144
|
+
});
|
|
2145
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2146
|
+
await provided.value.loadUserSettings();
|
|
2147
|
+
|
|
2148
|
+
await expect(
|
|
2149
|
+
provided.value.rotateApiKeyConnection("connection-1", "key-a"),
|
|
2150
|
+
).rejects.toThrow("response lost");
|
|
2151
|
+
const lostCommandId = commandIds[0];
|
|
2152
|
+
await expect(
|
|
2153
|
+
provided.value.rotateApiKeyConnection("connection-1", "key-b"),
|
|
2154
|
+
).rejects.toThrow("response lost");
|
|
2155
|
+
await provided.value.rotateApiKeyConnection("connection-1", "key-a");
|
|
2156
|
+
|
|
2157
|
+
expect(commandIds).toHaveLength(3);
|
|
2158
|
+
expect(commandIds[2]).not.toBe(lostCommandId);
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
test("retires a lost API-key create from its durable Connection projection", async () => {
|
|
2162
|
+
installMemoryStorage();
|
|
2163
|
+
Object.defineProperty(globalThis, "window", {
|
|
2164
|
+
configurable: true,
|
|
2165
|
+
value: { location: { href: "https://app.example/?bot=primary" } },
|
|
2166
|
+
});
|
|
2167
|
+
const commandIds: string[] = [];
|
|
2168
|
+
let createdCommandId: string | undefined;
|
|
2169
|
+
let attempts = 0;
|
|
2170
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2171
|
+
await shellClientPlugin({
|
|
2172
|
+
transport: {
|
|
2173
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
2174
|
+
readAuthenticatedUserId: () => Promise.resolve("user-a"),
|
|
2175
|
+
readConfiguration: () =>
|
|
2176
|
+
Promise.resolve({
|
|
2177
|
+
schemaVersion: 1,
|
|
2178
|
+
revision: 1,
|
|
2179
|
+
profile: { name: "User" },
|
|
2180
|
+
packages: [],
|
|
2181
|
+
connections: createdCommandId
|
|
2182
|
+
? [
|
|
2183
|
+
{
|
|
2184
|
+
connectionId: "connection-created",
|
|
2185
|
+
packageId: "provider-ollama-cloud",
|
|
2186
|
+
connectionTypeId: "ollama-cloud-account",
|
|
2187
|
+
displayName: "Work",
|
|
2188
|
+
state: "ready",
|
|
2189
|
+
safeMetadata: { creationCommandId: createdCommandId },
|
|
2190
|
+
},
|
|
2191
|
+
]
|
|
2192
|
+
: [],
|
|
2193
|
+
}),
|
|
2194
|
+
executeConnection: (command) => {
|
|
2195
|
+
commandIds.push(command.commandId);
|
|
2196
|
+
attempts += 1;
|
|
2197
|
+
if (attempts === 1) {
|
|
2198
|
+
createdCommandId = command.commandId;
|
|
2199
|
+
return Promise.reject(new Error("response lost"));
|
|
2200
|
+
}
|
|
2201
|
+
return Promise.resolve({
|
|
2202
|
+
schemaVersion: 1,
|
|
2203
|
+
commandId: command.commandId,
|
|
2204
|
+
connectionId: "connection-recreated",
|
|
2205
|
+
status: "applied",
|
|
2206
|
+
});
|
|
2207
|
+
},
|
|
2208
|
+
},
|
|
2209
|
+
slot: () => () => {},
|
|
2210
|
+
inject: () => {
|
|
2211
|
+
throw new Error("unexpected client provider injection");
|
|
2212
|
+
},
|
|
2213
|
+
provide: (_key, value) => {
|
|
2214
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2215
|
+
return () => {};
|
|
2216
|
+
},
|
|
2217
|
+
});
|
|
2218
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2219
|
+
const input = {
|
|
2220
|
+
packageId: "provider-ollama-cloud",
|
|
2221
|
+
connectionTypeId: "ollama-cloud-account",
|
|
2222
|
+
label: "Work",
|
|
2223
|
+
apiKey: "super-secret-api-key",
|
|
2224
|
+
};
|
|
2225
|
+
|
|
2226
|
+
await expect(provided.value.createApiKeyConnection(input)).rejects.toThrow(
|
|
2227
|
+
"response lost",
|
|
2228
|
+
);
|
|
2229
|
+
await provided.value.loadUserSettings();
|
|
2230
|
+
expect(
|
|
2231
|
+
globalThis.localStorage.getItem(
|
|
2232
|
+
"frockbot.pending-connection-operations.v1",
|
|
2233
|
+
),
|
|
2234
|
+
).toBe("{}");
|
|
2235
|
+
createdCommandId = undefined;
|
|
2236
|
+
await provided.value.createApiKeyConnection(input);
|
|
2237
|
+
|
|
2238
|
+
expect(commandIds).toHaveLength(2);
|
|
2239
|
+
expect(commandIds[1]).not.toBe(commandIds[0]);
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
test("surfaces failed label, disable, and disconnect receipts", async () => {
|
|
2243
|
+
const commands: string[] = [];
|
|
2244
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2245
|
+
await shellClientPlugin({
|
|
2246
|
+
transport: {
|
|
2247
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
2248
|
+
executeConnection: (command) => {
|
|
2249
|
+
commands.push(command.type);
|
|
2250
|
+
return Promise.resolve({
|
|
2251
|
+
schemaVersion: 1,
|
|
2252
|
+
commandId: command.commandId,
|
|
2253
|
+
connectionId:
|
|
2254
|
+
"connectionId" in command
|
|
2255
|
+
? command.connectionId
|
|
2256
|
+
: "created-connection",
|
|
2257
|
+
status: "failed",
|
|
2258
|
+
});
|
|
2259
|
+
},
|
|
2260
|
+
},
|
|
2261
|
+
slot: () => () => {},
|
|
2262
|
+
inject: () => {
|
|
2263
|
+
throw new Error("unexpected client provider injection");
|
|
2264
|
+
},
|
|
2265
|
+
provide: (_key, value) => {
|
|
2266
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2267
|
+
return () => {};
|
|
2268
|
+
},
|
|
2269
|
+
});
|
|
2270
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2271
|
+
|
|
2272
|
+
await expect(
|
|
2273
|
+
provided.value.updateConnectionLabel("connection-1", "Renamed"),
|
|
2274
|
+
).rejects.toThrow("Connection label update failed");
|
|
2275
|
+
await expect(
|
|
2276
|
+
provided.value.setConnectionEnabled("connection-1", false),
|
|
2277
|
+
).rejects.toThrow("Connection state update failed");
|
|
2278
|
+
await expect(
|
|
2279
|
+
provided.value.disconnectConnection("connection-1"),
|
|
2280
|
+
).rejects.toThrow("Connection revocation failed");
|
|
2281
|
+
expect(commands).toEqual([
|
|
2282
|
+
"connection/update-label",
|
|
2283
|
+
"connection/set-enabled",
|
|
2284
|
+
"connection/disconnect",
|
|
2285
|
+
]);
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
test("reuses the desktop command ID and nonce until durable settlement", async () => {
|
|
2289
|
+
installMemoryStorage();
|
|
2290
|
+
Object.defineProperty(globalThis, "window", {
|
|
2291
|
+
configurable: true,
|
|
2292
|
+
value: {
|
|
2293
|
+
location: { href: "https://app.example/?bot=primary" },
|
|
2294
|
+
frockbotDesktop: {},
|
|
2295
|
+
},
|
|
2296
|
+
});
|
|
2297
|
+
const commandIds: string[] = [];
|
|
2298
|
+
const nativeReturnNonces: Array<string | undefined> = [];
|
|
2299
|
+
let attempts = 0;
|
|
2300
|
+
const mount = async (): Promise<Ref<FrockBotWebData>> => {
|
|
2301
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2302
|
+
await shellClientPlugin({
|
|
2303
|
+
transport: {
|
|
2304
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
2305
|
+
readAuthenticatedUserId: () => Promise.resolve("user-a"),
|
|
2306
|
+
startConnection: (input) => {
|
|
2307
|
+
commandIds.push(input.commandId);
|
|
2308
|
+
nativeReturnNonces.push(input.nativeReturnNonce);
|
|
2309
|
+
attempts += 1;
|
|
2310
|
+
if (attempts === 1)
|
|
2311
|
+
return Promise.reject(new Error("response lost"));
|
|
2312
|
+
return Promise.resolve({
|
|
2313
|
+
schemaVersion: 1 as const,
|
|
2314
|
+
status: "authorization-required" as const,
|
|
2315
|
+
connectionId: input.commandId,
|
|
2316
|
+
redirectUrl: "https://connect.example/authorize",
|
|
2317
|
+
expiresAt: new Date(0).toISOString(),
|
|
2318
|
+
});
|
|
2319
|
+
},
|
|
2320
|
+
},
|
|
2321
|
+
slot: () => () => {},
|
|
2322
|
+
inject: () => {
|
|
2323
|
+
throw new Error("unexpected client provider injection");
|
|
2324
|
+
},
|
|
2325
|
+
provide: (_key, value) => {
|
|
2326
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2327
|
+
return () => {};
|
|
2328
|
+
},
|
|
2329
|
+
});
|
|
2330
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2331
|
+
return provided;
|
|
2332
|
+
};
|
|
2333
|
+
const first = await mount();
|
|
2334
|
+
|
|
2335
|
+
await expect(
|
|
2336
|
+
first.value.startConnection("composio", "gmail"),
|
|
2337
|
+
).rejects.toThrow("response lost");
|
|
2338
|
+
const afterRefresh = await mount();
|
|
2339
|
+
await afterRefresh.value.startConnection("composio", "gmail");
|
|
2340
|
+
const afterLinkExpiry = await mount();
|
|
2341
|
+
await afterLinkExpiry.value.startConnection("composio", "gmail");
|
|
2342
|
+
|
|
2343
|
+
expect(commandIds).toHaveLength(3);
|
|
2344
|
+
expect(new Set(commandIds).size).toBe(1);
|
|
2345
|
+
expect(nativeReturnNonces[0]).toBeString();
|
|
2346
|
+
expect(new Set(nativeReturnNonces).size).toBe(1);
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
test("shares one uncertain Connection identity across concurrent tabs", async () => {
|
|
2350
|
+
installMemoryStorage();
|
|
2351
|
+
Object.defineProperty(globalThis, "window", {
|
|
2352
|
+
configurable: true,
|
|
2353
|
+
value: { location: { href: "https://app.example/?bot=primary" } },
|
|
2354
|
+
});
|
|
2355
|
+
const commandIds: string[] = [];
|
|
2356
|
+
const mount = async (): Promise<Ref<FrockBotWebData>> => {
|
|
2357
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2358
|
+
await shellClientPlugin({
|
|
2359
|
+
transport: {
|
|
2360
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
2361
|
+
readAuthenticatedUserId: () => Promise.resolve("user-a"),
|
|
2362
|
+
startConnection: (input) => {
|
|
2363
|
+
commandIds.push(input.commandId);
|
|
2364
|
+
return Promise.reject(new Error("response lost"));
|
|
2365
|
+
},
|
|
2366
|
+
},
|
|
2367
|
+
slot: () => () => {},
|
|
2368
|
+
inject: () => {
|
|
2369
|
+
throw new Error("unexpected client provider injection");
|
|
2370
|
+
},
|
|
2371
|
+
provide: (_key, value) => {
|
|
2372
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2373
|
+
return () => {};
|
|
2374
|
+
},
|
|
2375
|
+
});
|
|
2376
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2377
|
+
return provided;
|
|
2378
|
+
};
|
|
2379
|
+
const [firstTab, secondTab] = await Promise.all([mount(), mount()]);
|
|
2380
|
+
|
|
2381
|
+
const results = await Promise.allSettled([
|
|
2382
|
+
firstTab.value.startConnection("composio", "gmail"),
|
|
2383
|
+
secondTab.value.startConnection("composio", "gmail"),
|
|
2384
|
+
]);
|
|
2385
|
+
|
|
2386
|
+
expect(results.map((result) => result.status)).toEqual([
|
|
2387
|
+
"rejected",
|
|
2388
|
+
"rejected",
|
|
2389
|
+
]);
|
|
2390
|
+
expect(commandIds).toHaveLength(2);
|
|
2391
|
+
expect(new Set(commandIds).size).toBe(1);
|
|
2392
|
+
});
|
|
2393
|
+
|
|
2394
|
+
test("does not reuse desktop authorization identity across users", async () => {
|
|
2395
|
+
installMemoryStorage();
|
|
2396
|
+
Object.defineProperty(globalThis, "window", {
|
|
2397
|
+
configurable: true,
|
|
2398
|
+
value: {
|
|
2399
|
+
location: { href: "https://app.example/?bot=primary" },
|
|
2400
|
+
frockbotDesktop: {},
|
|
2401
|
+
},
|
|
2402
|
+
});
|
|
2403
|
+
const attempts: Array<{
|
|
2404
|
+
commandId: string;
|
|
2405
|
+
nativeReturnNonce?: string;
|
|
2406
|
+
}> = [];
|
|
2407
|
+
const mount = async (userId: string): Promise<Ref<FrockBotWebData>> => {
|
|
2408
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2409
|
+
await shellClientPlugin({
|
|
2410
|
+
transport: {
|
|
2411
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
2412
|
+
readAuthenticatedUserId: () => Promise.resolve(userId),
|
|
2413
|
+
startConnection: (input) => {
|
|
2414
|
+
attempts.push({
|
|
2415
|
+
commandId: input.commandId,
|
|
2416
|
+
nativeReturnNonce: input.nativeReturnNonce,
|
|
2417
|
+
});
|
|
2418
|
+
return Promise.reject(new Error("response lost"));
|
|
2419
|
+
},
|
|
2420
|
+
},
|
|
2421
|
+
slot: () => () => {},
|
|
2422
|
+
inject: () => {
|
|
2423
|
+
throw new Error("unexpected client provider injection");
|
|
2424
|
+
},
|
|
2425
|
+
provide: (_key, value) => {
|
|
2426
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2427
|
+
return () => {};
|
|
2428
|
+
},
|
|
2429
|
+
});
|
|
2430
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2431
|
+
return provided;
|
|
2432
|
+
};
|
|
2433
|
+
|
|
2434
|
+
const first = await mount("user-a");
|
|
2435
|
+
await expect(
|
|
2436
|
+
first.value.startConnection("composio", "gmail"),
|
|
2437
|
+
).rejects.toThrow("response lost");
|
|
2438
|
+
const second = await mount("user-b");
|
|
2439
|
+
await expect(
|
|
2440
|
+
second.value.startConnection("composio", "gmail"),
|
|
2441
|
+
).rejects.toThrow("response lost");
|
|
2442
|
+
|
|
2443
|
+
expect(attempts).toHaveLength(2);
|
|
2444
|
+
expect(attempts[1]?.commandId).not.toBe(attempts[0]?.commandId);
|
|
2445
|
+
expect(attempts[1]?.nativeReturnNonce).not.toBe(
|
|
2446
|
+
attempts[0]?.nativeReturnNonce,
|
|
2447
|
+
);
|
|
2448
|
+
});
|
|
2449
|
+
|
|
2450
|
+
test("validates browser authorization targets before opening them", async () => {
|
|
2451
|
+
installMemoryStorage();
|
|
2452
|
+
const opened: string[] = [];
|
|
2453
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2454
|
+
await shellClientPlugin({
|
|
2455
|
+
transport: {
|
|
2456
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
2457
|
+
openExternalAuthorization: (url) => {
|
|
2458
|
+
opened.push(url);
|
|
2459
|
+
return Promise.resolve();
|
|
2460
|
+
},
|
|
2461
|
+
},
|
|
2462
|
+
slot: () => () => {},
|
|
2463
|
+
inject: () => {
|
|
2464
|
+
throw new Error("unexpected client provider injection");
|
|
2465
|
+
},
|
|
2466
|
+
provide: (_key, value) => {
|
|
2467
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2468
|
+
return () => {};
|
|
2469
|
+
},
|
|
2470
|
+
});
|
|
2471
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2472
|
+
|
|
2473
|
+
await provided.value.openConnectionAuthorization(
|
|
2474
|
+
"https://connect.example/authorize",
|
|
2475
|
+
);
|
|
2476
|
+
await expect(
|
|
2477
|
+
provided.value.openConnectionAuthorization(
|
|
2478
|
+
"https://connect.example/authorize#unsafe",
|
|
2479
|
+
),
|
|
2480
|
+
).rejects.toThrow("invalid external authorization URL");
|
|
2481
|
+
|
|
2482
|
+
expect(opened).toEqual(["https://connect.example/authorize"]);
|
|
2483
|
+
});
|
|
2484
|
+
|
|
2485
|
+
test("retires a settled callback operation before later revocation", async () => {
|
|
2486
|
+
installMemoryStorage();
|
|
2487
|
+
const commandIds: string[] = [];
|
|
2488
|
+
let connectionState: "ready" | "revoked" | undefined;
|
|
2489
|
+
let connectionId: string | undefined;
|
|
2490
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2491
|
+
await shellClientPlugin({
|
|
2492
|
+
transport: {
|
|
2493
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
2494
|
+
readAuthenticatedUserId: () => Promise.resolve("user-a"),
|
|
2495
|
+
startConnection: (input) => {
|
|
2496
|
+
commandIds.push(input.commandId);
|
|
2497
|
+
connectionId = input.commandId;
|
|
2498
|
+
return Promise.resolve({
|
|
2499
|
+
schemaVersion: 1 as const,
|
|
2500
|
+
status: "authorization-required" as const,
|
|
2501
|
+
connectionId: input.commandId,
|
|
2502
|
+
redirectUrl: "https://connect.example/authorize",
|
|
2503
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
2504
|
+
});
|
|
2505
|
+
},
|
|
2506
|
+
readConfiguration: () =>
|
|
2507
|
+
Promise.resolve({
|
|
2508
|
+
schemaVersion: 1,
|
|
2509
|
+
revision: 1,
|
|
2510
|
+
profile: { name: "User" },
|
|
2511
|
+
packages: [],
|
|
2512
|
+
connections:
|
|
2513
|
+
connectionState && connectionId
|
|
2514
|
+
? [
|
|
2515
|
+
{
|
|
2516
|
+
connectionId,
|
|
2517
|
+
packageId: "composio",
|
|
2518
|
+
connectionTypeId: "gmail",
|
|
2519
|
+
displayName: "Gmail",
|
|
2520
|
+
state: connectionState,
|
|
2521
|
+
safeMetadata: {},
|
|
2522
|
+
},
|
|
2523
|
+
]
|
|
2524
|
+
: [],
|
|
2525
|
+
}),
|
|
2526
|
+
},
|
|
2527
|
+
slot: () => () => {},
|
|
2528
|
+
inject: () => {
|
|
2529
|
+
throw new Error("unexpected client provider injection");
|
|
2530
|
+
},
|
|
2531
|
+
provide: (_key, value) => {
|
|
2532
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2533
|
+
return () => {};
|
|
2534
|
+
},
|
|
2535
|
+
});
|
|
2536
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2537
|
+
|
|
2538
|
+
await provided.value.startConnection("composio", "gmail");
|
|
2539
|
+
connectionState = "ready";
|
|
2540
|
+
await provided.value.loadUserSettings();
|
|
2541
|
+
connectionState = "revoked";
|
|
2542
|
+
await provided.value.loadUserSettings();
|
|
2543
|
+
await provided.value.startConnection("composio", "gmail");
|
|
2544
|
+
|
|
2545
|
+
expect(commandIds).toHaveLength(2);
|
|
2546
|
+
expect(commandIds[1]).not.toBe(commandIds[0]);
|
|
2547
|
+
});
|
|
2548
|
+
});
|