@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/run-protocol.ts
CHANGED
|
@@ -244,14 +244,30 @@ export interface ClientRunPageV1 {
|
|
|
244
244
|
* A durable Session event that belongs to no Turn. The WebUI renders it as a
|
|
245
245
|
* system line in the conversation.
|
|
246
246
|
*/
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
247
|
+
/**
|
|
248
|
+
* A session-level line in the transcript that belongs to neither party.
|
|
249
|
+
*
|
|
250
|
+
* `conversation/compacted` is ADR 0030's one user-visible surface: the earlier
|
|
251
|
+
* Turns are still there and still readable, and this says plainly that the
|
|
252
|
+
* model now carries a summary of them instead of the Turns themselves. ADR
|
|
253
|
+
* 0027's "not summarised" notice still stands where Turns were genuinely
|
|
254
|
+
* evicted, so the two never claim each other's ground.
|
|
255
|
+
*/
|
|
256
|
+
export type ClientAnnouncementV1 =
|
|
257
|
+
| {
|
|
258
|
+
type: "bot/renamed";
|
|
259
|
+
announcementId: string;
|
|
260
|
+
at: string;
|
|
261
|
+
from: string;
|
|
262
|
+
to: string;
|
|
263
|
+
namedBy: "user" | "bot";
|
|
264
|
+
}
|
|
265
|
+
| {
|
|
266
|
+
type: "conversation/compacted";
|
|
267
|
+
announcementId: string;
|
|
268
|
+
at: string;
|
|
269
|
+
throughTurn: number;
|
|
270
|
+
};
|
|
255
271
|
|
|
256
272
|
export interface ClientRunListV1 {
|
|
257
273
|
schemaVersion: 1;
|
|
@@ -286,6 +302,19 @@ export interface ClientConversationListV1 {
|
|
|
286
302
|
conversations: ClientConversationV1[];
|
|
287
303
|
}
|
|
288
304
|
|
|
305
|
+
/**
|
|
306
|
+
* The answer to "start a new conversation": the list, or the reason not now.
|
|
307
|
+
*
|
|
308
|
+
* A refusal is a value, not an exception. The request crosses a Durable Object
|
|
309
|
+
* boundary and a Worker boundary to get here, and an exception that crosses
|
|
310
|
+
* either is logged by workerd as `Uncaught Error` — the log then showed the
|
|
311
|
+
* isolate going down with a broken pipe behind it. Carrying the refusal as
|
|
312
|
+
* data means the only thing that reaches the client is the 409 it expects.
|
|
313
|
+
*/
|
|
314
|
+
export type ClientConversationOutcomeV1 =
|
|
315
|
+
| ({ status: "started" } & ClientConversationListV1)
|
|
316
|
+
| { status: "refused"; schemaVersion: 1; reason: string };
|
|
317
|
+
|
|
289
318
|
export function decodeClientConversationListV1(
|
|
290
319
|
input: unknown,
|
|
291
320
|
): ClientConversationListV1 {
|
|
@@ -983,24 +1012,74 @@ export function createClientRunListV1(
|
|
|
983
1012
|
const MAX_ANNOUNCEMENTS = 64;
|
|
984
1013
|
const MAX_ANNOUNCEMENT_NAME_BYTES = 400;
|
|
985
1014
|
|
|
986
|
-
/**
|
|
1015
|
+
/**
|
|
1016
|
+
* Where each Turn ended, by Turn number.
|
|
1017
|
+
*
|
|
1018
|
+
* A compaction is written at the end of the Turn that crossed the threshold,
|
|
1019
|
+
* which is the *newest* Turn — so its own timestamp would place its marker at
|
|
1020
|
+
* the bottom of the thread, far from the range it describes. The boundary it
|
|
1021
|
+
* actually names is the end of `throughTurn`, and that is what the marker is
|
|
1022
|
+
* dated with.
|
|
1023
|
+
*/
|
|
1024
|
+
function turnEndTimestampsV1(
|
|
1025
|
+
session: readonly SessionEvent[],
|
|
1026
|
+
): Map<number, string> {
|
|
1027
|
+
const ends = new Map<number, string>();
|
|
1028
|
+
for (const event of session) {
|
|
1029
|
+
if (event.type === "turn/end") ends.set(event.turn, event.timestamp);
|
|
1030
|
+
}
|
|
1031
|
+
return ends;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Projects the Bot's durable announcement events onto the wire.
|
|
1036
|
+
*
|
|
1037
|
+
* `session` is the conversation's own log, used only to date a compaction
|
|
1038
|
+
* marker at the boundary it covers. Omitting it dates the marker by when the
|
|
1039
|
+
* compaction was written, which is where it used to sit.
|
|
1040
|
+
*/
|
|
987
1041
|
export function projectClientAnnouncementsV1(
|
|
988
1042
|
events: readonly SessionEvent[],
|
|
1043
|
+
session: readonly SessionEvent[] = events,
|
|
989
1044
|
): ClientAnnouncementV1[] {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1045
|
+
const turnEnds = turnEndTimestampsV1(session);
|
|
1046
|
+
return events.flatMap((event): ClientAnnouncementV1[] => {
|
|
1047
|
+
if (event.type === "bot/renamed") {
|
|
1048
|
+
return [
|
|
1049
|
+
{
|
|
1050
|
+
type: "bot/renamed" as const,
|
|
1051
|
+
announcementId: `announcement-${event.seq}`,
|
|
1052
|
+
at: truncate(event.timestamp, MAX_TIMESTAMP_LENGTH),
|
|
1053
|
+
from: truncateWireString(event.from, MAX_ANNOUNCEMENT_NAME_BYTES),
|
|
1054
|
+
to: truncateWireString(event.to, MAX_ANNOUNCEMENT_NAME_BYTES),
|
|
1055
|
+
namedBy: event.namedBy,
|
|
1056
|
+
},
|
|
1057
|
+
];
|
|
1058
|
+
}
|
|
1059
|
+
if (event.type === "conversation/compacted") {
|
|
1060
|
+
// The summary itself is deliberately not on the wire. A person can read
|
|
1061
|
+
// every Turn it covers, unchanged, immediately above this line; the
|
|
1062
|
+
// summary is what the model carries, and it belongs to the audit view.
|
|
1063
|
+
return [
|
|
1064
|
+
{
|
|
1065
|
+
type: "conversation/compacted" as const,
|
|
1066
|
+
// A distinct prefix: a compaction is numbered by the session log and
|
|
1067
|
+
// a rename by this Bot's announcement log, and the two counters would
|
|
1068
|
+
// otherwise collide on an id the client upserts by.
|
|
1069
|
+
announcementId: `compaction-${event.seq}`,
|
|
1070
|
+
// Dated where the covered range ends, not when the summariser ran,
|
|
1071
|
+
// so the marker sits between the last compacted Turn and the first
|
|
1072
|
+
// verbatim one and stays there as newer Turns arrive.
|
|
1073
|
+
at: truncate(
|
|
1074
|
+
turnEnds.get(event.throughTurn) ?? event.timestamp,
|
|
1075
|
+
MAX_TIMESTAMP_LENGTH,
|
|
1076
|
+
),
|
|
1077
|
+
throughTurn: event.throughTurn,
|
|
1078
|
+
},
|
|
1079
|
+
];
|
|
1080
|
+
}
|
|
1081
|
+
return [];
|
|
1082
|
+
});
|
|
1004
1083
|
}
|
|
1005
1084
|
|
|
1006
1085
|
export function clientRunListWireBytes(value: ClientRunListV1): number {
|
|
@@ -1828,14 +1907,19 @@ export function decodeClientRunLookupV1(input: unknown): ClientRunLookup {
|
|
|
1828
1907
|
|
|
1829
1908
|
function decodeAnnouncement(value: unknown): ClientAnnouncementV1 {
|
|
1830
1909
|
const announcement = record(value, "run list.announcement");
|
|
1910
|
+
if (
|
|
1911
|
+
announcement.type !== "bot/renamed" &&
|
|
1912
|
+
announcement.type !== "conversation/compacted"
|
|
1913
|
+
) {
|
|
1914
|
+
throw new Error("run list.announcement.type is invalid");
|
|
1915
|
+
}
|
|
1831
1916
|
exactKeys(
|
|
1832
1917
|
announcement,
|
|
1833
|
-
|
|
1918
|
+
announcement.type === "conversation/compacted"
|
|
1919
|
+
? ["type", "announcementId", "at", "throughTurn"]
|
|
1920
|
+
: ["type", "announcementId", "at", "from", "to", "namedBy"],
|
|
1834
1921
|
"run list.announcement",
|
|
1835
1922
|
);
|
|
1836
|
-
if (announcement.type !== "bot/renamed") {
|
|
1837
|
-
throw new Error("run list.announcement.type is invalid");
|
|
1838
|
-
}
|
|
1839
1923
|
const at = string(
|
|
1840
1924
|
announcement,
|
|
1841
1925
|
"at",
|
|
@@ -1845,20 +1929,35 @@ function decodeAnnouncement(value: unknown): ClientAnnouncementV1 {
|
|
|
1845
1929
|
if (!Number.isFinite(Date.parse(at))) {
|
|
1846
1930
|
throw new Error("run list.announcement.at is invalid");
|
|
1847
1931
|
}
|
|
1932
|
+
const announcementId = publicEventId(
|
|
1933
|
+
string(
|
|
1934
|
+
announcement,
|
|
1935
|
+
"announcementId",
|
|
1936
|
+
MAX_EVENT_ID_LENGTH,
|
|
1937
|
+
"run list.announcement",
|
|
1938
|
+
),
|
|
1939
|
+
"run list.announcement.announcementId",
|
|
1940
|
+
);
|
|
1941
|
+
if (announcement.type === "conversation/compacted") {
|
|
1942
|
+
if (
|
|
1943
|
+
!Number.isSafeInteger(announcement.throughTurn) ||
|
|
1944
|
+
(announcement.throughTurn as number) < 1
|
|
1945
|
+
) {
|
|
1946
|
+
throw new Error("run list.announcement.throughTurn is invalid");
|
|
1947
|
+
}
|
|
1948
|
+
return {
|
|
1949
|
+
type: "conversation/compacted",
|
|
1950
|
+
announcementId,
|
|
1951
|
+
at,
|
|
1952
|
+
throughTurn: announcement.throughTurn as number,
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1848
1955
|
if (announcement.namedBy !== "user" && announcement.namedBy !== "bot") {
|
|
1849
1956
|
throw new Error("run list.announcement.namedBy is invalid");
|
|
1850
1957
|
}
|
|
1851
1958
|
return {
|
|
1852
1959
|
type: "bot/renamed",
|
|
1853
|
-
announcementId
|
|
1854
|
-
string(
|
|
1855
|
-
announcement,
|
|
1856
|
-
"announcementId",
|
|
1857
|
-
MAX_EVENT_ID_LENGTH,
|
|
1858
|
-
"run list.announcement",
|
|
1859
|
-
),
|
|
1860
|
-
"run list.announcement.announcementId",
|
|
1861
|
-
),
|
|
1960
|
+
announcementId,
|
|
1862
1961
|
at,
|
|
1863
1962
|
from: wireString(
|
|
1864
1963
|
announcement,
|
|
@@ -62,10 +62,16 @@ describe("settings link scheme", () => {
|
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
test("
|
|
65
|
+
test("a fragment belonging to another surface opens that surface", () => {
|
|
66
|
+
// The row is the specific request; the query parameter is where the link
|
|
67
|
+
// was written from, and rows move between surfaces.
|
|
66
68
|
expect(
|
|
67
69
|
decodeSettingsLinkV1("/?settings=bot-settings#bot-info-computer"),
|
|
68
|
-
).toEqual({ surface: "bot-
|
|
70
|
+
).toEqual({ surface: "bot-panel", anchor: "bot-info-computer" });
|
|
71
|
+
expect(decodeSettingsLinkV1("/?settings=bot-panel#bot-routines")).toEqual({
|
|
72
|
+
surface: "bot-settings",
|
|
73
|
+
anchor: "bot-routines",
|
|
74
|
+
});
|
|
69
75
|
});
|
|
70
76
|
|
|
71
77
|
test("drops an anchor nobody registered", () => {
|
package/src/settings-links.ts
CHANGED
|
@@ -275,9 +275,18 @@ export function decodeSettingsLinkV1(
|
|
|
275
275
|
const fragment = decodeURIComponent(url.hash.replace(/^#/u, ""));
|
|
276
276
|
const entry = fragment ? settingsAnchorV1(fragment) : undefined;
|
|
277
277
|
const botId = url.searchParams.get("bot") ?? undefined;
|
|
278
|
+
/*
|
|
279
|
+
* A fragment naming a row that lives somewhere else is still a request for
|
|
280
|
+
* that row, so the anchor decides which surface opens. Dropping it instead
|
|
281
|
+
* is what made `?settings=bot-panel#bot-routines` open the panel and then
|
|
282
|
+
* sit there: a link that resolves to nothing visible reads as a broken app,
|
|
283
|
+
* and a row moving between surfaces is exactly what these links have to
|
|
284
|
+
* survive. A fragment nobody registered still opens the named surface with
|
|
285
|
+
* no row.
|
|
286
|
+
*/
|
|
278
287
|
return {
|
|
279
|
-
surface,
|
|
280
|
-
...(entry
|
|
288
|
+
surface: entry ? entry.surface : surface,
|
|
289
|
+
...(entry ? { anchor: entry.anchor } : {}),
|
|
281
290
|
...(botId ? { botId } : {}),
|
|
282
291
|
};
|
|
283
292
|
}
|
package/src/shared.ts
CHANGED
|
@@ -222,6 +222,29 @@ export function withoutConnectionReturnV1(search: string): string {
|
|
|
222
222
|
return rest ? `?${rest}` : "";
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* What the shell holds of the conversations the User is not looking at, as
|
|
227
|
+
* everything outside the shell may address it.
|
|
228
|
+
*
|
|
229
|
+
* Two callers: the thread, which records and reads back where the reader had
|
|
230
|
+
* each Bot scrolled, and any surface that changes a Bot enough that its held
|
|
231
|
+
* transcript would be a lie rather than merely old — archive, delete, and a
|
|
232
|
+
* change of signed-in User.
|
|
233
|
+
*/
|
|
234
|
+
export interface TranscriptMemoryV1 {
|
|
235
|
+
/** Records where the reader had this Bot's thread. */
|
|
236
|
+
rememberViewport(
|
|
237
|
+
botId: string,
|
|
238
|
+
viewport: { scrollTop: number; pinnedToLatest: boolean },
|
|
239
|
+
): void;
|
|
240
|
+
/** Where the reader had this Bot's thread, if it is still held. */
|
|
241
|
+
viewportFor(
|
|
242
|
+
botId: string,
|
|
243
|
+
): { scrollTop: number; pinnedToLatest: boolean } | undefined;
|
|
244
|
+
/** Drops what is held for one Bot, or — with no argument — for every Bot. */
|
|
245
|
+
forget(botId?: string): void;
|
|
246
|
+
}
|
|
247
|
+
|
|
225
248
|
export interface FrockBotWebData {
|
|
226
249
|
connection: WebConnection;
|
|
227
250
|
modelLabel: string;
|
|
@@ -249,6 +272,14 @@ export interface FrockBotWebData {
|
|
|
249
272
|
runningRunId?: string;
|
|
250
273
|
activeRun?: WebActiveRun;
|
|
251
274
|
error?: string;
|
|
275
|
+
/**
|
|
276
|
+
* The Bot list could not be read, so "no Bot is open" means "we do not know
|
|
277
|
+
* yet" rather than "this account has none". Set by whichever Package owns
|
|
278
|
+
* the list. Without it a transport failure reads as data loss: the
|
|
279
|
+
* first-run empty state offering to add a first Bot to a User who has
|
|
280
|
+
* several is the worst thing this surface can say.
|
|
281
|
+
*/
|
|
282
|
+
botsUnavailable?: boolean;
|
|
252
283
|
botSettings?: BotSettingsViewV1;
|
|
253
284
|
userSettings?: UserSettingsViewV1;
|
|
254
285
|
pluginCatalog: PluginCatalogItem[];
|
|
@@ -328,6 +359,11 @@ export interface FrockBotWebData {
|
|
|
328
359
|
* cancelled or failed grant is reported rather than silently discarded.
|
|
329
360
|
*/
|
|
330
361
|
connectionReturn?: ConnectionReturnV1;
|
|
362
|
+
/**
|
|
363
|
+
* The conversations this client is still holding, so switching back to a
|
|
364
|
+
* Bot is a paint rather than a reload.
|
|
365
|
+
*/
|
|
366
|
+
transcripts: TranscriptMemoryV1;
|
|
331
367
|
selectBot(botId: string): Promise<void>;
|
|
332
368
|
loadBotSettings(): Promise<void>;
|
|
333
369
|
saveBotProfile(profile: BotProfile): Promise<void>;
|
package/tsconfig.json
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
"module": "ESNext",
|
|
5
5
|
"moduleResolution": "Bundler",
|
|
6
6
|
"allowImportingTsExtensions": true,
|
|
7
|
-
"baseUrl": ".",
|
|
8
7
|
"paths": {
|
|
9
|
-
"@cordisjs/client": ["src/client/cordis-client-shim.d.ts"]
|
|
8
|
+
"@cordisjs/client": ["./src/client/cordis-client-shim.d.ts"]
|
|
10
9
|
},
|
|
11
10
|
"strict": true,
|
|
12
11
|
"noEmit": true,
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
ACTIVITY_RING_MAX_LAPS_V1,
|
|
4
|
-
ACTIVITY_RING_SEGMENTS_V1,
|
|
5
|
-
activityRingV1,
|
|
6
|
-
type ActivityStepStatusV1,
|
|
7
|
-
} from "./activity-ring.js";
|
|
8
|
-
|
|
9
|
-
/** `count` settled steps, plus whatever is still in flight. */
|
|
10
|
-
function steps(settled: number, inFlight = 0): ActivityStepStatusV1[] {
|
|
11
|
-
return [
|
|
12
|
-
...Array.from<ActivityStepStatusV1>({ length: settled }).fill("completed"),
|
|
13
|
-
...Array.from<ActivityStepStatusV1>({ length: inFlight }).fill("running"),
|
|
14
|
-
];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe("the avatar's activity ring", () => {
|
|
18
|
-
test("a Turn with nothing done yet still shows a ring", () => {
|
|
19
|
-
// Liveness before progress: the ring is the answer to "is it paused?", so
|
|
20
|
-
// it is drawn from the first moment of the Turn, empty and pulsing.
|
|
21
|
-
const ring = activityRingV1({ toolStatuses: [], status: "streaming" });
|
|
22
|
-
expect(ring.active).toBe(true);
|
|
23
|
-
expect(ring.running).toBe(true);
|
|
24
|
-
expect(ring.filled).toBe(0);
|
|
25
|
-
expect(ring.progress).toBe(0);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test("a step that is still running has not ticked the ring", () => {
|
|
29
|
-
const ring = activityRingV1({
|
|
30
|
-
toolStatuses: steps(2, 1),
|
|
31
|
-
status: "streaming",
|
|
32
|
-
});
|
|
33
|
-
expect(ring.steps).toBe(2);
|
|
34
|
-
expect(ring.filled).toBe(2);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("a failed step ticks like any other — it is a step that happened", () => {
|
|
38
|
-
const ring = activityRingV1({
|
|
39
|
-
toolStatuses: ["completed", "failed"],
|
|
40
|
-
status: "streaming",
|
|
41
|
-
});
|
|
42
|
-
expect(ring.filled).toBe(2);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("each settled step fills one more segment of the lap", () => {
|
|
46
|
-
for (let settled = 0; settled < ACTIVITY_RING_SEGMENTS_V1; settled += 1) {
|
|
47
|
-
const ring = activityRingV1({
|
|
48
|
-
toolStatuses: steps(settled),
|
|
49
|
-
status: "streaming",
|
|
50
|
-
});
|
|
51
|
-
expect(ring.filled).toBe(settled);
|
|
52
|
-
expect(ring.progress).toBeCloseTo(settled / ACTIVITY_RING_SEGMENTS_V1);
|
|
53
|
-
expect(ring.laps).toBe(0);
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("a full lap leaves a faint ring behind and starts again", () => {
|
|
58
|
-
const ring = activityRingV1({
|
|
59
|
-
toolStatuses: steps(ACTIVITY_RING_SEGMENTS_V1 + 1),
|
|
60
|
-
status: "streaming",
|
|
61
|
-
});
|
|
62
|
-
expect(ring.laps).toBe(1);
|
|
63
|
-
expect(ring.filled).toBe(1);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("a Turn of forty tool calls never grows a fortieth ring", () => {
|
|
67
|
-
// The bound is the point: the ring reports that work is happening, not how
|
|
68
|
-
// much, so past the last lap it loops inside the strokes it already has.
|
|
69
|
-
const ring = activityRingV1({
|
|
70
|
-
toolStatuses: steps(40),
|
|
71
|
-
status: "streaming",
|
|
72
|
-
});
|
|
73
|
-
expect(ring.laps).toBe(ACTIVITY_RING_MAX_LAPS_V1);
|
|
74
|
-
expect(ring.filled).toBeLessThan(ACTIVITY_RING_SEGMENTS_V1);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("a settled Turn keeps no ring, however it ended", () => {
|
|
78
|
-
for (const status of [
|
|
79
|
-
"completed",
|
|
80
|
-
"failed",
|
|
81
|
-
"interrupted",
|
|
82
|
-
"reconciliation-required",
|
|
83
|
-
] as const) {
|
|
84
|
-
const ring = activityRingV1({ toolStatuses: steps(3), status });
|
|
85
|
-
expect(ring.active).toBe(false);
|
|
86
|
-
expect(ring.running).toBe(false);
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
});
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The activity ring: how much a running Turn has done, without saying so.
|
|
3
|
-
*
|
|
4
|
-
* A Turn can spend a minute making tool calls, and the thread used to answer
|
|
5
|
-
* that with either nothing or a list of tool names. Neither is right — the
|
|
6
|
-
* first reads as a paused app, the second puts the model's plumbing into a
|
|
7
|
-
* conversation. The ring is the third answer: a stroke around the Bot's avatar
|
|
8
|
-
* that pulses while the Turn runs and advances one segment for every step that
|
|
9
|
-
* settles, so a person sees liveness and rough progress and reads no words at
|
|
10
|
-
* all.
|
|
11
|
-
*
|
|
12
|
-
* This module is the whole rule, kept out of the component so it is testable
|
|
13
|
-
* without mounting Vue.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** Segments in one lap of the ring. A step fills one. */
|
|
17
|
-
export const ACTIVITY_RING_SEGMENTS_V1 = 8;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* How many laps the ring will draw at once. A Turn making forty tool calls
|
|
21
|
-
* must not grow a fortieth ring, so the lap beyond the first is the last one:
|
|
22
|
-
* past it the ring keeps looping inside those two strokes.
|
|
23
|
-
*/
|
|
24
|
-
export const ACTIVITY_RING_MAX_LAPS_V1 = 1;
|
|
25
|
-
|
|
26
|
-
/** A step's state, as the Turn's tool activity reports it. */
|
|
27
|
-
export type ActivityStepStatusV1 = "running" | "completed" | "failed";
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* A Turn's state, as the thread's message carries it.
|
|
31
|
-
*
|
|
32
|
-
* Deliberately the whole string rather than the union: only `streaming` means
|
|
33
|
-
* the Turn is still going, and every other value — including one a newer Bot
|
|
34
|
-
* invents — is an ending. The ring is drawn off that one positive test, so it
|
|
35
|
-
* cannot be left spinning by a status this file has never heard of.
|
|
36
|
-
*/
|
|
37
|
-
export type ActivityTurnStatusV1 = string;
|
|
38
|
-
|
|
39
|
-
export interface ActivityRingViewV1 {
|
|
40
|
-
/** Whether the ring is drawn at all. A settled Turn keeps none. */
|
|
41
|
-
active: boolean;
|
|
42
|
-
/** Whether the ring pulses. False once the Turn has settled. */
|
|
43
|
-
running: boolean;
|
|
44
|
-
/** Steps that have settled in this Turn. Unbounded, and never rendered. */
|
|
45
|
-
steps: number;
|
|
46
|
-
/** Filled segments of the current lap, `0 … ACTIVITY_RING_SEGMENTS_V1`. */
|
|
47
|
-
filled: number;
|
|
48
|
-
/** Faint laps completed behind the live stroke, capped. */
|
|
49
|
-
laps: number;
|
|
50
|
-
/** The fraction of the circumference the live stroke draws, `0 … 1`. */
|
|
51
|
-
progress: number;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* The ring for one assistant line.
|
|
56
|
-
*
|
|
57
|
-
* A running Turn ticks: every settled tool call advances the stroke one
|
|
58
|
-
* segment, and the lap that fills leaves a faint ring behind it — up to
|
|
59
|
-
* {@link ACTIVITY_RING_MAX_LAPS_V1}, after which the ring simply loops. A Turn
|
|
60
|
-
* that has settled draws no ring; the reply is the answer by then, and the
|
|
61
|
-
* component fades the ring out on its way off screen.
|
|
62
|
-
*/
|
|
63
|
-
export function activityRingV1(input: {
|
|
64
|
-
toolStatuses: readonly ActivityStepStatusV1[];
|
|
65
|
-
status: ActivityTurnStatusV1;
|
|
66
|
-
}): ActivityRingViewV1 {
|
|
67
|
-
const running = input.status === "streaming";
|
|
68
|
-
const steps = input.toolStatuses.filter(
|
|
69
|
-
(status) => status !== "running",
|
|
70
|
-
).length;
|
|
71
|
-
if (!running) {
|
|
72
|
-
return {
|
|
73
|
-
active: false,
|
|
74
|
-
running: false,
|
|
75
|
-
steps,
|
|
76
|
-
filled: 0,
|
|
77
|
-
laps: 0,
|
|
78
|
-
progress: 0,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
const filled = steps % ACTIVITY_RING_SEGMENTS_V1;
|
|
82
|
-
const laps = Math.min(
|
|
83
|
-
Math.floor(steps / ACTIVITY_RING_SEGMENTS_V1),
|
|
84
|
-
ACTIVITY_RING_MAX_LAPS_V1,
|
|
85
|
-
);
|
|
86
|
-
return {
|
|
87
|
-
active: true,
|
|
88
|
-
running: true,
|
|
89
|
-
steps,
|
|
90
|
-
filled,
|
|
91
|
-
laps,
|
|
92
|
-
progress: filled / ACTIVITY_RING_SEGMENTS_V1,
|
|
93
|
-
};
|
|
94
|
-
}
|