@frockbot/plugin-shell 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/package.json +35 -33
- package/src/agent.test.ts +78 -0
- package/src/agent.ts +130 -2
- package/src/backend-configuration.test.ts +26 -26
- package/src/backend-recovery-integration.test.ts +10 -10
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +85 -18
- package/src/client/AppletCanvas.vue +19 -6
- package/src/client/FrockBotApp.vue +405 -75
- package/src/client/activity-trail.test.ts +205 -0
- package/src/client/activity-trail.ts +227 -0
- package/src/client/applets-client.test.ts +62 -0
- package/src/client/applets-client.ts +19 -0
- package/src/client/index.test.ts +128 -21
- package/src/client/index.ts +359 -114
- package/src/client/model-presentation.test.ts +3 -3
- package/src/client/no-bot-model-label.test.ts +7 -7
- package/src/client/skill-invocation.test.ts +34 -0
- package/src/client/skill-invocation.ts +22 -0
- package/src/client/styles.css +69 -19
- package/src/client/transcript-cache.test.ts +125 -0
- package/src/client/transcript-cache.ts +190 -0
- package/src/compaction-scheduler.test.ts +96 -0
- package/src/compaction-scheduler.ts +108 -0
- package/src/compaction-transcript.test.ts +174 -0
- package/src/compaction.test.ts +596 -0
- package/src/compaction.ts +539 -0
- package/src/focus.test.ts +222 -0
- package/src/focus.ts +93 -0
- package/src/history.ts +86 -8
- package/src/legacy-frock-model-id.test.ts +148 -0
- package/src/notification-id.ts +0 -0
- package/src/run-failure-copy.test.ts +150 -0
- package/src/run-failure-copy.ts +110 -0
- package/src/run-protocol.test.ts +50 -7
- package/src/run-protocol.ts +152 -43
- package/src/settings-links.test.ts +8 -2
- package/src/settings-links.ts +11 -2
- package/src/shared.ts +36 -0
- package/tsconfig.json +1 -2
- package/src/client/activity-ring.test.ts +0 -89
- package/src/client/activity-ring.ts +0 -94
|
@@ -42,11 +42,11 @@ describe("model runtime presentation", () => {
|
|
|
42
42
|
modelRuntimeLabel({
|
|
43
43
|
source: "platform",
|
|
44
44
|
modelDisplayName: "Auto",
|
|
45
|
-
providerModelId: "@
|
|
46
|
-
packageDisplayName: "
|
|
45
|
+
providerModelId: "@frock/auto",
|
|
46
|
+
packageDisplayName: "Frock AI",
|
|
47
47
|
fallback: true,
|
|
48
48
|
}),
|
|
49
|
-
).toBe("Auto ·
|
|
49
|
+
).toBe("Auto · Frock AI · your chosen model is unavailable");
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
test("shows unavailable and backend failure states", () => {
|
|
@@ -20,7 +20,7 @@ const { shellClientPlugin } = await import("./index.js");
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* A first-run account: the platform model resolves against a ready ambient
|
|
23
|
-
*
|
|
23
|
+
* Frock AI Connection whose Catalog is fresh, and no Bot has been created yet.
|
|
24
24
|
* The account's model is available, so the shell must not tell the User it is
|
|
25
25
|
* unavailable before they have made their first Bot.
|
|
26
26
|
*/
|
|
@@ -37,7 +37,7 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
37
37
|
connectionId: "flock-ai-ambient",
|
|
38
38
|
packageId: "provider-flock-ai",
|
|
39
39
|
connectionTypeId: "flock-ai-account",
|
|
40
|
-
displayName: "
|
|
40
|
+
displayName: "Frock AI",
|
|
41
41
|
state: "ready",
|
|
42
42
|
providerType: "flock-ai",
|
|
43
43
|
safeMetadata: {},
|
|
@@ -47,7 +47,7 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
47
47
|
state: "fresh",
|
|
48
48
|
models: [
|
|
49
49
|
{
|
|
50
|
-
providerModelId: "@
|
|
50
|
+
providerModelId: "@frock/auto",
|
|
51
51
|
displayName: "Auto (recommended)",
|
|
52
52
|
capabilities: { tools: true, vision: false, reasoning: true },
|
|
53
53
|
source: "discovered",
|
|
@@ -58,7 +58,7 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
58
58
|
],
|
|
59
59
|
platformModel: {
|
|
60
60
|
connectionId: "flock-ai-ambient",
|
|
61
|
-
providerModelId: "@
|
|
61
|
+
providerModelId: "@frock/auto",
|
|
62
62
|
},
|
|
63
63
|
};
|
|
64
64
|
|
|
@@ -74,7 +74,7 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
74
74
|
packages: [
|
|
75
75
|
{
|
|
76
76
|
id: "provider-flock-ai",
|
|
77
|
-
displayName: "
|
|
77
|
+
displayName: "Frock AI",
|
|
78
78
|
version: "0.0.1",
|
|
79
79
|
contributions: ["backend", "runtime"],
|
|
80
80
|
configuration: {
|
|
@@ -82,7 +82,7 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
82
82
|
connectionTypes: [
|
|
83
83
|
{
|
|
84
84
|
id: "flock-ai-account",
|
|
85
|
-
displayName: "
|
|
85
|
+
displayName: "Frock AI",
|
|
86
86
|
allowMultiple: false,
|
|
87
87
|
authorization: { kind: "ambient-native" },
|
|
88
88
|
capabilities: ["flock-ai-models"],
|
|
@@ -117,6 +117,6 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
117
117
|
|
|
118
118
|
// No Bot has been created, so `activeBotId` is unset.
|
|
119
119
|
expect(provided.value.activeBotId).toBeUndefined();
|
|
120
|
-
expect(provided.value.modelLabel).toBe("Auto (recommended) ·
|
|
120
|
+
expect(provided.value.modelLabel).toBe("Auto (recommended) · Frock AI");
|
|
121
121
|
expect(provided.value.modelReady).toBe(true);
|
|
122
122
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
|
|
3
3
|
import {
|
|
4
|
+
keptSkillHighlightV1,
|
|
4
5
|
nextSkillHighlightV1,
|
|
5
6
|
rankSkillCandidatesV1,
|
|
6
7
|
SkillAttachmentStore,
|
|
@@ -140,4 +141,37 @@ describe("keyboard navigation", () => {
|
|
|
140
141
|
expect(nextSkillHighlightV1(0, 3, -1)).toBe(2);
|
|
141
142
|
expect(nextSkillHighlightV1(0, 0, 1)).toBe(0);
|
|
142
143
|
});
|
|
144
|
+
|
|
145
|
+
test("a refilter keeps the highlight on the Skill it was on", () => {
|
|
146
|
+
const ranked = rankSkillCandidatesV1(catalog, "standup");
|
|
147
|
+
// The composer refreshes the popover on every keyup, the arrow key's own
|
|
148
|
+
// keyup included. Row two has to survive that refresh, or the highlight
|
|
149
|
+
// snaps back to the first row and the arrow keys look dead.
|
|
150
|
+
const highlighted = ranked[1]!.entry.ref;
|
|
151
|
+
expect(keptSkillHighlightV1(highlighted, ranked)).toBe(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a narrower query keeps the highlight when the Skill is still offered", () => {
|
|
155
|
+
const before = rankSkillCandidatesV1(catalog, "s");
|
|
156
|
+
const highlighted = before.find(
|
|
157
|
+
(candidate) => candidate.entry.ref === "bot/standup-notes",
|
|
158
|
+
)!.entry.ref;
|
|
159
|
+
const after = rankSkillCandidatesV1(catalog, "standup-n");
|
|
160
|
+
expect(after.map((candidate) => candidate.entry.ref)).toContain(
|
|
161
|
+
highlighted,
|
|
162
|
+
);
|
|
163
|
+
expect(keptSkillHighlightV1(highlighted, after)).toBe(
|
|
164
|
+
after.findIndex((candidate) => candidate.entry.ref === highlighted),
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("falls back to the first row once the highlighted Skill is gone", () => {
|
|
169
|
+
const ranked = rankSkillCandidatesV1(catalog, "standup");
|
|
170
|
+
expect(keptSkillHighlightV1("bot/weekly-report", ranked)).toBe(0);
|
|
171
|
+
expect(keptSkillHighlightV1(undefined, ranked)).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("an empty list has no highlight to keep", () => {
|
|
175
|
+
expect(keptSkillHighlightV1("bot/daily-standup", [])).toBe(0);
|
|
176
|
+
});
|
|
143
177
|
});
|
|
@@ -164,6 +164,28 @@ export class SkillAttachmentStore {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* The highlight to keep once the candidate list has been recomputed.
|
|
169
|
+
*
|
|
170
|
+
* The popover is refreshed from the composer's own `keyup` — including the
|
|
171
|
+
* `keyup` of the arrow key that has just moved the highlight — so a refresh
|
|
172
|
+
* that reset the highlight to the first row made the arrow keys look like they
|
|
173
|
+
* did nothing at all. The highlight is carried by ref rather than by index: the
|
|
174
|
+
* Skill under it keeps its place for as long as the query still offers it, and
|
|
175
|
+
* only a Skill that has dropped out of the list hands the highlight back to the
|
|
176
|
+
* first row.
|
|
177
|
+
*/
|
|
178
|
+
export function keptSkillHighlightV1(
|
|
179
|
+
highlightedRef: string | undefined,
|
|
180
|
+
candidates: readonly SkillCandidateV1[],
|
|
181
|
+
): number {
|
|
182
|
+
if (candidates.length === 0) return 0;
|
|
183
|
+
const index = candidates.findIndex(
|
|
184
|
+
(candidate) => candidate.entry.ref === highlightedRef,
|
|
185
|
+
);
|
|
186
|
+
return index === -1 ? 0 : index;
|
|
187
|
+
}
|
|
188
|
+
|
|
167
189
|
/** Moves the popover's highlight, wrapping at both ends. */
|
|
168
190
|
export function nextSkillHighlightV1(
|
|
169
191
|
highlighted: number,
|
package/src/client/styles.css
CHANGED
|
@@ -201,6 +201,18 @@
|
|
|
201
201
|
-webkit-app-region: no-drag;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/*
|
|
205
|
+
* The Bot's own header controls on a phone, where the right panel that
|
|
206
|
+
* usually carries them is a closed drawer.
|
|
207
|
+
*/
|
|
208
|
+
.topbar-bot-actions {
|
|
209
|
+
display: flex;
|
|
210
|
+
flex: 0 0 auto;
|
|
211
|
+
align-items: center;
|
|
212
|
+
gap: 2px;
|
|
213
|
+
margin-left: auto;
|
|
214
|
+
}
|
|
215
|
+
|
|
204
216
|
/* Thread */
|
|
205
217
|
|
|
206
218
|
.thread {
|
|
@@ -215,6 +227,20 @@
|
|
|
215
227
|
background-image: var(--frock-thread-gradient);
|
|
216
228
|
scrollbar-color: var(--frock-scrollbar) transparent;
|
|
217
229
|
scrollbar-width: thin;
|
|
230
|
+
/*
|
|
231
|
+
* New content is added at the end, so the browser keeps the reader where
|
|
232
|
+
* they are rather than letting the growth push the view.
|
|
233
|
+
*/
|
|
234
|
+
overflow-anchor: auto;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/*
|
|
238
|
+
* Laid out and measured, deliberately not painted. A conversation is put at
|
|
239
|
+
* its end while this holds, so it opens there instead of opening at the top
|
|
240
|
+
* and scrolling down where the reader can see it.
|
|
241
|
+
*/
|
|
242
|
+
.thread-settling {
|
|
243
|
+
visibility: hidden;
|
|
218
244
|
}
|
|
219
245
|
|
|
220
246
|
.empty-thread {
|
|
@@ -334,26 +360,48 @@
|
|
|
334
360
|
* notices and sends stack inside it, so a one-word reply is a bubble the width
|
|
335
361
|
* of its word rather than a sliver of a shared row.
|
|
336
362
|
*
|
|
337
|
-
* The avatar is
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
363
|
+
* The avatar is not in that column, and not in the article at all: while the
|
|
364
|
+
* Bot is working it sits on its own row at the very end of the thread, so the
|
|
365
|
+
* trail streaming off its right has the width of the transcript to run through
|
|
366
|
+
* rather than the gutter beside a bubble, and a message sent mid-Turn lands
|
|
367
|
+
* above it. Every reply here is from the same Bot — there are no group
|
|
368
|
+
* conversations — so a sheep on every settled line said nothing and cost the
|
|
369
|
+
* column its left edge.
|
|
341
370
|
*/
|
|
342
371
|
.message-assistant {
|
|
343
|
-
flex-direction:
|
|
344
|
-
align-items:
|
|
372
|
+
flex-direction: column;
|
|
373
|
+
align-items: stretch;
|
|
345
374
|
gap: 8px;
|
|
346
375
|
}
|
|
347
376
|
|
|
348
377
|
.message-column {
|
|
349
378
|
display: flex;
|
|
350
379
|
min-width: 0;
|
|
351
|
-
flex: 1 1 auto;
|
|
352
380
|
flex-direction: column;
|
|
353
381
|
align-items: flex-start;
|
|
354
382
|
gap: 6px;
|
|
355
383
|
}
|
|
356
384
|
|
|
385
|
+
/*
|
|
386
|
+
* The working row. Its height is the trail's canvas: tall enough for the
|
|
387
|
+
* wobble to be visible, short enough that it reads as one line of the thread.
|
|
388
|
+
* It is the thread's last child, so it takes the same top margin every message
|
|
389
|
+
* has and adds no width of its own — the bubbles above never move sideways
|
|
390
|
+
* when it appears or goes.
|
|
391
|
+
*/
|
|
392
|
+
.bot-working {
|
|
393
|
+
display: flex;
|
|
394
|
+
width: 100%;
|
|
395
|
+
height: 44px;
|
|
396
|
+
align-items: center;
|
|
397
|
+
margin-top: 6px;
|
|
398
|
+
gap: 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.bot-working-indicator {
|
|
402
|
+
height: 100%;
|
|
403
|
+
}
|
|
404
|
+
|
|
357
405
|
.bot-avatar {
|
|
358
406
|
position: relative;
|
|
359
407
|
display: grid;
|
|
@@ -361,7 +409,6 @@
|
|
|
361
409
|
height: var(--frock-avatar-sm);
|
|
362
410
|
flex: 0 0 auto;
|
|
363
411
|
place-items: center;
|
|
364
|
-
margin-top: 2px;
|
|
365
412
|
}
|
|
366
413
|
|
|
367
414
|
.bot-avatar-fallback {
|
|
@@ -396,16 +443,17 @@
|
|
|
396
443
|
}
|
|
397
444
|
|
|
398
445
|
/*
|
|
399
|
-
* The
|
|
400
|
-
* now, and the
|
|
446
|
+
* The working row's way off screen. The Turn has settled, the reply is the
|
|
447
|
+
* answer now, and the row fades rather than vanishing between two frames —
|
|
448
|
+
* which is also long enough for the particles still in flight to drain.
|
|
401
449
|
*/
|
|
402
|
-
.
|
|
403
|
-
.
|
|
450
|
+
.bot-working-enter-active,
|
|
451
|
+
.bot-working-leave-active {
|
|
404
452
|
transition: opacity 420ms ease-out;
|
|
405
453
|
}
|
|
406
454
|
|
|
407
|
-
.
|
|
408
|
-
.
|
|
455
|
+
.bot-working-enter-from,
|
|
456
|
+
.bot-working-leave-to {
|
|
409
457
|
opacity: 0;
|
|
410
458
|
}
|
|
411
459
|
|
|
@@ -1094,19 +1142,21 @@
|
|
|
1094
1142
|
padding-left: 0;
|
|
1095
1143
|
}
|
|
1096
1144
|
|
|
1145
|
+
/* No window chrome to clear, but the panel toggle still sits at the
|
|
1146
|
+
trailing edge, so the row ends before it rather than under it. */
|
|
1097
1147
|
.topbar {
|
|
1098
1148
|
gap: 8px;
|
|
1099
|
-
padding: 0
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
.brand-mark {
|
|
1103
|
-
font-size: var(--frock-text-lg);
|
|
1149
|
+
padding: 0 52px 0 4px;
|
|
1104
1150
|
}
|
|
1105
1151
|
|
|
1106
1152
|
.window-actions {
|
|
1107
1153
|
padding: 0 8px;
|
|
1108
1154
|
}
|
|
1109
1155
|
|
|
1156
|
+
.brand-mark {
|
|
1157
|
+
font-size: var(--frock-text-lg);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1110
1160
|
.nav-toggle {
|
|
1111
1161
|
flex: 0 0 auto;
|
|
1112
1162
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { WebChatMessage } from "../shared.js";
|
|
3
|
+
import {
|
|
4
|
+
TRANSCRIPT_CACHE_LIMIT,
|
|
5
|
+
TranscriptCache,
|
|
6
|
+
TRANSCRIPT_FRESH_MS,
|
|
7
|
+
} from "./transcript-cache.js";
|
|
8
|
+
|
|
9
|
+
function message(runId: string, text = "hello"): WebChatMessage {
|
|
10
|
+
return {
|
|
11
|
+
id: `${runId}:user`,
|
|
12
|
+
runId,
|
|
13
|
+
role: "user",
|
|
14
|
+
text,
|
|
15
|
+
status: "completed",
|
|
16
|
+
tools: [],
|
|
17
|
+
sends: [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function snapshot(conversationKey = "bot:1", runId = "run-1") {
|
|
22
|
+
return { conversationKey, messages: [message(runId)] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("TranscriptCache", () => {
|
|
26
|
+
test("gives a saved conversation back without a read", () => {
|
|
27
|
+
const cache = new TranscriptCache();
|
|
28
|
+
cache.save("alpha", snapshot());
|
|
29
|
+
const restored = cache.take("alpha", "bot:1");
|
|
30
|
+
expect(restored?.messages.map((entry) => entry.runId)).toEqual(["run-1"]);
|
|
31
|
+
expect(restored?.stale).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("hands back copies, so the caller's edits never reach the cache", () => {
|
|
35
|
+
const cache = new TranscriptCache();
|
|
36
|
+
cache.save("alpha", snapshot());
|
|
37
|
+
const restored = cache.take("alpha", "bot:1");
|
|
38
|
+
restored?.messages.push(message("run-2"));
|
|
39
|
+
const [first] = restored?.messages ?? [];
|
|
40
|
+
if (first) first.text = "rewritten";
|
|
41
|
+
expect(cache.take("alpha", "bot:1")?.messages).toEqual([message("run-1")]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a different conversation on the same Bot is a miss, not the old one", () => {
|
|
45
|
+
const cache = new TranscriptCache();
|
|
46
|
+
cache.save("alpha", snapshot("bot:1"));
|
|
47
|
+
// ADR 0027: "new conversation" moves the Bot to a new Session, and the
|
|
48
|
+
// transcript that belonged to the previous one must not come back.
|
|
49
|
+
expect(cache.take("alpha", "bot:1#2")).toBeUndefined();
|
|
50
|
+
// The miss drops it: nothing will ask for that conversation again.
|
|
51
|
+
expect(cache.take("alpha", "bot:1")).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("keeps the last N Bots and evicts the least recently used", () => {
|
|
55
|
+
const cache = new TranscriptCache();
|
|
56
|
+
for (let index = 0; index < TRANSCRIPT_CACHE_LIMIT + 2; index += 1) {
|
|
57
|
+
cache.save(`bot-${index}`, snapshot(`key-${index}`, `run-${index}`));
|
|
58
|
+
}
|
|
59
|
+
expect(cache.size).toBe(TRANSCRIPT_CACHE_LIMIT);
|
|
60
|
+
expect(cache.take("bot-0", "key-0")).toBeUndefined();
|
|
61
|
+
expect(cache.take("bot-1", "key-1")).toBeUndefined();
|
|
62
|
+
expect(cache.take("bot-2", "key-2")).toBeDefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("reading a transcript makes it the last one evicted", () => {
|
|
66
|
+
const cache = new TranscriptCache({ limit: 2 });
|
|
67
|
+
cache.save("alpha", snapshot("a"));
|
|
68
|
+
cache.save("beta", snapshot("b"));
|
|
69
|
+
// Alpha is the oldest write but the newest use.
|
|
70
|
+
expect(cache.take("alpha", "a")).toBeDefined();
|
|
71
|
+
cache.save("gamma", snapshot("c"));
|
|
72
|
+
expect(cache.take("beta", "b")).toBeUndefined();
|
|
73
|
+
expect(cache.take("alpha", "a")).toBeDefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an empty transcript is not held", () => {
|
|
77
|
+
const cache = new TranscriptCache();
|
|
78
|
+
cache.save("alpha", snapshot());
|
|
79
|
+
cache.save("alpha", { conversationKey: "bot:1", messages: [] });
|
|
80
|
+
expect(cache.size).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a channel notice leaves the transcript drawable but owes a read", () => {
|
|
84
|
+
const cache = new TranscriptCache();
|
|
85
|
+
cache.save("alpha", snapshot());
|
|
86
|
+
cache.markStale("alpha");
|
|
87
|
+
const restored = cache.take("alpha", "bot:1");
|
|
88
|
+
expect(restored?.messages).toHaveLength(1);
|
|
89
|
+
expect(restored?.stale).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a transcript past its freshness window owes a read", () => {
|
|
93
|
+
let clock = 1_000;
|
|
94
|
+
const cache = new TranscriptCache({ now: () => clock });
|
|
95
|
+
cache.save("alpha", snapshot());
|
|
96
|
+
clock += TRANSCRIPT_FRESH_MS + 1;
|
|
97
|
+
expect(cache.take("alpha", "bot:1")?.stale).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("forget drops one Bot, or every Bot", () => {
|
|
101
|
+
const cache = new TranscriptCache();
|
|
102
|
+
cache.save("alpha", snapshot("a"));
|
|
103
|
+
cache.save("beta", snapshot("b"));
|
|
104
|
+
cache.forget("alpha");
|
|
105
|
+
expect(cache.take("alpha", "a")).toBeUndefined();
|
|
106
|
+
expect(cache.take("beta", "b")).toBeDefined();
|
|
107
|
+
// Signing out is not "some conversations are stale", it is "none of these
|
|
108
|
+
// are this User's".
|
|
109
|
+
cache.forget();
|
|
110
|
+
expect(cache.size).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("remembers where the reader had the thread", () => {
|
|
114
|
+
const cache = new TranscriptCache();
|
|
115
|
+
cache.save("alpha", snapshot());
|
|
116
|
+
cache.rememberViewport("alpha", { scrollTop: 420, pinnedToLatest: false });
|
|
117
|
+
expect(cache.take("alpha", "bot:1")?.viewport).toEqual({
|
|
118
|
+
scrollTop: 420,
|
|
119
|
+
pinnedToLatest: false,
|
|
120
|
+
});
|
|
121
|
+
// A viewport for a Bot that is not held is dropped rather than resurrecting it.
|
|
122
|
+
cache.rememberViewport("ghost", { scrollTop: 1, pinnedToLatest: true });
|
|
123
|
+
expect(cache.take("ghost", "bot:1")).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the client keeps of a conversation it is not currently showing.
|
|
3
|
+
*
|
|
4
|
+
* Clicking between Bots used to throw the transcript away and read it back:
|
|
5
|
+
* every switch was an empty thread, a network round trip, and a scroll jump.
|
|
6
|
+
* A Bot's conversation is small, already durable behind it, and cheap to hold,
|
|
7
|
+
* so the last few are kept in memory and redrawn immediately.
|
|
8
|
+
*
|
|
9
|
+
* Two rules keep the cache from lying:
|
|
10
|
+
*
|
|
11
|
+
* - **It is keyed by conversation, not by Bot.** ADR 0027 makes "new
|
|
12
|
+
* conversation" change the Session a Bot's Turns record, so the transcript
|
|
13
|
+
* that belonged to the previous one must not come back under the same key.
|
|
14
|
+
* - **A cached transcript is still revalidated.** The entry carries when it
|
|
15
|
+
* was written; past {@link TRANSCRIPT_FRESH_MS}, or once something has told
|
|
16
|
+
* the client the Bot's runs moved, the restore is followed by a read. Inside
|
|
17
|
+
* that window the click costs nothing, which is the whole point.
|
|
18
|
+
*
|
|
19
|
+
* The cache is memory only and never outlives the page: nothing about one
|
|
20
|
+
* User's conversations reaches the next one through it.
|
|
21
|
+
*/
|
|
22
|
+
import type { WebActiveRun, WebChatMessage } from "../shared.ts";
|
|
23
|
+
|
|
24
|
+
/** How many Bots' transcripts are held before the least recent is dropped. */
|
|
25
|
+
export const TRANSCRIPT_CACHE_LIMIT = 8;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How long a cached transcript is served without a read behind it. Long
|
|
29
|
+
* enough that clicking between Bots is free, short enough that a conversation
|
|
30
|
+
* changed on another device is never stale for long.
|
|
31
|
+
*/
|
|
32
|
+
export const TRANSCRIPT_FRESH_MS = 30_000;
|
|
33
|
+
|
|
34
|
+
/** Where the reader had the thread when they switched away. */
|
|
35
|
+
export interface TranscriptViewport {
|
|
36
|
+
scrollTop: number;
|
|
37
|
+
/**
|
|
38
|
+
* True when they were at the end. Restored as "the end" rather than as the
|
|
39
|
+
* pixel offset, so a transcript that grew while they were away comes back
|
|
40
|
+
* pinned to the newest Turn and not to where it used to be.
|
|
41
|
+
*/
|
|
42
|
+
pinnedToLatest: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One conversation, as the thread last drew it. */
|
|
46
|
+
export interface TranscriptSnapshot {
|
|
47
|
+
/** Distinguishes this conversation from the next one on the same Bot. */
|
|
48
|
+
conversationKey: string;
|
|
49
|
+
messages: WebChatMessage[];
|
|
50
|
+
activeRun?: WebActiveRun;
|
|
51
|
+
activeRunId?: string;
|
|
52
|
+
runningRunId?: string;
|
|
53
|
+
viewport?: TranscriptViewport;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface CacheEntry extends TranscriptSnapshot {
|
|
57
|
+
writtenAt: number;
|
|
58
|
+
stale: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A restored transcript, and whether reading it back is still owed. */
|
|
62
|
+
export interface TranscriptRestore extends TranscriptSnapshot {
|
|
63
|
+
/** True when the caller should revalidate before trusting this for long. */
|
|
64
|
+
stale: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TranscriptCacheOptions {
|
|
68
|
+
limit?: number;
|
|
69
|
+
freshMs?: number;
|
|
70
|
+
now?: () => number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The last few Bots' conversations, most recently used last.
|
|
75
|
+
*
|
|
76
|
+
* `Map` iteration order is insertion order, so "touch on read" is a delete
|
|
77
|
+
* followed by a set and the first key is always the eviction candidate.
|
|
78
|
+
*/
|
|
79
|
+
export class TranscriptCache {
|
|
80
|
+
readonly #entries = new Map<string, CacheEntry>();
|
|
81
|
+
readonly #limit: number;
|
|
82
|
+
readonly #freshMs: number;
|
|
83
|
+
readonly #now: () => number;
|
|
84
|
+
|
|
85
|
+
constructor(options: TranscriptCacheOptions = {}) {
|
|
86
|
+
this.#limit = options.limit ?? TRANSCRIPT_CACHE_LIMIT;
|
|
87
|
+
this.#freshMs = options.freshMs ?? TRANSCRIPT_FRESH_MS;
|
|
88
|
+
this.#now = options.now ?? (() => Date.now());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The Bots held, least recently used first. */
|
|
92
|
+
get botIds(): readonly string[] {
|
|
93
|
+
return [...this.#entries.keys()];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get size(): number {
|
|
97
|
+
return this.#entries.size;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The transcript for this Bot's current conversation, if it is held.
|
|
102
|
+
*
|
|
103
|
+
* A key mismatch is a miss and drops the entry: the conversation it holds
|
|
104
|
+
* is over, and nothing will ask for it again.
|
|
105
|
+
*/
|
|
106
|
+
take(botId: string, conversationKey: string): TranscriptRestore | undefined {
|
|
107
|
+
const entry = this.#entries.get(botId);
|
|
108
|
+
if (!entry) return undefined;
|
|
109
|
+
if (entry.conversationKey !== conversationKey) {
|
|
110
|
+
this.#entries.delete(botId);
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
// Reading is using: this Bot is now the most recent and the last to go.
|
|
114
|
+
this.#entries.delete(botId);
|
|
115
|
+
this.#entries.set(botId, entry);
|
|
116
|
+
return {
|
|
117
|
+
conversationKey: entry.conversationKey,
|
|
118
|
+
// Copies, so the caller's edits never reach back into the cache.
|
|
119
|
+
messages: entry.messages.map((message) => ({ ...message })),
|
|
120
|
+
...(entry.activeRun ? { activeRun: { ...entry.activeRun } } : {}),
|
|
121
|
+
...(entry.activeRunId ? { activeRunId: entry.activeRunId } : {}),
|
|
122
|
+
...(entry.runningRunId ? { runningRunId: entry.runningRunId } : {}),
|
|
123
|
+
...(entry.viewport ? { viewport: { ...entry.viewport } } : {}),
|
|
124
|
+
stale: entry.stale || this.#now() - entry.writtenAt > this.#freshMs,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Holds this conversation, evicting the least recently used past the limit. */
|
|
129
|
+
save(botId: string, snapshot: TranscriptSnapshot): void {
|
|
130
|
+
// An empty transcript is not worth a slot: restoring it looks exactly like
|
|
131
|
+
// the read it would have saved.
|
|
132
|
+
if (snapshot.messages.length === 0) {
|
|
133
|
+
this.#entries.delete(botId);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// The scroll position is written after the transcript, by the thread that
|
|
137
|
+
// still has it on screen, so a save that carries none keeps the last one.
|
|
138
|
+
const viewport =
|
|
139
|
+
snapshot.viewport ?? this.#entries.get(botId)?.viewport ?? undefined;
|
|
140
|
+
this.#entries.delete(botId);
|
|
141
|
+
this.#entries.set(botId, {
|
|
142
|
+
conversationKey: snapshot.conversationKey,
|
|
143
|
+
messages: snapshot.messages.map((message) => ({ ...message })),
|
|
144
|
+
...(snapshot.activeRun ? { activeRun: { ...snapshot.activeRun } } : {}),
|
|
145
|
+
...(snapshot.activeRunId ? { activeRunId: snapshot.activeRunId } : {}),
|
|
146
|
+
...(snapshot.runningRunId ? { runningRunId: snapshot.runningRunId } : {}),
|
|
147
|
+
...(viewport ? { viewport: { ...viewport } } : {}),
|
|
148
|
+
writtenAt: this.#now(),
|
|
149
|
+
stale: false,
|
|
150
|
+
});
|
|
151
|
+
while (this.#entries.size > this.#limit) {
|
|
152
|
+
const oldest = this.#entries.keys().next();
|
|
153
|
+
if (oldest.done) break;
|
|
154
|
+
this.#entries.delete(oldest.value);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Records where the reader had the thread, without touching the transcript. */
|
|
159
|
+
rememberViewport(botId: string, viewport: TranscriptViewport): void {
|
|
160
|
+
const entry = this.#entries.get(botId);
|
|
161
|
+
if (!entry) return;
|
|
162
|
+
entry.viewport = { ...viewport };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Where the reader had this Bot's thread, if it is still held. */
|
|
166
|
+
viewportFor(botId: string): TranscriptViewport | undefined {
|
|
167
|
+
const viewport = this.#entries.get(botId)?.viewport;
|
|
168
|
+
return viewport ? { ...viewport } : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Says this Bot's runs have moved. The transcript still draws immediately —
|
|
173
|
+
* a stale answer beats an empty thread — but a read follows it.
|
|
174
|
+
*/
|
|
175
|
+
markStale(botId: string): void {
|
|
176
|
+
const entry = this.#entries.get(botId);
|
|
177
|
+
if (entry) entry.stale = true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Drops what is held for this Bot, or for every Bot.
|
|
182
|
+
*
|
|
183
|
+
* Called where the cached transcript would be a lie rather than merely old:
|
|
184
|
+
* archive, delete, rename, and a change of signed-in User.
|
|
185
|
+
*/
|
|
186
|
+
forget(botId?: string): void {
|
|
187
|
+
if (botId === undefined) this.#entries.clear();
|
|
188
|
+
else this.#entries.delete(botId);
|
|
189
|
+
}
|
|
190
|
+
}
|