@frockbot/plugin-shell 0.3.8 → 0.3.10
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 +32 -31
- package/src/backend-configuration.test.ts +3 -1
- package/src/backend-package-catalog.ts +59 -31
- package/src/backend-routines.ts +4 -0
- package/src/backend-subagent-blocking.test.ts +222 -0
- package/src/backend.ts +144 -23
- package/src/client/index.test.ts +73 -0
- package/src/client/index.ts +8 -1
- package/src/client/model-presentation.test.ts +14 -0
- package/src/client/model-presentation.ts +6 -0
- package/src/composition-manifest.test.ts +126 -0
- package/src/composition-manifest.ts +55 -0
- package/src/notification-id.test.ts +67 -0
- package/src/notification-id.ts +58 -0
- package/src/run-protocol.test.ts +81 -0
- package/src/run-protocol.ts +49 -3
- package/src/unread.test.ts +27 -0
- package/src/unread.ts +9 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isPublicIdentifier } from "@frockbot/configuration-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The acknowledge decoder's ceiling: `isPublicIdentifier` admits a leading
|
|
5
|
+
* alphanumeric plus at most 127 more characters.
|
|
6
|
+
*/
|
|
7
|
+
const MAX_NOTIFICATION_ID_LENGTH = 128;
|
|
8
|
+
|
|
9
|
+
/** How much of a folded id the digest suffix costs: `-` plus eight hex. */
|
|
10
|
+
const DIGEST_SUFFIX_LENGTH = 9;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A notification is only useful if it can be acknowledged, and acknowledgement
|
|
14
|
+
* decodes the id through `isPublicIdentifier`
|
|
15
|
+
* (`/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/`) — which admits no colons. Delivery
|
|
16
|
+
* only bounds the length, so an id minted by hand out of a generation id
|
|
17
|
+
* (`2026-09-03T23:49:00.416Z:dc03a32d9b717619`) or a package request went out,
|
|
18
|
+
* came back, and was refused with `400` on every poll for the life of the Bot.
|
|
19
|
+
*
|
|
20
|
+
* Every notification id is minted here instead, so the two sides cannot drift:
|
|
21
|
+
* the parts are joined with `-`, anything outside the admitted alphabet
|
|
22
|
+
* becomes `-`, and an over-long id keeps its readable head and folds the rest
|
|
23
|
+
* into a digest so distinct inputs stay distinct ids. Ids are stable for the
|
|
24
|
+
* same parts, which is what makes one firing one intent however many times a
|
|
25
|
+
* retry re-mints it.
|
|
26
|
+
*/
|
|
27
|
+
export function notificationIdV1(
|
|
28
|
+
...parts: readonly (string | number)[]
|
|
29
|
+
): string {
|
|
30
|
+
const raw = parts.map((part) => String(part)).join("-");
|
|
31
|
+
const sanitized = raw
|
|
32
|
+
.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
33
|
+
.replace(/^[^a-zA-Z0-9]+/, "");
|
|
34
|
+
const seeded =
|
|
35
|
+
sanitized.length === 0 ? `notification-${digest(raw)}` : sanitized;
|
|
36
|
+
const bounded =
|
|
37
|
+
seeded.length <= MAX_NOTIFICATION_ID_LENGTH
|
|
38
|
+
? seeded
|
|
39
|
+
: `${seeded.slice(0, MAX_NOTIFICATION_ID_LENGTH - DIGEST_SUFFIX_LENGTH)}-${digest(raw)}`;
|
|
40
|
+
if (!isPublicIdentifier(bounded)) {
|
|
41
|
+
// Unreachable by construction; a mint that cannot be acknowledged is a
|
|
42
|
+
// bug, never something to ship to a client that will 400 on it forever.
|
|
43
|
+
throw new Error(
|
|
44
|
+
`minted notification id "${bounded}" is not acknowledgeable`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return bounded;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** FNV-1a, eight hex digits — an id disambiguator, not a security claim. */
|
|
51
|
+
function digest(value: string): string {
|
|
52
|
+
let hash = 0x811c9dc5;
|
|
53
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
54
|
+
hash ^= value.charCodeAt(index);
|
|
55
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
56
|
+
}
|
|
57
|
+
return hash.toString(16).padStart(8, "0");
|
|
58
|
+
}
|
package/src/run-protocol.test.ts
CHANGED
|
@@ -1476,6 +1476,87 @@ describe("dispatched subagents in the run projection", () => {
|
|
|
1476
1476
|
}
|
|
1477
1477
|
});
|
|
1478
1478
|
|
|
1479
|
+
test("a running Turn projects the words it has written so far", () => {
|
|
1480
|
+
const streamed: SessionEvent[] = [
|
|
1481
|
+
event({
|
|
1482
|
+
type: "assistant/chunk",
|
|
1483
|
+
seq: 0,
|
|
1484
|
+
timestamp,
|
|
1485
|
+
turn: 1,
|
|
1486
|
+
step: 1,
|
|
1487
|
+
requestId: "request-1",
|
|
1488
|
+
text: "Half a",
|
|
1489
|
+
}),
|
|
1490
|
+
event({
|
|
1491
|
+
type: "assistant/chunk",
|
|
1492
|
+
seq: 1,
|
|
1493
|
+
timestamp,
|
|
1494
|
+
turn: 1,
|
|
1495
|
+
step: 1,
|
|
1496
|
+
requestId: "request-1",
|
|
1497
|
+
text: " thought",
|
|
1498
|
+
}),
|
|
1499
|
+
];
|
|
1500
|
+
|
|
1501
|
+
const projected = projectClientRunV1(storedRun(streamed, "running"));
|
|
1502
|
+
expect(projected.partialText).toBe("Half a thought");
|
|
1503
|
+
expect(projected.outcome).toBeUndefined();
|
|
1504
|
+
|
|
1505
|
+
// And it survives the wire, so the thread draws it while the Turn runs.
|
|
1506
|
+
const decoded = decodeClientRunPageV1(
|
|
1507
|
+
createClientRunListV1([projected], { truncated: false }),
|
|
1508
|
+
).runs[0];
|
|
1509
|
+
expect(decoded?.partialText).toBe("Half a thought");
|
|
1510
|
+
expect(decoded?.responseText).toBeUndefined();
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
test("a running Turn that has said nothing carries no partial text", () => {
|
|
1514
|
+
expect(projectClientRunV1(storedRun([], "running")).partialText).toBe(
|
|
1515
|
+
undefined,
|
|
1516
|
+
);
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
test("a later request restarts the partial answer", () => {
|
|
1520
|
+
const streamed: SessionEvent[] = [
|
|
1521
|
+
event({
|
|
1522
|
+
type: "assistant/chunk",
|
|
1523
|
+
seq: 0,
|
|
1524
|
+
timestamp,
|
|
1525
|
+
turn: 1,
|
|
1526
|
+
step: 1,
|
|
1527
|
+
requestId: "request-1",
|
|
1528
|
+
text: "scratch",
|
|
1529
|
+
}),
|
|
1530
|
+
event({
|
|
1531
|
+
type: "assistant/chunk",
|
|
1532
|
+
seq: 1,
|
|
1533
|
+
timestamp,
|
|
1534
|
+
turn: 1,
|
|
1535
|
+
step: 2,
|
|
1536
|
+
requestId: "request-2",
|
|
1537
|
+
text: "the answer",
|
|
1538
|
+
}),
|
|
1539
|
+
];
|
|
1540
|
+
expect(projectClientRunV1(storedRun(streamed, "running")).partialText).toBe(
|
|
1541
|
+
"the answer",
|
|
1542
|
+
);
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
test("a settled Turn carries its answer once, as an outcome", () => {
|
|
1546
|
+
const projected = projectClientRunV1(storedRun([], "completed"));
|
|
1547
|
+
expect(projected.partialText).toBeUndefined();
|
|
1548
|
+
expect(projected.outcome).toMatchObject({ type: "completed" });
|
|
1549
|
+
|
|
1550
|
+
const page = createClientRunListV1([projected], { truncated: false });
|
|
1551
|
+
const tampered = structuredClone(page) as unknown as {
|
|
1552
|
+
runs: Array<Record<string, unknown>>;
|
|
1553
|
+
};
|
|
1554
|
+
tampered.runs[0]!.partialText = "words";
|
|
1555
|
+
expect(() => decodeClientRunPageV1(tampered)).toThrow(
|
|
1556
|
+
"only a running run may carry partial text",
|
|
1557
|
+
);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1479
1560
|
test("refuses a chip whose background flag is not a boolean", () => {
|
|
1480
1561
|
const page = createClientRunListV1(
|
|
1481
1562
|
[projectClientRunV1(storedRun([dispatched]))],
|
package/src/run-protocol.ts
CHANGED
|
@@ -220,6 +220,14 @@ export interface ClientRunV1 {
|
|
|
220
220
|
* the flag is durable state, so a reload draws the same thing.
|
|
221
221
|
*/
|
|
222
222
|
queued?: true;
|
|
223
|
+
/**
|
|
224
|
+
* The answer the Bot has written so far, present only while the run is still
|
|
225
|
+
* running and has produced text. The thread draws it in the bubble it is
|
|
226
|
+
* already drawing for the Turn, so a reply appears as it is written instead
|
|
227
|
+
* of arriving whole at settlement. A settled run carries its answer in
|
|
228
|
+
* `outcome` instead, and never both.
|
|
229
|
+
*/
|
|
230
|
+
partialText?: string;
|
|
223
231
|
outcome?: ClientRunOutcomeV1;
|
|
224
232
|
recovery?: ClientRunRecoveryV1;
|
|
225
233
|
}
|
|
@@ -738,10 +746,13 @@ function visibleEvents(
|
|
|
738
746
|
* fact about what the person watched arrive, not a claim that the Turn
|
|
739
747
|
* succeeded, and the thread keeps it instead of replacing it with a notice.
|
|
740
748
|
*/
|
|
741
|
-
function
|
|
749
|
+
export function assistantTextSoFarV1(
|
|
750
|
+
events: readonly SessionEvent[],
|
|
751
|
+
responseText = "",
|
|
752
|
+
): string {
|
|
742
753
|
let requestId: string | undefined;
|
|
743
|
-
let text =
|
|
744
|
-
for (const event of
|
|
754
|
+
let text = responseText;
|
|
755
|
+
for (const event of events) {
|
|
745
756
|
if (event.type === "assistant/chunk") {
|
|
746
757
|
if (event.requestId !== requestId) {
|
|
747
758
|
requestId = event.requestId;
|
|
@@ -753,9 +764,31 @@ function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
|
|
|
753
764
|
text = event.text;
|
|
754
765
|
}
|
|
755
766
|
}
|
|
767
|
+
return text;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
|
|
771
|
+
const text = assistantTextSoFarV1(run.events, run.responseText ?? "");
|
|
756
772
|
return text ? { text: truncateWireString(text, MAX_OUTCOME_BYTES) } : {};
|
|
757
773
|
}
|
|
758
774
|
|
|
775
|
+
/**
|
|
776
|
+
* What a still-running Turn has said so far, read out of the same journal an
|
|
777
|
+
* interrupted one is read from.
|
|
778
|
+
*
|
|
779
|
+
* The kernel appends an `assistant/chunk` per provider text delta and each
|
|
780
|
+
* append lands on the run record, so the words are already durable while the
|
|
781
|
+
* Turn runs; nothing here is a second copy and nothing crosses the channel.
|
|
782
|
+
* Bounded exactly as an outcome is, because a long answer must not be able to
|
|
783
|
+
* grow the run list past its wire budget.
|
|
784
|
+
*/
|
|
785
|
+
function partialTextV1(run: StoredRun): { partialText?: string } {
|
|
786
|
+
const text = assistantTextSoFarV1(run.events);
|
|
787
|
+
return text
|
|
788
|
+
? { partialText: truncateWireString(text, MAX_OUTCOME_BYTES) }
|
|
789
|
+
: {};
|
|
790
|
+
}
|
|
791
|
+
|
|
759
792
|
function runStatus(run: StoredRun): ClientRunStatusV1 {
|
|
760
793
|
return requireStoredRunV1(run).status;
|
|
761
794
|
}
|
|
@@ -810,6 +843,7 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
|
|
|
810
843
|
input: truncateWireString(run.input, MAX_INPUT_BYTES),
|
|
811
844
|
status,
|
|
812
845
|
events: visibleEvents(run.events, status),
|
|
846
|
+
...(status === "running" ? partialTextV1(run) : {}),
|
|
813
847
|
...(run.stopRequestedAt
|
|
814
848
|
? {
|
|
815
849
|
stopRequestedAt: truncate(run.stopRequestedAt, MAX_TIMESTAMP_LENGTH),
|
|
@@ -1300,6 +1334,7 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
1300
1334
|
"events",
|
|
1301
1335
|
"stopRequestedAt",
|
|
1302
1336
|
"queued",
|
|
1337
|
+
"partialText",
|
|
1303
1338
|
"outcome",
|
|
1304
1339
|
"recovery",
|
|
1305
1340
|
],
|
|
@@ -1347,6 +1382,16 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
1347
1382
|
if (run.queued === true && runStatus !== "running") {
|
|
1348
1383
|
throw new Error("only a running run may be queued");
|
|
1349
1384
|
}
|
|
1385
|
+
let partialText: string | undefined;
|
|
1386
|
+
if (run.partialText !== undefined) {
|
|
1387
|
+
// A settled run's answer is its outcome. Carrying both would give the
|
|
1388
|
+
// thread two sources for one bubble, which is the duplication the
|
|
1389
|
+
// one-bubble contract exists to prevent.
|
|
1390
|
+
if (runStatus !== "running") {
|
|
1391
|
+
throw new Error("only a running run may carry partial text");
|
|
1392
|
+
}
|
|
1393
|
+
partialText = wireString(run, "partialText", MAX_OUTCOME_BYTES, "run");
|
|
1394
|
+
}
|
|
1350
1395
|
return {
|
|
1351
1396
|
runId,
|
|
1352
1397
|
admittedAt,
|
|
@@ -1355,6 +1400,7 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
1355
1400
|
events: decodeEvents(run.events),
|
|
1356
1401
|
...(stopRequestedAt ? { stopRequestedAt } : {}),
|
|
1357
1402
|
...(run.queued === true ? { queued: true as const } : {}),
|
|
1403
|
+
...(partialText ? { partialText } : {}),
|
|
1358
1404
|
...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
|
|
1359
1405
|
...(outcome?.type === "failed"
|
|
1360
1406
|
? {
|
package/src/unread.test.ts
CHANGED
|
@@ -236,6 +236,33 @@ describe("the unread projection", () => {
|
|
|
236
236
|
expect(view).toMatchObject({ count: 0, capped: false, unread: false });
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
+
// A Routine failing every minute left the badge at zero, because an
|
|
240
|
+
// automation Turn never advances the activity cursor.
|
|
241
|
+
test("badges a Bot whose Routine is failing, with nothing else unread", () => {
|
|
242
|
+
const view = projectBotUnreadViewV1(
|
|
243
|
+
"alpha",
|
|
244
|
+
emptyUnreadStateV1(),
|
|
245
|
+
index(3),
|
|
246
|
+
undefined,
|
|
247
|
+
2,
|
|
248
|
+
);
|
|
249
|
+
expect(view).toMatchObject({ count: 2, unread: true, capped: false });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("adds Routine failures to the unread chat Turns", () => {
|
|
253
|
+
const state: UnreadStateV1 = {
|
|
254
|
+
schemaVersion: 1,
|
|
255
|
+
lastActivityCursor: cursor(3),
|
|
256
|
+
lastActivityAt: "2026-08-31T00:03:00.000Z",
|
|
257
|
+
lastSeenCursor: cursor(1),
|
|
258
|
+
lastViewedAt: "2026-08-31T00:01:00.000Z",
|
|
259
|
+
manuallyUnread: false,
|
|
260
|
+
};
|
|
261
|
+
expect(
|
|
262
|
+
projectBotUnreadViewV1("alpha", state, index(4), undefined, 1),
|
|
263
|
+
).toMatchObject({ count: 3, unread: true });
|
|
264
|
+
});
|
|
265
|
+
|
|
239
266
|
test("carries the already-bounded latest message without deriving it", () => {
|
|
240
267
|
const preview = decodeSidebarMessagePreviewV1({
|
|
241
268
|
schemaVersion: 1,
|
package/src/unread.ts
CHANGED
|
@@ -406,9 +406,17 @@ export function projectBotUnreadViewV1(
|
|
|
406
406
|
state: UnreadStateV1,
|
|
407
407
|
cursors: readonly string[],
|
|
408
408
|
lastMessage?: SidebarMessagePreviewV1,
|
|
409
|
+
/**
|
|
410
|
+
* Unacknowledged Routine failures. An automation Turn deliberately does not
|
|
411
|
+
* advance the activity cursor, so a Routine failing every minute badged
|
|
412
|
+
* nothing at all — the one Bot the User most needed to look at was the one
|
|
413
|
+
* the sidebar stayed quiet about. A failure is the Bot addressing its User,
|
|
414
|
+
* so it counts here even though the firing that produced it does not.
|
|
415
|
+
*/
|
|
416
|
+
automationFailures = 0,
|
|
409
417
|
): BotUnreadViewV1 {
|
|
410
418
|
const ceiling = state.lastActivityCursor;
|
|
411
|
-
let counted = 0;
|
|
419
|
+
let counted = Math.max(0, automationFailures);
|
|
412
420
|
if (ceiling !== undefined) {
|
|
413
421
|
for (const cursor of cursors) {
|
|
414
422
|
if (cursor > ceiling) continue;
|