@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.
- package/package.json +34 -32
- package/src/agent.test.ts +66 -0
- package/src/agent.ts +107 -2
- package/src/backend-configuration.test.ts +15 -9
- package/src/backend-package-catalog.ts +75 -26
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +118 -27
- package/src/client/FrockBotApp.vue +405 -134
- 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 +134 -89
- 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.test.ts +26 -0
- package/src/notification-id.ts +0 -0
- package/src/run-protocol.test.ts +37 -0
- package/src/run-protocol.ts +148 -38
- package/src/settings-links.test.ts +8 -2
- package/src/settings-links.ts +17 -2
- package/src/shared.ts +36 -0
- package/src/unread.ts +23 -1
- package/tsconfig.json +1 -2
|
@@ -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
|
+
}
|
package/src/client/index.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
"
|
|
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: "
|
|
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
|
-
|
|
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", () => {
|