@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.
@@ -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
+ }
@@ -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]))],
@@ -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 interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
749
+ export function assistantTextSoFarV1(
750
+ events: readonly SessionEvent[],
751
+ responseText = "",
752
+ ): string {
742
753
  let requestId: string | undefined;
743
- let text = run.responseText ?? "";
744
- for (const event of run.events) {
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
  ? {
@@ -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;