@frockbot/plugin-flock 0.3.11 → 0.3.13
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 +2 -1
- package/package.json +8 -8
- package/src/agent.ts +3 -1
- package/src/bot.test.ts +85 -0
- package/src/bot.ts +111 -1
- package/src/client/BotAvatar.vue +94 -12
- package/src/client/FlockDangerZone.vue +71 -0
- package/src/client/FlockOverlay.vue +36 -1
- package/src/client/FlockSidebar.vue +42 -4
- package/src/client/delivered-notifications.test.ts +103 -0
- package/src/client/delivered-notifications.ts +100 -0
- package/src/client/index.test.ts +205 -2
- package/src/client/index.ts +0 -0
- package/src/client/state.ts +8 -3
- package/src/client/styles.css +34 -0
- package/src/shared.test.ts +41 -0
- package/src/shared.ts +26 -4
- package/src/user.test.ts +104 -0
- package/src/user.ts +76 -7
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
claimNotificationDeliveryV1,
|
|
4
|
+
deliveredNotificationKeyV1,
|
|
5
|
+
DELIVERED_NOTIFICATIONS_KEY,
|
|
6
|
+
DELIVERED_NOTIFICATIONS_LIMIT,
|
|
7
|
+
releaseNotificationDeliveryV1,
|
|
8
|
+
} from "./delivered-notifications.js";
|
|
9
|
+
|
|
10
|
+
/** The half of `localStorage` the ledger uses, shared as a browser shares it. */
|
|
11
|
+
function storage(initial?: string): Pick<Storage, "getItem" | "setItem"> {
|
|
12
|
+
const values = new Map<string, string>(
|
|
13
|
+
initial === undefined ? [] : [[DELIVERED_NOTIFICATIONS_KEY, initial]],
|
|
14
|
+
);
|
|
15
|
+
return {
|
|
16
|
+
getItem: (key) => values.get(key) ?? null,
|
|
17
|
+
setItem: (key, value) => {
|
|
18
|
+
values.set(key, value);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("the delivered-notification ledger", () => {
|
|
24
|
+
test("the first claim wins and every later one loses", () => {
|
|
25
|
+
const shared = storage();
|
|
26
|
+
const key = deliveredNotificationKeyV1("beta", "run-1");
|
|
27
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
|
|
28
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(false);
|
|
29
|
+
// A second tab reads the same storage, so it loses too — which is the
|
|
30
|
+
// whole point: one notification per message, not one per tab.
|
|
31
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(false);
|
|
32
|
+
// A different message is still news.
|
|
33
|
+
expect(
|
|
34
|
+
claimNotificationDeliveryV1(
|
|
35
|
+
deliveredNotificationKeyV1("beta", "run-2"),
|
|
36
|
+
shared,
|
|
37
|
+
),
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a claim survives the reload that empties the page's own set", () => {
|
|
42
|
+
const values = new Map<string, string>();
|
|
43
|
+
const persistent = (): Pick<Storage, "getItem" | "setItem"> => ({
|
|
44
|
+
getItem: (key) => values.get(key) ?? null,
|
|
45
|
+
setItem: (key, value) => {
|
|
46
|
+
values.set(key, value);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const key = deliveredNotificationKeyV1("beta", "run-1");
|
|
50
|
+
expect(claimNotificationDeliveryV1(key, persistent())).toBe(true);
|
|
51
|
+
// A brand-new page, the same browser.
|
|
52
|
+
expect(claimNotificationDeliveryV1(key, persistent())).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a notification that could not be shown gives its claim back", () => {
|
|
56
|
+
const shared = storage();
|
|
57
|
+
const key = deliveredNotificationKeyV1("beta", "run-1");
|
|
58
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
|
|
59
|
+
releaseNotificationDeliveryV1(key, shared);
|
|
60
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("the ledger is bounded, oldest first", () => {
|
|
64
|
+
const shared = storage();
|
|
65
|
+
for (let index = 0; index <= DELIVERED_NOTIFICATIONS_LIMIT; index += 1) {
|
|
66
|
+
claimNotificationDeliveryV1(
|
|
67
|
+
deliveredNotificationKeyV1("beta", `run-${index}`),
|
|
68
|
+
shared,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
// The oldest fell off; the newest is still remembered.
|
|
72
|
+
expect(
|
|
73
|
+
claimNotificationDeliveryV1(
|
|
74
|
+
deliveredNotificationKeyV1("beta", "run-0"),
|
|
75
|
+
shared,
|
|
76
|
+
),
|
|
77
|
+
).toBe(true);
|
|
78
|
+
expect(
|
|
79
|
+
claimNotificationDeliveryV1(
|
|
80
|
+
deliveredNotificationKeyV1(
|
|
81
|
+
"beta",
|
|
82
|
+
`run-${DELIVERED_NOTIFICATIONS_LIMIT}`,
|
|
83
|
+
),
|
|
84
|
+
shared,
|
|
85
|
+
),
|
|
86
|
+
).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("junk in storage is not a reason to go silent", () => {
|
|
90
|
+
for (const junk of ["", "{", "null", '{"not":"an array"}', "[1,2,3]"]) {
|
|
91
|
+
const key = deliveredNotificationKeyV1("beta", "run-1");
|
|
92
|
+
const shared = storage(junk);
|
|
93
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
|
|
94
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(false);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("no storage at all still shows the notification", () => {
|
|
99
|
+
const key = deliveredNotificationKeyV1("beta", "run-1");
|
|
100
|
+
expect(claimNotificationDeliveryV1(key, undefined)).toBe(true);
|
|
101
|
+
expect(claimNotificationDeliveryV1(key, undefined)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which notification intents this browser has already shown.
|
|
3
|
+
*
|
|
4
|
+
* "One notification per message" is a promise about the *person*, not about
|
|
5
|
+
* the page. The durable acknowledgement the Bot records is what closes an
|
|
6
|
+
* intent, but it lands after the notification is shown, and in that window a
|
|
7
|
+
* second tab polling the same fan-out — or the same tab after a reload —
|
|
8
|
+
* showed the identical intent again. A set on the page could not see either.
|
|
9
|
+
*
|
|
10
|
+
* `localStorage` can, because both tabs of one browser share it. It is a
|
|
11
|
+
* ledger of ids, never of content: an id already here is one this browser has
|
|
12
|
+
* spoken. It is bounded and oldest-first, so a long-lived session cannot grow
|
|
13
|
+
* it without limit; an id that falls off the end is one whose acknowledgement
|
|
14
|
+
* settled long ago.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const DELIVERED_NOTIFICATIONS_KEY =
|
|
18
|
+
"frockbot.flock.delivered-notifications.v1";
|
|
19
|
+
|
|
20
|
+
/** How many ids one browser remembers. Comfortably past any in-flight burst. */
|
|
21
|
+
export const DELIVERED_NOTIFICATIONS_LIMIT = 200;
|
|
22
|
+
|
|
23
|
+
type WritableStorage = Pick<Storage, "getItem" | "setItem">;
|
|
24
|
+
|
|
25
|
+
function browserStorage(): Storage | undefined {
|
|
26
|
+
try {
|
|
27
|
+
return typeof localStorage === "undefined" ? undefined : localStorage;
|
|
28
|
+
} catch {
|
|
29
|
+
// A browser with storage denied still shows notifications; it only loses
|
|
30
|
+
// the cross-tab half of the promise.
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The key one intent is remembered under. */
|
|
36
|
+
export function deliveredNotificationKeyV1(
|
|
37
|
+
botId: string,
|
|
38
|
+
notificationId: string,
|
|
39
|
+
): string {
|
|
40
|
+
return `${botId}:${notificationId}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parse(raw: string | null): string[] {
|
|
44
|
+
if (!raw) return [];
|
|
45
|
+
try {
|
|
46
|
+
const value: unknown = JSON.parse(raw);
|
|
47
|
+
return Array.isArray(value)
|
|
48
|
+
? value.filter((entry): entry is string => typeof entry === "string")
|
|
49
|
+
: [];
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Claims the right to show the intent behind `key`, returning `false` when
|
|
57
|
+
* some other tab — or this one, before a reload — already has it.
|
|
58
|
+
*
|
|
59
|
+
* Check-and-write in one call, and the write happens *before* the notification
|
|
60
|
+
* is shown, because the gap between showing and recording is exactly where the
|
|
61
|
+
* duplicate got in.
|
|
62
|
+
*/
|
|
63
|
+
export function claimNotificationDeliveryV1(
|
|
64
|
+
key: string,
|
|
65
|
+
storage: WritableStorage | undefined = browserStorage(),
|
|
66
|
+
): boolean {
|
|
67
|
+
if (!storage) return true;
|
|
68
|
+
try {
|
|
69
|
+
const existing = parse(storage.getItem(DELIVERED_NOTIFICATIONS_KEY));
|
|
70
|
+
if (existing.includes(key)) return false;
|
|
71
|
+
const next = [...existing, key].slice(-DELIVERED_NOTIFICATIONS_LIMIT);
|
|
72
|
+
storage.setItem(DELIVERED_NOTIFICATIONS_KEY, JSON.stringify(next));
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
// Storage that will not take the ledger must not silence the notification.
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Gives a claim back, for the one case where the notification was never
|
|
82
|
+
* actually shown: no permission yet. Without this the intent would be
|
|
83
|
+
* remembered as spoken and stay silent after the User granted it.
|
|
84
|
+
*/
|
|
85
|
+
export function releaseNotificationDeliveryV1(
|
|
86
|
+
key: string,
|
|
87
|
+
storage: WritableStorage | undefined = browserStorage(),
|
|
88
|
+
): void {
|
|
89
|
+
if (!storage) return;
|
|
90
|
+
try {
|
|
91
|
+
const existing = parse(storage.getItem(DELIVERED_NOTIFICATIONS_KEY));
|
|
92
|
+
if (!existing.includes(key)) return;
|
|
93
|
+
storage.setItem(
|
|
94
|
+
DELIVERED_NOTIFICATIONS_KEY,
|
|
95
|
+
JSON.stringify(existing.filter((entry) => entry !== key)),
|
|
96
|
+
);
|
|
97
|
+
} catch {
|
|
98
|
+
// Nothing to undo that anybody can see.
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/client/index.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import type { ClientPluginContext } from "@frockbot/client-core";
|
|
3
3
|
import type { FrockBotWebData } from "@frockbot/plugin-shell/shared";
|
|
4
|
-
import { ref, type Ref } from "vue";
|
|
4
|
+
import { nextTick, ref, type Ref } from "vue";
|
|
5
5
|
import { randomSheepRecipeV1 } from "../shared.js";
|
|
6
6
|
import { flockClientPlugin } from "./index.js";
|
|
7
7
|
import { pendingCreateKey, pendingSheepKey } from "./pending-create.js";
|
|
@@ -281,7 +281,8 @@ describe("Flock client reconciliation", () => {
|
|
|
281
281
|
|
|
282
282
|
await state.value.load();
|
|
283
283
|
|
|
284
|
-
|
|
284
|
+
// The sidebar says what failed, not what the deployment called it.
|
|
285
|
+
expect(state.value.error).toBe("Couldn't load your Bots.");
|
|
285
286
|
expect(storage.has(pendingCreateKey("user-a"))).toBe(false);
|
|
286
287
|
});
|
|
287
288
|
|
|
@@ -410,6 +411,114 @@ describe("Flock client reconciliation", () => {
|
|
|
410
411
|
expect(state.value.directory.bots).toEqual([]);
|
|
411
412
|
});
|
|
412
413
|
|
|
414
|
+
test("deletes with confirmation and drops the Bot from the list", async () => {
|
|
415
|
+
installStorage();
|
|
416
|
+
const location = { href: "https://app.example/?bot=alpha" };
|
|
417
|
+
Object.defineProperty(globalThis, "window", {
|
|
418
|
+
configurable: true,
|
|
419
|
+
value: {
|
|
420
|
+
location,
|
|
421
|
+
history: {
|
|
422
|
+
state: null,
|
|
423
|
+
replaceState: (_state: unknown, _title: string, url: URL) => {
|
|
424
|
+
location.href = url.href;
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
const sheep = randomSheepRecipeV1(() => 0);
|
|
430
|
+
// A deleted Bot leaves the directory outright, unlike an archived one.
|
|
431
|
+
let bots = ["alpha", "beta"];
|
|
432
|
+
let deleteCommands = 0;
|
|
433
|
+
const state = mount((path, method, body) => {
|
|
434
|
+
if (path === "/api/bots/identities")
|
|
435
|
+
return Promise.resolve({ schemaVersion: 1, identities: [] });
|
|
436
|
+
if (path === "/api/bots/alpha/lifecycle" && method === "POST") {
|
|
437
|
+
const command = JSON.parse(body ?? "") as {
|
|
438
|
+
commandId: string;
|
|
439
|
+
botId: string;
|
|
440
|
+
type: string;
|
|
441
|
+
};
|
|
442
|
+
expect(command.type).toBe("bot/delete");
|
|
443
|
+
deleteCommands += 1;
|
|
444
|
+
bots = bots.filter((botId) => botId !== "alpha");
|
|
445
|
+
return Promise.resolve({
|
|
446
|
+
schemaVersion: 1,
|
|
447
|
+
commandId: command.commandId,
|
|
448
|
+
botId: command.botId,
|
|
449
|
+
status: "applied",
|
|
450
|
+
lifecycle: {
|
|
451
|
+
schemaVersion: 1,
|
|
452
|
+
botId: command.botId,
|
|
453
|
+
status: "deleted",
|
|
454
|
+
revision: 1,
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
if (path === "/api/bots")
|
|
459
|
+
return Promise.resolve({
|
|
460
|
+
schemaVersion: 1,
|
|
461
|
+
revision: 3,
|
|
462
|
+
bots: bots.map((botId) => ({
|
|
463
|
+
schemaVersion: 1,
|
|
464
|
+
botId,
|
|
465
|
+
registeredAt: new Date(0).toISOString(),
|
|
466
|
+
initialName: botId,
|
|
467
|
+
sheep,
|
|
468
|
+
})),
|
|
469
|
+
});
|
|
470
|
+
if (path === "/api/bots/lifecycles")
|
|
471
|
+
return Promise.resolve({
|
|
472
|
+
schemaVersion: 1,
|
|
473
|
+
lifecycles: bots.map((botId) => ({
|
|
474
|
+
schemaVersion: 1,
|
|
475
|
+
botId,
|
|
476
|
+
status: "active",
|
|
477
|
+
revision: 0,
|
|
478
|
+
})),
|
|
479
|
+
});
|
|
480
|
+
if (path.endsWith("/sheep")) {
|
|
481
|
+
const botId = path.split("/")[3]!;
|
|
482
|
+
return Promise.resolve({ schemaVersion: 1, botId, revision: 0, sheep });
|
|
483
|
+
}
|
|
484
|
+
return Promise.reject(new Error(`unexpected request: ${path}`));
|
|
485
|
+
});
|
|
486
|
+
const selected: string[] = [];
|
|
487
|
+
const forgotten: (string | undefined)[] = [];
|
|
488
|
+
state.value.bindShell(
|
|
489
|
+
ref({
|
|
490
|
+
activeBotId: "alpha",
|
|
491
|
+
selectBot: (botId: string) => {
|
|
492
|
+
selected.push(botId);
|
|
493
|
+
return Promise.resolve();
|
|
494
|
+
},
|
|
495
|
+
transcripts: {
|
|
496
|
+
rememberViewport: () => undefined,
|
|
497
|
+
viewportFor: () => undefined,
|
|
498
|
+
forget: (botId?: string) => forgotten.push(botId),
|
|
499
|
+
},
|
|
500
|
+
}) as unknown as Ref<FrockBotWebData>,
|
|
501
|
+
);
|
|
502
|
+
await state.value.load();
|
|
503
|
+
state.value.openDelete("alpha");
|
|
504
|
+
expect(state.value.overlay).toBe("delete");
|
|
505
|
+
expect(state.value.lifecyclePending).toBe("alpha");
|
|
506
|
+
await state.value.deleteBot();
|
|
507
|
+
expect(deleteCommands).toBe(1);
|
|
508
|
+
expect(state.value.overlay).toBeUndefined();
|
|
509
|
+
expect(state.value.directory.bots.map((bot) => bot.botId)).toEqual([
|
|
510
|
+
"beta",
|
|
511
|
+
]);
|
|
512
|
+
expect(selected.at(-1)).toBe("beta");
|
|
513
|
+
expect(new URL(location.href).searchParams.get("bot")).toBe("beta");
|
|
514
|
+
// The held transcript goes with the Bot: unlike an archive, there is no
|
|
515
|
+
// Bot left to read it back from.
|
|
516
|
+
expect(forgotten).toEqual(["alpha"]);
|
|
517
|
+
// A Bot that is no longer in the directory cannot be confirmed for delete.
|
|
518
|
+
state.value.openDelete("alpha");
|
|
519
|
+
expect(state.value.overlay).toBeUndefined();
|
|
520
|
+
});
|
|
521
|
+
|
|
413
522
|
test("archives with confirmation, hides archived Bots, and selects a fallback", async () => {
|
|
414
523
|
installStorage();
|
|
415
524
|
const location = { href: "https://app.example/?bot=alpha" };
|
|
@@ -481,6 +590,7 @@ describe("Flock client reconciliation", () => {
|
|
|
481
590
|
return Promise.reject(new Error(`unexpected request: ${path}`));
|
|
482
591
|
});
|
|
483
592
|
const selected: string[] = [];
|
|
593
|
+
const forgotten: (string | undefined)[] = [];
|
|
484
594
|
state.value.bindShell(
|
|
485
595
|
ref({
|
|
486
596
|
activeBotId: "alpha",
|
|
@@ -488,6 +598,11 @@ describe("Flock client reconciliation", () => {
|
|
|
488
598
|
selected.push(botId);
|
|
489
599
|
return Promise.resolve();
|
|
490
600
|
},
|
|
601
|
+
transcripts: {
|
|
602
|
+
rememberViewport: () => undefined,
|
|
603
|
+
viewportFor: () => undefined,
|
|
604
|
+
forget: (botId?: string) => forgotten.push(botId),
|
|
605
|
+
},
|
|
491
606
|
}) as unknown as Ref<FrockBotWebData>,
|
|
492
607
|
);
|
|
493
608
|
state.value.openArchive("alpha");
|
|
@@ -496,6 +611,9 @@ describe("Flock client reconciliation", () => {
|
|
|
496
611
|
expect(state.value.lifecycles.alpha).toBe("archived");
|
|
497
612
|
expect(selected.at(-1)).toBe("beta");
|
|
498
613
|
expect(new URL(location.href).searchParams.get("bot")).toBe("beta");
|
|
614
|
+
// The archived Bot's held transcript goes with it, so restoring it later
|
|
615
|
+
// reads from the Bot rather than redrawing what the cache still had.
|
|
616
|
+
expect(forgotten).toEqual(["alpha"]);
|
|
499
617
|
});
|
|
500
618
|
|
|
501
619
|
test("reconciles a lost sheep response and clears the exact pending command", async () => {
|
|
@@ -532,3 +650,88 @@ describe("Flock client reconciliation", () => {
|
|
|
532
650
|
expect(storage.has(pendingSheepKey("user-a", "alpha"))).toBe(false);
|
|
533
651
|
});
|
|
534
652
|
});
|
|
653
|
+
|
|
654
|
+
describe("Flock sidebar rows follow the transcript", () => {
|
|
655
|
+
test("a settled Turn updates the row without waiting for the poll", async () => {
|
|
656
|
+
installStorage();
|
|
657
|
+
const reads: string[] = [];
|
|
658
|
+
let reply: string | undefined;
|
|
659
|
+
const state = mount((path) => {
|
|
660
|
+
if (path !== "/api/bots/unread")
|
|
661
|
+
return Promise.reject(new Error(`unexpected request: ${path}`));
|
|
662
|
+
reads.push(path);
|
|
663
|
+
return Promise.resolve({
|
|
664
|
+
schemaVersion: 1,
|
|
665
|
+
unread: [
|
|
666
|
+
{
|
|
667
|
+
schemaVersion: 1,
|
|
668
|
+
botId: "alpha",
|
|
669
|
+
count: 0,
|
|
670
|
+
capped: false,
|
|
671
|
+
unread: false,
|
|
672
|
+
manuallyUnread: false,
|
|
673
|
+
...(reply === undefined
|
|
674
|
+
? {}
|
|
675
|
+
: {
|
|
676
|
+
lastMessage: {
|
|
677
|
+
schemaVersion: 1,
|
|
678
|
+
text: reply,
|
|
679
|
+
at: "2026-08-31T00:00:01.000Z",
|
|
680
|
+
role: "assistant",
|
|
681
|
+
},
|
|
682
|
+
}),
|
|
683
|
+
},
|
|
684
|
+
],
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
const shell = ref({
|
|
688
|
+
activeBotId: "alpha",
|
|
689
|
+
activeRunId: "run-1",
|
|
690
|
+
messages: [
|
|
691
|
+
{
|
|
692
|
+
id: "m1",
|
|
693
|
+
runId: "run-1",
|
|
694
|
+
role: "user",
|
|
695
|
+
text: "hello",
|
|
696
|
+
status: "completed",
|
|
697
|
+
},
|
|
698
|
+
],
|
|
699
|
+
} as unknown as FrockBotWebData);
|
|
700
|
+
state.value.bindShell(shell as unknown as Ref<FrockBotWebData>);
|
|
701
|
+
// Binding a Shell is not itself a beat: nothing has settled, so nothing is
|
|
702
|
+
// re-read.
|
|
703
|
+
await nextTick();
|
|
704
|
+
expect(reads).toHaveLength(0);
|
|
705
|
+
expect(state.value.unread.alpha).toBeUndefined();
|
|
706
|
+
|
|
707
|
+
// The Turn settles: its reply is in the transcript, no run is in flight.
|
|
708
|
+
reply = "Ollama reply";
|
|
709
|
+
shell.value = {
|
|
710
|
+
...shell.value,
|
|
711
|
+
activeRunId: undefined,
|
|
712
|
+
messages: [
|
|
713
|
+
...shell.value.messages,
|
|
714
|
+
{
|
|
715
|
+
id: "m2",
|
|
716
|
+
runId: "run-1",
|
|
717
|
+
role: "assistant",
|
|
718
|
+
text: reply,
|
|
719
|
+
status: "completed",
|
|
720
|
+
},
|
|
721
|
+
],
|
|
722
|
+
} as unknown as FrockBotWebData;
|
|
723
|
+
await nextTick();
|
|
724
|
+
// Draining the read the watcher started. A rendered frame does this on its
|
|
725
|
+
// own; the claim under test is that no 15-second poll was involved.
|
|
726
|
+
await Promise.resolve();
|
|
727
|
+
await Promise.resolve();
|
|
728
|
+
await Promise.resolve();
|
|
729
|
+
|
|
730
|
+
expect(reads).toHaveLength(1);
|
|
731
|
+
expect(state.value.unread.alpha?.lastMessage).toMatchObject({
|
|
732
|
+
text: "Ollama reply",
|
|
733
|
+
at: "2026-08-31T00:00:01.000Z",
|
|
734
|
+
role: "assistant",
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
});
|
package/src/client/index.ts
CHANGED
|
Binary file
|
package/src/client/state.ts
CHANGED
|
@@ -24,12 +24,14 @@ export interface FlockWebData {
|
|
|
24
24
|
* Whether a directory read has ever completed.
|
|
25
25
|
*
|
|
26
26
|
* "No Bots yet." is a fact about the User's account, and it can only be
|
|
27
|
-
* stated once the account has been read. Before that — the first paint,
|
|
28
|
-
*
|
|
27
|
+
* stated once the account has been read. Before that — the first paint, the
|
|
28
|
+
* reload after a Bot is created, and every read that failed — the list is
|
|
29
|
+
* unknown, not empty, and offering to create a first Bot to someone who
|
|
30
|
+
* already has several is the worst thing this column can say.
|
|
29
31
|
*/
|
|
30
32
|
loaded: boolean;
|
|
31
33
|
error?: string;
|
|
32
|
-
overlay?: "create" | "edit" | "archive";
|
|
34
|
+
overlay?: "create" | "edit" | "archive" | "delete";
|
|
33
35
|
lifecycles: Record<string, BotLifecycleStatusV1>;
|
|
34
36
|
showArchived: boolean;
|
|
35
37
|
lifecyclePending?: string;
|
|
@@ -50,6 +52,9 @@ export interface FlockWebData {
|
|
|
50
52
|
toggleHidden(): void;
|
|
51
53
|
openArchive(botId: string): void;
|
|
52
54
|
archive(): Promise<void>;
|
|
55
|
+
/** Confirmation first: deleting a Bot destroys its chat history. */
|
|
56
|
+
openDelete(botId: string): void;
|
|
57
|
+
deleteBot(): Promise<void>;
|
|
53
58
|
restore(botId: string): Promise<void>;
|
|
54
59
|
closeOverlay(): void;
|
|
55
60
|
reroll(): void;
|
package/src/client/styles.css
CHANGED
|
@@ -70,6 +70,11 @@
|
|
|
70
70
|
color: var(--frock-text);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
.flock-lifecycle--danger,
|
|
74
|
+
.flock-lifecycle--danger:hover {
|
|
75
|
+
color: var(--frock-danger-text);
|
|
76
|
+
}
|
|
77
|
+
|
|
73
78
|
/*
|
|
74
79
|
* Pinned Bots. A wrapping row of large round tiles above the labelled groups,
|
|
75
80
|
* each one a whole Bot rather than a line of text about it.
|
|
@@ -357,6 +362,24 @@
|
|
|
357
362
|
color: var(--frock-danger-text);
|
|
358
363
|
}
|
|
359
364
|
|
|
365
|
+
/* The way to try the read again, beside the sentence that says it failed. */
|
|
366
|
+
.flock-retry {
|
|
367
|
+
display: inline-block;
|
|
368
|
+
margin-top: 6px;
|
|
369
|
+
border: 1px solid var(--frock-border);
|
|
370
|
+
border-radius: var(--frock-radius-control);
|
|
371
|
+
background: transparent;
|
|
372
|
+
color: var(--frock-text);
|
|
373
|
+
padding: 2px 8px;
|
|
374
|
+
font: inherit;
|
|
375
|
+
font-size: var(--frock-text-xs);
|
|
376
|
+
cursor: pointer;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.flock-retry:hover {
|
|
380
|
+
background: var(--frock-fill-hover);
|
|
381
|
+
}
|
|
382
|
+
|
|
360
383
|
/* Header identity */
|
|
361
384
|
|
|
362
385
|
.flock-identity-button {
|
|
@@ -606,6 +629,17 @@
|
|
|
606
629
|
background: var(--frock-action-primary-pressed);
|
|
607
630
|
}
|
|
608
631
|
|
|
632
|
+
/* The one irreversible action in this dialog set, and it says so. */
|
|
633
|
+
.flock-actions .danger {
|
|
634
|
+
border-color: var(--frock-danger-strong);
|
|
635
|
+
background: var(--frock-danger-strong);
|
|
636
|
+
color: var(--frock-on-accent);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
.flock-actions .danger:hover {
|
|
640
|
+
background: var(--frock-danger-text);
|
|
641
|
+
}
|
|
642
|
+
|
|
609
643
|
@media (max-width: 600px) {
|
|
610
644
|
.flock-backdrop {
|
|
611
645
|
padding: 10px;
|
package/src/shared.test.ts
CHANGED
|
@@ -4,11 +4,13 @@ import {
|
|
|
4
4
|
decodeBotIdentityViewV1,
|
|
5
5
|
decodeBotLifecycleCommandV1,
|
|
6
6
|
decodeBotLifecycleReceiptV1,
|
|
7
|
+
decodeBotLifecycleViewV1,
|
|
7
8
|
decodeBotMembershipViewV1,
|
|
8
9
|
decodeBotRegistrationV1,
|
|
9
10
|
decodeCreateBotCommandV1,
|
|
10
11
|
decodeDirectoryViewV1,
|
|
11
12
|
decodeSheepRecipeV1,
|
|
13
|
+
lifecycleTargetStatusV1,
|
|
12
14
|
migrateStoredBotDirectoryV1,
|
|
13
15
|
randomSheepRecipeV1,
|
|
14
16
|
sheepCatalog,
|
|
@@ -38,6 +40,45 @@ describe("Flock v1 contracts", () => {
|
|
|
38
40
|
).toThrow("unknown or missing field");
|
|
39
41
|
});
|
|
40
42
|
|
|
43
|
+
test("strictly decodes the delete command and its terminal status", () => {
|
|
44
|
+
const command = {
|
|
45
|
+
schemaVersion: 1 as const,
|
|
46
|
+
type: "bot/delete" as const,
|
|
47
|
+
commandId: "delete-1",
|
|
48
|
+
botId: "alpha",
|
|
49
|
+
};
|
|
50
|
+
expect(decodeBotLifecycleCommandV1(command)).toEqual(command);
|
|
51
|
+
expect(lifecycleTargetStatusV1("bot/delete")).toBe("deleted");
|
|
52
|
+
expect(lifecycleTargetStatusV1("bot/archive")).toBe("archived");
|
|
53
|
+
expect(lifecycleTargetStatusV1("bot/restore")).toBe("active");
|
|
54
|
+
expect(
|
|
55
|
+
decodeBotLifecycleReceiptV1({
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
commandId: "delete-1",
|
|
58
|
+
botId: "alpha",
|
|
59
|
+
status: "applied",
|
|
60
|
+
lifecycle: {
|
|
61
|
+
schemaVersion: 1,
|
|
62
|
+
botId: "alpha",
|
|
63
|
+
status: "deleted",
|
|
64
|
+
revision: 1,
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
).toMatchObject({ lifecycle: { status: "deleted" } });
|
|
68
|
+
// A near-miss is still a rejection: the union is exact, not prefixed.
|
|
69
|
+
expect(() =>
|
|
70
|
+
decodeBotLifecycleCommandV1({ ...command, type: "bot/delete-all" }),
|
|
71
|
+
).toThrow("unsupported Bot lifecycle command");
|
|
72
|
+
expect(() =>
|
|
73
|
+
decodeBotLifecycleViewV1({
|
|
74
|
+
schemaVersion: 1,
|
|
75
|
+
botId: "alpha",
|
|
76
|
+
status: "removed",
|
|
77
|
+
revision: 1,
|
|
78
|
+
}),
|
|
79
|
+
).toThrow("Bot lifecycle is invalid");
|
|
80
|
+
});
|
|
81
|
+
|
|
41
82
|
test("strictly decodes archive and restore DTOs", () => {
|
|
42
83
|
const command = {
|
|
43
84
|
schemaVersion: 1 as const,
|
package/src/shared.ts
CHANGED
|
@@ -52,7 +52,12 @@ export interface BotDirectoryViewV1 {
|
|
|
52
52
|
revision: number;
|
|
53
53
|
bots: BotRegistrationV1[];
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* A Bot's durable lifecycle. `deleted` is terminal: the Bot and its chat
|
|
57
|
+
* history are gone, and the status survives only as a tombstone so a late
|
|
58
|
+
* command or Turn is refused rather than resurrecting the Bot.
|
|
59
|
+
*/
|
|
60
|
+
export type BotLifecycleStatusV1 = "active" | "archived" | "deleted";
|
|
56
61
|
export interface BotLifecycleViewV1 {
|
|
57
62
|
schemaVersion: 1;
|
|
58
63
|
botId: string;
|
|
@@ -65,7 +70,7 @@ export interface BotLifecycleDirectoryViewV1 {
|
|
|
65
70
|
}
|
|
66
71
|
export interface BotLifecycleCommandV1 {
|
|
67
72
|
schemaVersion: 1;
|
|
68
|
-
type: "bot/archive" | "bot/restore";
|
|
73
|
+
type: "bot/archive" | "bot/restore" | "bot/delete";
|
|
69
74
|
commandId: string;
|
|
70
75
|
botId: string;
|
|
71
76
|
}
|
|
@@ -162,6 +167,19 @@ export class FlockConflictError extends Error {
|
|
|
162
167
|
this.name = "FlockConflictError";
|
|
163
168
|
}
|
|
164
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* The status a lifecycle command settles on. The saga on the User side and the
|
|
172
|
+
* Bot Durable Object both read the target from here rather than each writing
|
|
173
|
+
* out the same mapping.
|
|
174
|
+
*/
|
|
175
|
+
export function lifecycleTargetStatusV1(
|
|
176
|
+
type: BotLifecycleCommandV1["type"],
|
|
177
|
+
): BotLifecycleStatusV1 {
|
|
178
|
+
if (type === "bot/archive") return "archived";
|
|
179
|
+
if (type === "bot/delete") return "deleted";
|
|
180
|
+
return "active";
|
|
181
|
+
}
|
|
182
|
+
|
|
165
183
|
export class BotNotFoundError extends Error {
|
|
166
184
|
constructor(readonly botId: string) {
|
|
167
185
|
super(`Bot "${botId}" is not registered`);
|
|
@@ -456,7 +474,9 @@ export function decodeBotLifecycleCommandV1(
|
|
|
456
474
|
exact(value, ["schemaVersion", "type", "commandId", "botId"]);
|
|
457
475
|
if (
|
|
458
476
|
value.schemaVersion !== 1 ||
|
|
459
|
-
(value.type !== "bot/archive" &&
|
|
477
|
+
(value.type !== "bot/archive" &&
|
|
478
|
+
value.type !== "bot/restore" &&
|
|
479
|
+
value.type !== "bot/delete")
|
|
460
480
|
)
|
|
461
481
|
throw new FlockDecodeError("unsupported Bot lifecycle command");
|
|
462
482
|
return {
|
|
@@ -472,7 +492,9 @@ export function decodeBotLifecycleViewV1(input: unknown): BotLifecycleViewV1 {
|
|
|
472
492
|
exact(value, ["schemaVersion", "botId", "status", "revision"]);
|
|
473
493
|
if (
|
|
474
494
|
value.schemaVersion !== 1 ||
|
|
475
|
-
(value.status !== "active" &&
|
|
495
|
+
(value.status !== "active" &&
|
|
496
|
+
value.status !== "archived" &&
|
|
497
|
+
value.status !== "deleted")
|
|
476
498
|
)
|
|
477
499
|
throw new FlockDecodeError("Bot lifecycle is invalid");
|
|
478
500
|
return {
|