@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.
@@ -0,0 +1,205 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ACTIVITY_TRAIL_CHARACTERS_PER_PARTICLE_V1,
4
+ ACTIVITY_TRAIL_MAX_BURSTS_PER_STEP_V1,
5
+ ACTIVITY_TRAIL_MAX_RATE_V1,
6
+ ACTIVITY_TRAIL_QUIET_AFTER_MS_V1,
7
+ ACTIVITY_TRAIL_SEND_BURST_V1,
8
+ ACTIVITY_TRAIL_TOOL_BURST_V1,
9
+ ACTIVITY_TRAIL_TRICKLE_RATE_V1,
10
+ activityTrailBeginV1,
11
+ activityTrailSampleV1,
12
+ activityTrailStepV1,
13
+ type ActivityTrailSampleV1,
14
+ } from "./activity-trail.js";
15
+
16
+ function sample(
17
+ overrides: Partial<ActivityTrailSampleV1> = {},
18
+ ): ActivityTrailSampleV1 {
19
+ return {
20
+ characters: 0,
21
+ toolStarts: 0,
22
+ toolSettles: 0,
23
+ sends: 0,
24
+ status: "streaming",
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ describe("what the transcript says a Turn is doing", () => {
30
+ test("a tool with no result yet has started but not settled", () => {
31
+ const reading = activityTrailSampleV1({
32
+ text: "half a rep",
33
+ toolStatuses: ["completed", "running"],
34
+ sends: 1,
35
+ status: "streaming",
36
+ });
37
+ expect(reading.characters).toBe("half a rep".length);
38
+ expect(reading.toolStarts).toBe(2);
39
+ expect(reading.toolSettles).toBe(1);
40
+ expect(reading.sends).toBe(1);
41
+ });
42
+ });
43
+
44
+ describe("the comet trail's emission plan", () => {
45
+ test("a Turn that has produced nothing yet still emits", () => {
46
+ // Liveness before pace: the first moment of a Turn is exactly when a
47
+ // person needs to see that something is happening.
48
+ const memory = activityTrailBeginV1(sample(), 0);
49
+ const { plan } = activityTrailStepV1(memory, sample(), 16);
50
+ expect(plan.active).toBe(true);
51
+ expect(plan.state).toBe("running");
52
+ expect(plan.rate).toBe(ACTIVITY_TRAIL_TRICKLE_RATE_V1);
53
+ expect(plan.bursts).toEqual([]);
54
+ });
55
+
56
+ test("the density tracks how fast text is arriving", () => {
57
+ // Two hundred characters inside the averaging window is well above the
58
+ // trickle and below saturation, so the rate is the chunk rate itself.
59
+ let memory = activityTrailBeginV1(sample(), 0);
60
+ const stepped = activityTrailStepV1(
61
+ memory,
62
+ sample({ characters: 200 }),
63
+ 200,
64
+ );
65
+ memory = stepped.memory;
66
+ expect(stepped.plan.rate).toBeGreaterThan(ACTIVITY_TRAIL_TRICKLE_RATE_V1);
67
+ expect(stepped.plan.rate).toBeLessThan(ACTIVITY_TRAIL_MAX_RATE_V1);
68
+ expect(stepped.plan.rate).toBeCloseTo(
69
+ 200 / 1.2 / ACTIVITY_TRAIL_CHARACTERS_PER_PARTICLE_V1,
70
+ 5,
71
+ );
72
+
73
+ // Half as much text in the same window is half the density.
74
+ const slower = activityTrailStepV1(
75
+ activityTrailBeginV1(sample(), 0),
76
+ sample({ characters: 100 }),
77
+ 200,
78
+ );
79
+ expect(slower.plan.rate).toBeCloseTo(stepped.plan.rate / 2, 5);
80
+ });
81
+
82
+ test("a torrent of text saturates rather than running away", () => {
83
+ const { plan } = activityTrailStepV1(
84
+ activityTrailBeginV1(sample(), 0),
85
+ sample({ characters: 100_000 }),
86
+ 100,
87
+ );
88
+ expect(plan.rate).toBe(ACTIVITY_TRAIL_MAX_RATE_V1);
89
+ });
90
+
91
+ test("a tool call starting throws a burst, and settling throws another", () => {
92
+ let memory = activityTrailBeginV1(sample(), 0);
93
+ const started = activityTrailStepV1(memory, sample({ toolStarts: 1 }), 100);
94
+ memory = started.memory;
95
+ expect(started.plan.bursts).toEqual([
96
+ { count: ACTIVITY_TRAIL_TOOL_BURST_V1, speed: 1.8, brightness: 1.15 },
97
+ ]);
98
+
99
+ const settled = activityTrailStepV1(
100
+ memory,
101
+ sample({ toolStarts: 1, toolSettles: 1 }),
102
+ 200,
103
+ );
104
+ expect(settled.plan.bursts).toHaveLength(1);
105
+ expect(settled.plan.bursts[0]?.count).toBe(ACTIVITY_TRAIL_TOOL_BURST_V1);
106
+ });
107
+
108
+ test("a send landing is a brighter puff than a tool call", () => {
109
+ const { plan } = activityTrailStepV1(
110
+ activityTrailBeginV1(sample(), 0),
111
+ sample({ sends: 1 }),
112
+ 100,
113
+ );
114
+ expect(plan.bursts).toEqual([
115
+ { count: ACTIVITY_TRAIL_SEND_BURST_V1, speed: 1.2, brightness: 1.9 },
116
+ ]);
117
+ });
118
+
119
+ test("a whole Turn arriving at once does not fire a hundred bursts", () => {
120
+ // What a reconnect looks like: the projection replays every step of a Turn
121
+ // in one update. The trail says "a lot just happened", not two hundred
122
+ // separate shots into one frame.
123
+ const { plan } = activityTrailStepV1(
124
+ activityTrailBeginV1(sample(), 0),
125
+ sample({ toolStarts: 60, toolSettles: 60, sends: 20 }),
126
+ 100,
127
+ );
128
+ expect(plan.bursts).toHaveLength(ACTIVITY_TRAIL_MAX_BURSTS_PER_STEP_V1);
129
+ });
130
+
131
+ test("an open Turn that has gone quiet trickles rather than stopping", () => {
132
+ let memory = activityTrailBeginV1(sample(), 0);
133
+ memory = activityTrailStepV1(
134
+ memory,
135
+ sample({ characters: 40 }),
136
+ 100,
137
+ ).memory;
138
+
139
+ const soon = activityTrailStepV1(
140
+ memory,
141
+ sample({ characters: 40 }),
142
+ 100 + ACTIVITY_TRAIL_QUIET_AFTER_MS_V1,
143
+ );
144
+ expect(soon.plan.state).toBe("running");
145
+
146
+ const later = activityTrailStepV1(
147
+ memory,
148
+ sample({ characters: 40 }),
149
+ 101 + ACTIVITY_TRAIL_QUIET_AFTER_MS_V1,
150
+ );
151
+ expect(later.plan.active).toBe(true);
152
+ expect(later.plan.state).toBe("waiting");
153
+ expect(later.plan.rate).toBe(ACTIVITY_TRAIL_TRICKLE_RATE_V1);
154
+ expect(later.plan.bursts).toEqual([]);
155
+ });
156
+
157
+ test("a settled Turn emits nothing at all", () => {
158
+ for (const status of [
159
+ "completed",
160
+ "aborted",
161
+ "error",
162
+ "interrupted",
163
+ "reconciliation-required",
164
+ "a status this file has never heard of",
165
+ ]) {
166
+ const { plan } = activityTrailStepV1(
167
+ activityTrailBeginV1(sample(), 0),
168
+ sample({ characters: 400, toolStarts: 3, toolSettles: 3, status }),
169
+ 100,
170
+ );
171
+ expect(plan.active).toBe(false);
172
+ expect(plan.state).toBe("ended");
173
+ expect(plan.rate).toBe(0);
174
+ expect(plan.bursts).toEqual([]);
175
+ }
176
+ });
177
+
178
+ test("text the Turn takes back is no work rather than negative work", () => {
179
+ // A delivered send supersedes the model's own longer draft, so the
180
+ // transcript's text can shrink between two samples.
181
+ const memory = activityTrailBeginV1(sample({ characters: 500 }), 0);
182
+ const { plan } = activityTrailStepV1(
183
+ memory,
184
+ sample({ characters: 4, sends: 1 }),
185
+ 100,
186
+ );
187
+ expect(plan.rate).toBe(ACTIVITY_TRAIL_TRICKLE_RATE_V1);
188
+ expect(plan.bursts).toHaveLength(1);
189
+ });
190
+
191
+ test("the rate window forgets text that arrived long ago", () => {
192
+ let memory = activityTrailBeginV1(sample(), 0);
193
+ memory = activityTrailStepV1(
194
+ memory,
195
+ sample({ characters: 400 }),
196
+ 100,
197
+ ).memory;
198
+ const { plan } = activityTrailStepV1(
199
+ memory,
200
+ sample({ characters: 400 }),
201
+ 100 + ACTIVITY_TRAIL_QUIET_AFTER_MS_V1 + 1,
202
+ );
203
+ expect(plan.rate).toBe(ACTIVITY_TRAIL_TRICKLE_RATE_V1);
204
+ });
205
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The comet trail: what a running Turn is actually doing, drawn as motion.
3
+ *
4
+ * A Turn can spend a minute streaming tokens and calling tools, and the thread
5
+ * used to answer that with a stroke around the avatar that ticked once per
6
+ * settled step. The stroke was honest but coarse — it said "a step happened"
7
+ * and nothing about the rate work was arriving at. The trail is the finer
8
+ * answer: particles stream off the right of the working Bot's avatar, and
9
+ * their density is the Turn's own pace. Text arriving quickly is a dense
10
+ * stream; a tool call starting or settling throws a burst; a reply landing is
11
+ * a bright puff; a Turn waiting on the model still breathes, faintly, so the
12
+ * app never reads as dead.
13
+ *
14
+ * This module is the whole mapping, kept out of the component so it is
15
+ * testable without a canvas or a mounted Vue tree. It takes samples of what
16
+ * the client already knows about the open Bot's Turn — the transcript's text,
17
+ * its tool activity, its sends, its status — and returns the emission plan for
18
+ * the moment between two samples. It never touches the DOM and never keeps a
19
+ * clock of its own: the caller supplies `now`.
20
+ */
21
+
22
+ /** The most particles a second the trail will ever ask for. */
23
+ export const ACTIVITY_TRAIL_MAX_RATE_V1 = 40;
24
+
25
+ /**
26
+ * The floor while a Turn is open. A Turn waiting on a model that has not sent
27
+ * a token yet is still working, and a trail that stops entirely reads as an
28
+ * app that has crashed.
29
+ */
30
+ export const ACTIVITY_TRAIL_TRICKLE_RATE_V1 = 6;
31
+
32
+ /** Silence longer than this is a wait, not a pause between chunks. */
33
+ export const ACTIVITY_TRAIL_QUIET_AFTER_MS_V1 = 1500;
34
+
35
+ /** How far back the chunk-rate average looks. */
36
+ export const ACTIVITY_TRAIL_RATE_WINDOW_MS_V1 = 1200;
37
+
38
+ /**
39
+ * Characters of streamed text one particle stands for. At the cap above this
40
+ * makes a full stream about 240 characters a second — faster than that and the
41
+ * trail is simply saturated, which is the right thing for it to say.
42
+ */
43
+ export const ACTIVITY_TRAIL_CHARACTERS_PER_PARTICLE_V1 = 6;
44
+
45
+ /** A tool call starting, or its result settling. */
46
+ export const ACTIVITY_TRAIL_TOOL_BURST_V1 = 15;
47
+
48
+ /** A payload reaching the person. */
49
+ export const ACTIVITY_TRAIL_SEND_BURST_V1 = 14;
50
+
51
+ /**
52
+ * The most burst events one step will honour. A transcript that arrives in one
53
+ * lump — a reconnect replaying a whole Turn — must not fire two hundred bursts
54
+ * into the same frame.
55
+ */
56
+ export const ACTIVITY_TRAIL_MAX_BURSTS_PER_STEP_V1 = 4;
57
+
58
+ /**
59
+ * What the trail is saying, and the value the row carries as `data-state` so a
60
+ * spec can assert on it.
61
+ *
62
+ * `running` is work arriving now, `waiting` is an open Turn that has gone
63
+ * quiet, and `ended` is a settled Turn: nothing new is emitted and whatever is
64
+ * on screen drains.
65
+ */
66
+ export type ActivityTrailStateV1 = "running" | "waiting" | "ended";
67
+
68
+ /** One reading of the open Turn, as the client's projection has it. */
69
+ export interface ActivityTrailSampleV1 {
70
+ /** Characters of assistant text in the Turn so far. Monotonic. */
71
+ characters: number;
72
+ /** Tool calls this Turn has started, settled or not. Monotonic. */
73
+ toolStarts: number;
74
+ /** Tool calls whose result has settled. Monotonic. */
75
+ toolSettles: number;
76
+ /** Payloads this Turn has delivered to the person. Monotonic. */
77
+ sends: number;
78
+ /**
79
+ * The Turn's status, as the whole string rather than a union: only
80
+ * `streaming` means the Turn is still going, and every other value —
81
+ * including one a newer backend invents — is an ending. The trail is driven
82
+ * off that one positive test, so it cannot be left emitting forever by a
83
+ * status this file has never heard of.
84
+ */
85
+ status: string;
86
+ }
87
+
88
+ /** A one-off shot of particles, over and above the steady stream. */
89
+ export interface ActivityTrailBurstV1 {
90
+ /** Particles to spawn at once. */
91
+ count: number;
92
+ /** Multiplier on the stream's rightward speed. */
93
+ speed: number;
94
+ /** Multiplier on the particles' opacity. Above one reads as a flash. */
95
+ brightness: number;
96
+ }
97
+
98
+ /** What to emit for the moment between two samples. */
99
+ export interface ActivityTrailPlanV1 {
100
+ /** Whether the emitter runs at all. A settled Turn emits nothing. */
101
+ active: boolean;
102
+ /** What the trail is saying, for the row's `data-state`. */
103
+ state: ActivityTrailStateV1;
104
+ /** Steady particles per second, `0 … ACTIVITY_TRAIL_MAX_RATE_V1`. */
105
+ rate: number;
106
+ /** Shots to fire once, now. */
107
+ bursts: readonly ActivityTrailBurstV1[];
108
+ }
109
+
110
+ /** What the mapping remembers between two samples. Opaque to the caller. */
111
+ export interface ActivityTrailMemoryV1 {
112
+ sample: ActivityTrailSampleV1;
113
+ /** When something last changed, so a wait can be told from a pause. */
114
+ lastEventAt: number;
115
+ /** Recent character deltas, for the rate average. */
116
+ window: ReadonlyArray<{ at: number; characters: number }>;
117
+ }
118
+
119
+ /**
120
+ * A sample from the shapes the transcript already carries.
121
+ *
122
+ * Kept here rather than in the component so the Turn's vocabulary — a tool
123
+ * that has not settled is `running`, the text is the whole bubble so far —
124
+ * lives with the rule that reads it.
125
+ */
126
+ export function activityTrailSampleV1(input: {
127
+ text: string;
128
+ toolStatuses: readonly string[];
129
+ sends: number;
130
+ status: string;
131
+ }): ActivityTrailSampleV1 {
132
+ return {
133
+ characters: input.text.length,
134
+ toolStarts: input.toolStatuses.length,
135
+ toolSettles: input.toolStatuses.filter((status) => status !== "running")
136
+ .length,
137
+ sends: input.sends,
138
+ status: input.status,
139
+ };
140
+ }
141
+
142
+ /** The memory a Turn starts with. Its first sample is the baseline. */
143
+ export function activityTrailBeginV1(
144
+ sample: ActivityTrailSampleV1,
145
+ now: number,
146
+ ): ActivityTrailMemoryV1 {
147
+ return { sample, lastEventAt: now, window: [] };
148
+ }
149
+
150
+ function clamp(value: number, low: number, high: number): number {
151
+ return Math.min(high, Math.max(low, value));
152
+ }
153
+
154
+ /**
155
+ * The plan for the moment between the remembered sample and this one.
156
+ *
157
+ * Deltas are floored at zero: a projection that replaces a Turn's text with a
158
+ * shorter final version — a send superseding the model's own draft — is not a
159
+ * negative amount of work, it is no work.
160
+ */
161
+ export function activityTrailStepV1(
162
+ memory: ActivityTrailMemoryV1,
163
+ sample: ActivityTrailSampleV1,
164
+ now: number,
165
+ ): { memory: ActivityTrailMemoryV1; plan: ActivityTrailPlanV1 } {
166
+ const characters = Math.max(0, sample.characters - memory.sample.characters);
167
+ const startedTools = Math.max(
168
+ 0,
169
+ sample.toolStarts - memory.sample.toolStarts,
170
+ );
171
+ const settledTools = Math.max(
172
+ 0,
173
+ sample.toolSettles - memory.sample.toolSettles,
174
+ );
175
+ const delivered = Math.max(0, sample.sends - memory.sample.sends);
176
+
177
+ if (sample.status !== "streaming") {
178
+ return {
179
+ memory: { sample, lastEventAt: memory.lastEventAt, window: [] },
180
+ plan: { active: false, state: "ended", rate: 0, bursts: [] },
181
+ };
182
+ }
183
+
184
+ const window = [...memory.window, { at: now, characters }].filter(
185
+ (entry) => now - entry.at <= ACTIVITY_TRAIL_RATE_WINDOW_MS_V1,
186
+ );
187
+ const streamed = window.reduce((total, entry) => total + entry.characters, 0);
188
+ const streamRate =
189
+ streamed /
190
+ (ACTIVITY_TRAIL_RATE_WINDOW_MS_V1 / 1000) /
191
+ ACTIVITY_TRAIL_CHARACTERS_PER_PARTICLE_V1;
192
+
193
+ const moved =
194
+ characters > 0 || startedTools > 0 || settledTools > 0 || delivered > 0;
195
+ const lastEventAt = moved ? now : memory.lastEventAt;
196
+ const quiet = now - lastEventAt > ACTIVITY_TRAIL_QUIET_AFTER_MS_V1;
197
+
198
+ const bursts: ActivityTrailBurstV1[] = [];
199
+ for (let index = 0; index < startedTools + settledTools; index += 1) {
200
+ bursts.push({
201
+ count: ACTIVITY_TRAIL_TOOL_BURST_V1,
202
+ speed: 1.8,
203
+ brightness: 1.15,
204
+ });
205
+ }
206
+ for (let index = 0; index < delivered; index += 1) {
207
+ bursts.push({
208
+ count: ACTIVITY_TRAIL_SEND_BURST_V1,
209
+ speed: 1.2,
210
+ brightness: 1.9,
211
+ });
212
+ }
213
+
214
+ return {
215
+ memory: { sample, lastEventAt, window },
216
+ plan: {
217
+ active: true,
218
+ state: quiet ? "waiting" : "running",
219
+ rate: clamp(
220
+ Math.max(streamRate, ACTIVITY_TRAIL_TRICKLE_RATE_V1),
221
+ 0,
222
+ ACTIVITY_TRAIL_MAX_RATE_V1,
223
+ ),
224
+ bursts: bursts.slice(0, ACTIVITY_TRAIL_MAX_BURSTS_PER_STEP_V1),
225
+ },
226
+ };
227
+ }
@@ -572,7 +572,11 @@ describe("Bot selection", () => {
572
572
  await Promise.all([botLoad, olderLoad]);
573
573
 
574
574
  expect(provided.value.userSettings?.profile.name).toBe("Newer");
575
- expect(provided.value.settingsError).toBe("Bot settings unavailable");
575
+ // The surface says what failed and what it was for; the thrown text
576
+ // ("Bot settings unavailable") stays in the console.
577
+ expect(provided.value.settingsError).toBe(
578
+ "Couldn't load this Bot's settings.",
579
+ );
576
580
  });
577
581
 
578
582
  test("says a deployment has no Catalog instead of relaying the gateway's sentence", async () => {
@@ -608,12 +612,14 @@ describe("Bot selection", () => {
608
612
  "No plugins are published for this deployment yet.",
609
613
  );
610
614
 
611
- // A genuine fault still reaches the operator, labelled as one.
615
+ // A genuine fault is labelled as one and says what failed rather than
616
+ // what the storage layer called it. "R2 is down" is for the console.
612
617
  failure = new Error("R2 is down");
613
618
  await provided.value.loadPackageCatalog();
614
619
  expect(provided.value.settingsError).toBe(
615
- "Plugins could not be loaded: R2 is down",
620
+ "Couldn't load the plugin catalog.",
616
621
  );
622
+ expect(provided.value.settingsError).not.toContain("R2");
617
623
  });
618
624
 
619
625
  test("commits a catalog without overwriting newer User settings", async () => {
@@ -1258,14 +1264,20 @@ describe("detached Turn projection", () => {
1258
1264
  ],
1259
1265
  );
1260
1266
 
1267
+ // The failure is a notice under the reply, never a bubble that reads as
1268
+ // the Bot saying the provider's own words.
1261
1269
  expect(messages).toMatchObject([
1262
1270
  { role: "user", status: "completed" },
1263
1271
  {
1264
1272
  role: "assistant",
1265
- text: "This Bot couldn't finish its reply. Try again.",
1273
+ text: "",
1274
+ notice: "This Bot couldn't finish its reply. Try again.",
1266
1275
  status: "error",
1267
1276
  },
1268
1277
  ]);
1278
+ expect(JSON.stringify(messages)).not.toContain(
1279
+ "Provider reconciliation is required",
1280
+ );
1269
1281
  });
1270
1282
  });
1271
1283
 
@@ -1726,6 +1738,9 @@ describe("active durable Turn projection", () => {
1726
1738
  },
1727
1739
  ],
1728
1740
  );
1741
+ // One sentence of the product's own, and the raw provider text nowhere on
1742
+ // screen — the banner, the bubble and the notice all read the same way
1743
+ // whatever the provider called the failure.
1729
1744
  expect(reconciliation.activeRun).toEqual({
1730
1745
  runId: "run-reconciliation",
1731
1746
  status: "reconciliation-required",
@@ -1733,9 +1748,14 @@ describe("active durable Turn projection", () => {
1733
1748
  canResume: true,
1734
1749
  });
1735
1750
  expect(reconciliation.messages[1]).toMatchObject({
1736
- text: "This reply stopped partway. Try again to continue it.",
1751
+ role: "assistant",
1752
+ text: "",
1753
+ notice: "This reply stopped partway. Try again to continue it.",
1737
1754
  status: "reconciliation-required",
1738
1755
  });
1756
+ expect(JSON.stringify(reconciliation.messages)).not.toContain(
1757
+ "Provider result needs confirmation",
1758
+ );
1739
1759
  });
1740
1760
 
1741
1761
  test("keeps busy state until the durable run becomes terminal", () => {