@frockbot/plugin-shell 0.3.10 → 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.
Files changed (37) hide show
  1. package/package.json +34 -32
  2. package/src/agent.test.ts +66 -0
  3. package/src/agent.ts +107 -2
  4. package/src/backend-configuration.test.ts +15 -9
  5. package/src/backend-package-catalog.ts +75 -26
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +118 -27
  8. package/src/client/FrockBotApp.vue +405 -134
  9. package/src/client/activity-trail.test.ts +205 -0
  10. package/src/client/activity-trail.ts +227 -0
  11. package/src/client/index.test.ts +25 -5
  12. package/src/client/index.ts +191 -47
  13. package/src/client/model-presentation.test.ts +3 -3
  14. package/src/client/no-bot-model-label.test.ts +7 -7
  15. package/src/client/skill-invocation.test.ts +34 -0
  16. package/src/client/skill-invocation.ts +22 -0
  17. package/src/client/styles.css +134 -89
  18. package/src/client/transcript-cache.test.ts +125 -0
  19. package/src/client/transcript-cache.ts +190 -0
  20. package/src/compaction-scheduler.test.ts +96 -0
  21. package/src/compaction-scheduler.ts +108 -0
  22. package/src/compaction-transcript.test.ts +174 -0
  23. package/src/compaction.test.ts +596 -0
  24. package/src/compaction.ts +539 -0
  25. package/src/focus.test.ts +222 -0
  26. package/src/focus.ts +93 -0
  27. package/src/history.ts +86 -8
  28. package/src/legacy-frock-model-id.test.ts +148 -0
  29. package/src/notification-id.test.ts +26 -0
  30. package/src/notification-id.ts +0 -0
  31. package/src/run-protocol.test.ts +37 -0
  32. package/src/run-protocol.ts +148 -38
  33. package/src/settings-links.test.ts +8 -2
  34. package/src/settings-links.ts +17 -2
  35. package/src/shared.ts +36 -0
  36. package/src/unread.ts +23 -1
  37. package/tsconfig.json +1 -2
@@ -0,0 +1,596 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { Context } from "cordis";
3
+ import {
4
+ decodeSessionEvent,
5
+ SessionStore,
6
+ type LlmMessage,
7
+ type SessionEvent,
8
+ type SessionEventInput,
9
+ } from "@frockbot/kernel-contracts";
10
+ import {
11
+ assessCompactionV1,
12
+ compactionMessageV1,
13
+ compactionStateV1,
14
+ historyCharsV1,
15
+ parseCompactionSummaryV1,
16
+ PRUNED_TOOL_RESULT_V1,
17
+ pruneToolOutputsV1,
18
+ runCompactionV1,
19
+ COMPACTION_TRIGGER_RATIO_V1,
20
+ } from "./compaction.js";
21
+ import { chatWindowV1, turnScopedMessagesV1 } from "./history.js";
22
+
23
+ const SESSION_ID = "user-1:bot-1";
24
+
25
+ function log(inputs: SessionEventInput[]): SessionEvent[] {
26
+ return inputs.map((input, index) =>
27
+ decodeSessionEvent({
28
+ ...input,
29
+ seq: index,
30
+ timestamp: new Date(1_700_000_000_000 + index).toISOString(),
31
+ }),
32
+ );
33
+ }
34
+
35
+ /** The same derivation `Session.deriveMessages` performs, over a fixed log. */
36
+ function derive(events: readonly SessionEvent[]): LlmMessage[] {
37
+ const messages: LlmMessage[] = [];
38
+ for (const event of events) {
39
+ if (event.type === "user/message") {
40
+ messages.push({ role: "user", content: event.text });
41
+ } else if (event.type === "assistant/message") {
42
+ messages.push({
43
+ role: "assistant",
44
+ content: event.text,
45
+ toolCalls: event.toolCalls,
46
+ });
47
+ } else if (event.type === "tool/result") {
48
+ messages.push({
49
+ role: "tool",
50
+ callId: event.occurrenceId,
51
+ name: event.name,
52
+ content: event.content,
53
+ isError: event.isError,
54
+ });
55
+ }
56
+ }
57
+ return messages;
58
+ }
59
+
60
+ interface TurnShape {
61
+ turn: number;
62
+ say?: string;
63
+ reply?: string;
64
+ /** A tool result of this many characters. */
65
+ toolChars?: number;
66
+ }
67
+
68
+ function turnEvents(shape: TurnShape): SessionEventInput[] {
69
+ const { turn } = shape;
70
+ const events: SessionEventInput[] = [
71
+ { type: "turn/start", turn },
72
+ { type: "turn/admission", turn, turnType: "chat" },
73
+ { type: "step/start", turn, step: 1 },
74
+ {
75
+ type: "user/message",
76
+ turn,
77
+ step: 1,
78
+ messageId: `m-${turn}`,
79
+ text: shape.say ?? `question ${turn}`,
80
+ },
81
+ ];
82
+ if (shape.toolChars !== undefined) {
83
+ events.push(
84
+ {
85
+ type: "assistant/message",
86
+ turn,
87
+ step: 1,
88
+ requestId: `r-${turn}`,
89
+ text: "",
90
+ toolCalls: [{ id: `c-${turn}`, name: "search", input: {} }],
91
+ },
92
+ {
93
+ type: "tool/result",
94
+ turn,
95
+ step: 1,
96
+ occurrenceId: `o-${turn}`,
97
+ name: "search",
98
+ content: "T".repeat(shape.toolChars),
99
+ isError: false,
100
+ status: "completed",
101
+ },
102
+ );
103
+ }
104
+ events.push(
105
+ {
106
+ type: "assistant/message",
107
+ turn,
108
+ step: 1,
109
+ requestId: `r2-${turn}`,
110
+ text: shape.reply ?? `answer ${turn}`,
111
+ toolCalls: [],
112
+ },
113
+ { type: "step/end", turn, step: 1, outcome: "completed" },
114
+ { type: "turn/end", turn, outcome: "completed" },
115
+ );
116
+ return events;
117
+ }
118
+
119
+ /** A conversation of `count` Turns, each carrying a fat tool result. */
120
+ function conversation(count: number, toolChars: number): SessionEventInput[] {
121
+ return Array.from({ length: count }, (_, index) =>
122
+ turnEvents({ turn: index + 1, toolChars }),
123
+ ).flat();
124
+ }
125
+
126
+ /** The same, with the weight in the assistant's words rather than in tools. */
127
+ function wordy(count: number, chars: number): SessionEventInput[] {
128
+ return Array.from({ length: count }, (_, index) =>
129
+ turnEvents({ turn: index + 1, reply: "W".repeat(chars) }),
130
+ ).flat();
131
+ }
132
+
133
+ /** An open Turn, so `currentTurnV1` names it and it is never compacted. */
134
+ function openTurn(turn: number): SessionEventInput[] {
135
+ return [
136
+ { type: "turn/start", turn },
137
+ { type: "turn/admission", turn, turnType: "chat" },
138
+ { type: "step/start", turn, step: 1 },
139
+ {
140
+ type: "user/message",
141
+ turn,
142
+ step: 1,
143
+ messageId: `m-${turn}`,
144
+ text: `question ${turn}`,
145
+ },
146
+ ];
147
+ }
148
+
149
+ const MODEL_REQUEST: SessionEventInput = {
150
+ type: "model/request",
151
+ turn: 1,
152
+ step: 1,
153
+ request: {
154
+ requestId: "seed",
155
+ provider: "ollama-cloud",
156
+ model: "kimi-k2",
157
+ system: "",
158
+ messages: [],
159
+ tools: [],
160
+ },
161
+ };
162
+
163
+ function windowOf(events: readonly SessionEvent[]) {
164
+ return chatWindowV1(events, derive(events));
165
+ }
166
+
167
+ describe("the size estimate", () => {
168
+ test("is the character measure over the pruned window", () => {
169
+ const events = log([MODEL_REQUEST, ...conversation(8, 5_000)]);
170
+ const window = windowOf(events);
171
+ const raw = historyCharsV1(window.messages);
172
+ const pruned = historyCharsV1(
173
+ pruneToolOutputsV1(window.messages, window.turns),
174
+ );
175
+ // Pruning is what the trigger measures, so a conversation whose weight is
176
+ // tool output never reaches the summariser at all.
177
+ expect(pruned).toBeLessThan(raw / 2);
178
+ });
179
+
180
+ test("does not fire under the threshold", () => {
181
+ const events = log([MODEL_REQUEST, ...conversation(8, 100)]);
182
+ const window = windowOf(events);
183
+ const assessment = assessCompactionV1({
184
+ ...window,
185
+ budget: 150_000,
186
+ currentTurn: 8,
187
+ });
188
+ expect(assessment.skipped).toBe("under-threshold");
189
+ expect(assessment.throughTurn).toBeUndefined();
190
+ expect(assessment.threshold).toBe(
191
+ Math.floor(150_000 * COMPACTION_TRIGGER_RATIO_V1),
192
+ );
193
+ });
194
+
195
+ test("fires over the threshold and keeps the newest four Turns", () => {
196
+ const events = log([MODEL_REQUEST, ...wordy(10, 400)]);
197
+ const window = windowOf(events);
198
+ const assessment = assessCompactionV1({
199
+ ...window,
200
+ budget: 4_000,
201
+ currentTurn: 10,
202
+ });
203
+ expect(assessment.chars).toBeGreaterThan(assessment.threshold);
204
+ expect(assessment.throughTurn).toBe(6);
205
+ expect(assessment.fromTurn).toBe(1);
206
+ });
207
+
208
+ test("has nothing new to cover when only the recent Turns are left", () => {
209
+ const events = log([MODEL_REQUEST, ...conversation(3, 4_000)]);
210
+ const window = windowOf(events);
211
+ expect(
212
+ assessCompactionV1({ ...window, budget: 2_000, currentTurn: 3 }).skipped,
213
+ ).toBe("nothing-new-to-cover");
214
+ });
215
+
216
+ test("backs off after a failure, then tries again", () => {
217
+ const base = [
218
+ MODEL_REQUEST,
219
+ ...wordy(10, 400),
220
+ {
221
+ type: "conversation/compaction-intent" as const,
222
+ effectId: "e1",
223
+ throughTurn: 6,
224
+ provider: "ollama-cloud",
225
+ model: "kimi-k2",
226
+ },
227
+ {
228
+ type: "conversation/compaction-failed" as const,
229
+ effectId: "e1",
230
+ throughTurn: 6,
231
+ reason: "provider said no",
232
+ },
233
+ ];
234
+ const events = log(base);
235
+ const window = windowOf(events);
236
+ expect(window.state.failures).toBe(1);
237
+ // One failure waits one Turn: the Turn it failed on is not enough.
238
+ expect(
239
+ assessCompactionV1({ ...window, budget: 4_000, currentTurn: 6 }).skipped,
240
+ ).toBe("backing-off");
241
+ expect(
242
+ assessCompactionV1({ ...window, budget: 4_000, currentTurn: 10 })
243
+ .throughTurn,
244
+ ).toBe(6);
245
+ });
246
+ });
247
+
248
+ describe("pruning tool outputs", () => {
249
+ const messages: LlmMessage[] = [
250
+ {
251
+ role: "tool",
252
+ callId: "c1",
253
+ name: "search",
254
+ content: "A".repeat(500),
255
+ isError: false,
256
+ },
257
+ {
258
+ role: "tool",
259
+ callId: "c2",
260
+ name: "search",
261
+ content: "short",
262
+ isError: false,
263
+ },
264
+ {
265
+ role: "tool",
266
+ callId: "c3",
267
+ name: "search",
268
+ content: "B".repeat(500),
269
+ isError: false,
270
+ },
271
+ ];
272
+ const turns = [1, 1, 5];
273
+
274
+ test("keeps the pairing and drops only the payload", () => {
275
+ const pruned = pruneToolOutputsV1(messages, turns, 1);
276
+ expect(pruned[0]).toEqual({
277
+ role: "tool",
278
+ callId: "c1",
279
+ name: "search",
280
+ content: PRUNED_TOOL_RESULT_V1,
281
+ isError: false,
282
+ });
283
+ });
284
+
285
+ test("leaves the newest Turns and small results alone", () => {
286
+ const pruned = pruneToolOutputsV1(messages, turns, 1);
287
+ expect(pruned[1]!.content).toBe("short");
288
+ expect(pruned[2]!.content).toBe("B".repeat(500));
289
+ });
290
+
291
+ test("reaches a Turn's tool results through request assembly", () => {
292
+ const events = log([
293
+ MODEL_REQUEST,
294
+ ...conversation(6, 1_000),
295
+ ...openTurn(7),
296
+ ]);
297
+ const messages = turnScopedMessagesV1({
298
+ events,
299
+ messages: derive(events),
300
+ pointer: () => "pointer",
301
+ sessionId: SESSION_ID,
302
+ });
303
+ // The newest three Turns of the window are 5, 6 and the open 7, so only
304
+ // Turns 5 and 6 still carry a payload.
305
+ const tools = messages.filter((message) => message.role === "tool");
306
+ expect(tools).toHaveLength(6);
307
+ expect(
308
+ tools.slice(0, 4).every((t) => t.content === PRUNED_TOOL_RESULT_V1),
309
+ ).toBe(true);
310
+ expect(tools.slice(4).every((t) => t.content === "T".repeat(1_000))).toBe(
311
+ true,
312
+ );
313
+ });
314
+ });
315
+
316
+ describe("injecting a compaction", () => {
317
+ const events = log([
318
+ MODEL_REQUEST,
319
+ ...conversation(6, 0),
320
+ {
321
+ type: "conversation/compacted" as const,
322
+ effectId: "e1",
323
+ fromTurn: 1,
324
+ throughTurn: 4,
325
+ summary: "## Summary\nThey discussed the plan.",
326
+ identifiers: ["pkg-abc123"],
327
+ provider: "ollama-cloud",
328
+ model: "kimi-k2",
329
+ },
330
+ ...openTurn(7),
331
+ ]);
332
+
333
+ test("puts the summary first and drops the Turns it covers", () => {
334
+ const messages = turnScopedMessagesV1({
335
+ events,
336
+ messages: derive(events),
337
+ pointer: () => "pointer",
338
+ sessionId: SESSION_ID,
339
+ });
340
+ expect(messages[0]!.role).toBe("user");
341
+ expect(messages[0]!.content).toContain("They discussed the plan.");
342
+ expect(messages[0]!.content).toContain("pkg-abc123");
343
+ const text = messages.slice(1).map((message) => message.content);
344
+ expect(text).not.toContain("question 4");
345
+ expect(text).toContain("question 5");
346
+ expect(text).toContain("question 7");
347
+ });
348
+
349
+ test("spends the summary from the budget rather than evicting it", () => {
350
+ const messages = turnScopedMessagesV1({
351
+ events,
352
+ messages: derive(events),
353
+ pointer: () => "pointer",
354
+ sessionId: SESSION_ID,
355
+ budget: 400,
356
+ });
357
+ expect(messages[0]!.content).toContain("They discussed the plan.");
358
+ expect(messages.at(-1)!.content).toBe("question 7");
359
+ });
360
+
361
+ test("never covers the Turn being assembled", () => {
362
+ const stale = log([
363
+ MODEL_REQUEST,
364
+ ...conversation(3, 0),
365
+ {
366
+ type: "conversation/compacted" as const,
367
+ effectId: "e1",
368
+ fromTurn: 1,
369
+ throughTurn: 9,
370
+ summary: "everything",
371
+ identifiers: [],
372
+ provider: "ollama-cloud",
373
+ model: "kimi-k2",
374
+ },
375
+ ...openTurn(4),
376
+ ]);
377
+ const messages = turnScopedMessagesV1({
378
+ events: stale,
379
+ messages: derive(stale),
380
+ pointer: () => "pointer",
381
+ sessionId: SESSION_ID,
382
+ });
383
+ expect(messages.at(-1)!.content).toBe("question 4");
384
+ });
385
+ });
386
+
387
+ describe("reading the summariser's answer", () => {
388
+ test("lifts the identifiers out of their heading", () => {
389
+ const parsed = parseCompactionSummaryV1(
390
+ [
391
+ "## Summary",
392
+ "Work on the Applet.",
393
+ "## Identifiers mentioned",
394
+ "- applet-9f2c",
395
+ "- https://example.test/a?b=c",
396
+ "",
397
+ ].join("\n"),
398
+ );
399
+ expect(parsed?.identifiers).toEqual([
400
+ "applet-9f2c",
401
+ "https://example.test/a?b=c",
402
+ ]);
403
+ expect(parsed?.summary).toContain("Work on the Applet.");
404
+ });
405
+
406
+ test("reads `none` as an empty list", () => {
407
+ expect(
408
+ parseCompactionSummaryV1("## Identifiers mentioned\n- none")?.identifiers,
409
+ ).toEqual([]);
410
+ });
411
+
412
+ test("refuses an empty answer", () => {
413
+ expect(parseCompactionSummaryV1(" ")).toBeUndefined();
414
+ });
415
+ });
416
+
417
+ describe("running a compaction", () => {
418
+ const roots: Context[] = [];
419
+ afterEach(() => {
420
+ for (const root of roots.splice(0)) void root.fiber.dispose();
421
+ });
422
+
423
+ async function sessionFrom(inputs: SessionEventInput[]) {
424
+ const root = new Context();
425
+ roots.push(root);
426
+ await root.plugin(SessionStore, {
427
+ initialSessions: { [SESSION_ID]: log(inputs) },
428
+ });
429
+ return root.sessions.create(SESSION_ID);
430
+ }
431
+
432
+ function runner(
433
+ session: Awaited<ReturnType<typeof sessionFrom>>,
434
+ summarise: () => Promise<string>,
435
+ currentTurn = 10,
436
+ ) {
437
+ return {
438
+ session,
439
+ window: chatWindowV1(session.events, session.deriveMessages()),
440
+ budget: 4_000,
441
+ currentTurn,
442
+ newEffectId: () => "effect-1",
443
+ summarise,
444
+ };
445
+ }
446
+
447
+ const SUMMARY = "## Summary\nA long talk.\n## Identifiers mentioned\n- id-7";
448
+
449
+ test("records intent, then one compaction covering the range", async () => {
450
+ const session = await sessionFrom([MODEL_REQUEST, ...wordy(10, 400)]);
451
+ const outcome = await runCompactionV1(runner(session, async () => SUMMARY));
452
+ expect(outcome).toEqual({ kind: "compacted", throughTurn: 6, fromTurn: 1 });
453
+ const types = session.events.map((event) => event.type);
454
+ expect(
455
+ types.filter((t) => t === "conversation/compaction-intent"),
456
+ ).toHaveLength(1);
457
+ const compacted = session.events.findLast(
458
+ (event) => event.type === "conversation/compacted",
459
+ );
460
+ expect(
461
+ compacted?.type === "conversation/compacted" && compacted,
462
+ ).toMatchObject({
463
+ fromTurn: 1,
464
+ throughTurn: 6,
465
+ identifiers: ["id-7"],
466
+ provider: "ollama-cloud",
467
+ model: "kimi-k2",
468
+ });
469
+ // Every event it wrote decodes, so a reload reads back what it stored.
470
+ for (const event of session.events) {
471
+ expect(() => decodeSessionEvent(event)).not.toThrow();
472
+ }
473
+ });
474
+
475
+ test("is keyed by the range, so a second run compacts nothing", async () => {
476
+ const session = await sessionFrom([MODEL_REQUEST, ...wordy(10, 400)]);
477
+ let calls = 0;
478
+ await runCompactionV1(
479
+ runner(session, async () => {
480
+ calls += 1;
481
+ return SUMMARY;
482
+ }),
483
+ );
484
+ const again = await runCompactionV1(
485
+ runner(session, async () => {
486
+ calls += 1;
487
+ return SUMMARY;
488
+ }),
489
+ );
490
+ expect(calls).toBe(1);
491
+ expect(again.kind).toBe("skipped");
492
+ expect(
493
+ session.events.filter((event) => event.type === "conversation/compacted"),
494
+ ).toHaveLength(1);
495
+ });
496
+
497
+ test("settles an intent a restart left open, and writes no summary for it", async () => {
498
+ const session = await sessionFrom([
499
+ MODEL_REQUEST,
500
+ ...wordy(10, 400),
501
+ {
502
+ type: "conversation/compaction-intent",
503
+ effectId: "orphan",
504
+ throughTurn: 6,
505
+ provider: "ollama-cloud",
506
+ model: "kimi-k2",
507
+ },
508
+ ]);
509
+ let calls = 0;
510
+ const outcome = await runCompactionV1(
511
+ runner(session, async () => {
512
+ calls += 1;
513
+ return SUMMARY;
514
+ }),
515
+ );
516
+ expect(calls).toBe(0);
517
+ expect(outcome).toEqual({
518
+ kind: "failed",
519
+ throughTurn: 6,
520
+ reason: "interrupted",
521
+ });
522
+ expect(
523
+ session.events.some((event) => event.type === "conversation/compacted"),
524
+ ).toBe(false);
525
+ expect(compactionStateV1(session.events).unsettled).toBeUndefined();
526
+ });
527
+
528
+ test("records a failure and leaves the conversation alone", async () => {
529
+ const session = await sessionFrom([MODEL_REQUEST, ...wordy(10, 400)]);
530
+ const outcome = await runCompactionV1(
531
+ runner(session, async () => {
532
+ throw new Error("provider refused\nthe request");
533
+ }),
534
+ );
535
+ expect(outcome.kind).toBe("failed");
536
+ const failure = session.events.findLast(
537
+ (event) => event.type === "conversation/compaction-failed",
538
+ );
539
+ expect(
540
+ failure?.type === "conversation/compaction-failed" && failure.reason,
541
+ ).toBe("provider refused the request");
542
+ // The request that follows is exactly the one ADR 0027 would assemble.
543
+ expect(compactionStateV1(session.events).compaction).toBeUndefined();
544
+ });
545
+
546
+ test("folds a previous summary into the range it extends", async () => {
547
+ const session = await sessionFrom([
548
+ MODEL_REQUEST,
549
+ ...wordy(14, 400),
550
+ {
551
+ type: "conversation/compacted",
552
+ effectId: "old",
553
+ fromTurn: 1,
554
+ throughTurn: 5,
555
+ summary: "the first five",
556
+ identifiers: [],
557
+ provider: "ollama-cloud",
558
+ model: "kimi-k2",
559
+ },
560
+ ]);
561
+ let seen = "";
562
+ const outcome = await runCompactionV1({
563
+ ...runner(session, async () => SUMMARY, 14),
564
+ summarise: async (request) => {
565
+ seen = String(request.messages[0]?.content ?? "");
566
+ return SUMMARY;
567
+ },
568
+ });
569
+ expect(seen).toContain("the first five");
570
+ expect(seen).not.toContain("question 3");
571
+ expect(seen).toContain("question 6");
572
+ expect(outcome).toEqual({
573
+ kind: "compacted",
574
+ fromTurn: 1,
575
+ throughTurn: 10,
576
+ });
577
+ });
578
+ });
579
+
580
+ describe("the compaction message", () => {
581
+ test("says plainly that the Turns are not there", () => {
582
+ const message = compactionMessageV1({
583
+ effectId: "e",
584
+ fromTurn: 1,
585
+ throughTurn: 9,
586
+ summary: "the gist",
587
+ identifiers: ["bot-42"],
588
+ provider: "p",
589
+ model: "m",
590
+ });
591
+ expect(message.role).toBe("user");
592
+ expect(message.content).toContain("Turns 1 to 9");
593
+ expect(message.content).toContain("the gist");
594
+ expect(message.content).toContain("bot-42");
595
+ });
596
+ });