@frockbot/plugin-shell 0.3.11 → 0.3.12
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 +34 -33
- package/src/agent.ts +65 -1
- package/src/backend-configuration.test.ts +6 -6
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +85 -18
- 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/index.test.ts +25 -5
- package/src/client/index.ts +191 -47
- 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 +118 -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/run-protocol.test.ts +37 -0
- package/src/run-protocol.ts +135 -36
- 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
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,41 @@
|
|
|
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. While the Bot is working it sits on its
|
|
364
|
+
* own row underneath, so the trail streaming off its right has the width of
|
|
365
|
+
* the transcript to run through rather than the gutter beside a bubble. Every
|
|
366
|
+
* reply here is from the same Bot — there are no group conversations — so a
|
|
367
|
+
* sheep on every settled line said nothing and cost the column its left edge.
|
|
341
368
|
*/
|
|
342
369
|
.message-assistant {
|
|
343
|
-
flex-direction:
|
|
344
|
-
align-items:
|
|
370
|
+
flex-direction: column;
|
|
371
|
+
align-items: stretch;
|
|
345
372
|
gap: 8px;
|
|
346
373
|
}
|
|
347
374
|
|
|
348
375
|
.message-column {
|
|
349
376
|
display: flex;
|
|
350
377
|
min-width: 0;
|
|
351
|
-
flex: 1 1 auto;
|
|
352
378
|
flex-direction: column;
|
|
353
379
|
align-items: flex-start;
|
|
354
380
|
gap: 6px;
|
|
355
381
|
}
|
|
356
382
|
|
|
383
|
+
/*
|
|
384
|
+
* The working row. Its height is the trail's canvas: tall enough for the
|
|
385
|
+
* wobble to be visible, short enough that it reads as one line of the thread.
|
|
386
|
+
*/
|
|
387
|
+
.bot-working {
|
|
388
|
+
display: flex;
|
|
389
|
+
height: 44px;
|
|
390
|
+
align-items: center;
|
|
391
|
+
gap: 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.bot-working-indicator {
|
|
395
|
+
height: 100%;
|
|
396
|
+
}
|
|
397
|
+
|
|
357
398
|
.bot-avatar {
|
|
358
399
|
position: relative;
|
|
359
400
|
display: grid;
|
|
@@ -361,7 +402,6 @@
|
|
|
361
402
|
height: var(--frock-avatar-sm);
|
|
362
403
|
flex: 0 0 auto;
|
|
363
404
|
place-items: center;
|
|
364
|
-
margin-top: 2px;
|
|
365
405
|
}
|
|
366
406
|
|
|
367
407
|
.bot-avatar-fallback {
|
|
@@ -396,16 +436,17 @@
|
|
|
396
436
|
}
|
|
397
437
|
|
|
398
438
|
/*
|
|
399
|
-
* The
|
|
400
|
-
* now, and the
|
|
439
|
+
* The working row's way off screen. The Turn has settled, the reply is the
|
|
440
|
+
* answer now, and the row fades rather than vanishing between two frames —
|
|
441
|
+
* which is also long enough for the particles still in flight to drain.
|
|
401
442
|
*/
|
|
402
|
-
.
|
|
403
|
-
.
|
|
443
|
+
.bot-working-enter-active,
|
|
444
|
+
.bot-working-leave-active {
|
|
404
445
|
transition: opacity 420ms ease-out;
|
|
405
446
|
}
|
|
406
447
|
|
|
407
|
-
.
|
|
408
|
-
.
|
|
448
|
+
.bot-working-enter-from,
|
|
449
|
+
.bot-working-leave-to {
|
|
409
450
|
opacity: 0;
|
|
410
451
|
}
|
|
411
452
|
|
|
@@ -420,6 +461,62 @@
|
|
|
420
461
|
}
|
|
421
462
|
}
|
|
422
463
|
|
|
464
|
+
@keyframes frock-halo {
|
|
465
|
+
0%,
|
|
466
|
+
100% {
|
|
467
|
+
opacity: 0;
|
|
468
|
+
transform: scale(0.94);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
50% {
|
|
472
|
+
opacity: 1;
|
|
473
|
+
transform: scale(1.06);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/*
|
|
478
|
+
* The bubble a Turn has before it has words: three dots that say the Bot is
|
|
479
|
+
* working, where the reply itself will appear.
|
|
480
|
+
*/
|
|
481
|
+
.message-working {
|
|
482
|
+
display: flex;
|
|
483
|
+
align-items: center;
|
|
484
|
+
min-height: 20px;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.working-dots {
|
|
488
|
+
display: inline-flex;
|
|
489
|
+
gap: 4px;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.working-dots i {
|
|
493
|
+
width: 6px;
|
|
494
|
+
height: 6px;
|
|
495
|
+
border-radius: 50%;
|
|
496
|
+
background: var(--frock-text-muted);
|
|
497
|
+
animation: frock-working-dot 1.2s ease-in-out infinite;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.working-dots i:nth-child(2) {
|
|
501
|
+
animation-delay: 0.15s;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.working-dots i:nth-child(3) {
|
|
505
|
+
animation-delay: 0.3s;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
@keyframes frock-working-dot {
|
|
509
|
+
0%,
|
|
510
|
+
60%,
|
|
511
|
+
100% {
|
|
512
|
+
opacity: 0.3;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
30% {
|
|
516
|
+
opacity: 1;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
423
520
|
/*
|
|
424
521
|
* A bubble runs nearly the width of the transcript, leaving one clear margin
|
|
425
522
|
* on the side it is not anchored to: the Bot's at the end, the User's at the
|
|
@@ -1094,19 +1191,21 @@
|
|
|
1094
1191
|
padding-left: 0;
|
|
1095
1192
|
}
|
|
1096
1193
|
|
|
1194
|
+
/* No window chrome to clear, but the panel toggle still sits at the
|
|
1195
|
+
trailing edge, so the row ends before it rather than under it. */
|
|
1097
1196
|
.topbar {
|
|
1098
1197
|
gap: 8px;
|
|
1099
|
-
padding: 0
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
.brand-mark {
|
|
1103
|
-
font-size: var(--frock-text-lg);
|
|
1198
|
+
padding: 0 52px 0 4px;
|
|
1104
1199
|
}
|
|
1105
1200
|
|
|
1106
1201
|
.window-actions {
|
|
1107
1202
|
padding: 0 8px;
|
|
1108
1203
|
}
|
|
1109
1204
|
|
|
1205
|
+
.brand-mark {
|
|
1206
|
+
font-size: var(--frock-text-lg);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1110
1209
|
.nav-toggle {
|
|
1111
1210
|
flex: 0 0 auto;
|
|
1112
1211
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// A compaction that outlives the Turn, and yields to the next one (ADR 0030).
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import {
|
|
4
|
+
compactionInFlightV1,
|
|
5
|
+
compactionWorkV1,
|
|
6
|
+
whenCompactionSettledV1,
|
|
7
|
+
yieldCompactionWorkV1,
|
|
8
|
+
} from "./compaction-scheduler.js";
|
|
9
|
+
|
|
10
|
+
function stall(signal: AbortSignal): Promise<never> {
|
|
11
|
+
return new Promise((_resolve, reject) => {
|
|
12
|
+
signal.addEventListener("abort", () => reject(signal.reason), {
|
|
13
|
+
once: true,
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A promise and the function that settles it, for ordering without timers. */
|
|
19
|
+
function gate(): { promise: Promise<void>; open: () => void } {
|
|
20
|
+
let open = () => {};
|
|
21
|
+
const promise = new Promise<void>((resolve) => {
|
|
22
|
+
open = resolve;
|
|
23
|
+
});
|
|
24
|
+
return { promise, open };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("detached compaction", () => {
|
|
28
|
+
test("starting it does not wait for it", async () => {
|
|
29
|
+
const session = `session-${crypto.randomUUID()}`;
|
|
30
|
+
let finished = false;
|
|
31
|
+
const running = gate();
|
|
32
|
+
const started = Date.now();
|
|
33
|
+
compactionWorkV1(session).start(async (signal) => {
|
|
34
|
+
running.open();
|
|
35
|
+
await stall(signal).catch(() => {});
|
|
36
|
+
finished = true;
|
|
37
|
+
});
|
|
38
|
+
// The claim the defect got wrong: control is back immediately.
|
|
39
|
+
expect(Date.now() - started).toBeLessThan(50);
|
|
40
|
+
expect(finished).toBe(false);
|
|
41
|
+
expect(compactionInFlightV1(session)).toBe(true);
|
|
42
|
+
await running.promise;
|
|
43
|
+
await yieldCompactionWorkV1(session);
|
|
44
|
+
expect(finished).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a newly admitted Turn aborts it rather than queueing behind it", async () => {
|
|
48
|
+
const session = `session-${crypto.randomUUID()}`;
|
|
49
|
+
let reason: unknown;
|
|
50
|
+
const running = gate();
|
|
51
|
+
compactionWorkV1(session).start(async (signal) => {
|
|
52
|
+
running.open();
|
|
53
|
+
try {
|
|
54
|
+
await stall(signal);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
reason = error;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
await running.promise;
|
|
60
|
+
await yieldCompactionWorkV1(session);
|
|
61
|
+
expect(compactionInFlightV1(session)).toBe(false);
|
|
62
|
+
expect(String((reason as Error).message)).toContain("yielded");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a failure is nobody's problem, and never leaves work in flight", async () => {
|
|
66
|
+
const session = `session-${crypto.randomUUID()}`;
|
|
67
|
+
compactionWorkV1(session).start(async () => {
|
|
68
|
+
throw new Error("the summariser fell over");
|
|
69
|
+
});
|
|
70
|
+
await whenCompactionSettledV1(session);
|
|
71
|
+
expect(compactionInFlightV1(session)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("two compactions on one conversation never run beside each other", async () => {
|
|
75
|
+
const session = `session-${crypto.randomUUID()}`;
|
|
76
|
+
const order: string[] = [];
|
|
77
|
+
const release = gate();
|
|
78
|
+
compactionWorkV1(session).start(async () => {
|
|
79
|
+
order.push("first:start");
|
|
80
|
+
await release.promise;
|
|
81
|
+
order.push("first:end");
|
|
82
|
+
});
|
|
83
|
+
compactionWorkV1(session).start(async () => {
|
|
84
|
+
order.push("second:start");
|
|
85
|
+
});
|
|
86
|
+
release.open();
|
|
87
|
+
await whenCompactionSettledV1(session);
|
|
88
|
+
expect(order).toEqual(["first:start", "first:end", "second:start"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("yielding costs nothing when no compaction is running", async () => {
|
|
92
|
+
await expect(
|
|
93
|
+
yieldCompactionWorkV1(`session-${crypto.randomUUID()}`),
|
|
94
|
+
).resolves.toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|