@frockbot/plugin-shell 0.3.6 → 0.3.8

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.
@@ -13,6 +13,7 @@ import {
13
13
  optionalUnreadStateV1,
14
14
  projectBotUnreadViewV1,
15
15
  sidebarMessagePreviewForTurnV1,
16
+ sidebarMessagePreviewFromRunsV1,
16
17
  UNREAD_COUNT_CAP,
17
18
  type UnreadStateV1,
18
19
  } from "./unread.js";
@@ -263,6 +264,73 @@ describe("the unread projection", () => {
263
264
  });
264
265
  });
265
266
 
267
+ // The record is written at settlement, so a Bot whose Turns settled before
268
+ // that projection existed has a full transcript and no preview — and its
269
+ // sidebar row said "No messages yet" over six messages. A read derives it.
270
+ describe("the sidebar preview derived from stored runs", () => {
271
+ const run = (over: Record<string, unknown> = {}) => ({
272
+ acceptedAt: "2026-08-31T00:02:00.000Z",
273
+ input: "What is the plan?",
274
+ responseText: "Here is the plan.",
275
+ status: "completed",
276
+ events: [{ timestamp: "2026-08-31T00:02:05.000Z" }],
277
+ ...over,
278
+ });
279
+
280
+ test("takes the newest settled chat Turn's reply, stamped when it settled", () => {
281
+ expect(sidebarMessagePreviewFromRunsV1([run()])).toEqual({
282
+ schemaVersion: 1,
283
+ text: "Here is the plan.",
284
+ at: "2026-08-31T00:02:05.000Z",
285
+ role: "assistant",
286
+ });
287
+ });
288
+
289
+ test("walks past a running Turn and an automation to the newest chat reply", () => {
290
+ expect(
291
+ sidebarMessagePreviewFromRunsV1([
292
+ run({ status: "running", responseText: undefined }),
293
+ run({
294
+ admission: { turnType: "automation" },
295
+ responseText: "Routine ran.",
296
+ }),
297
+ run({ responseText: "The older answer." }),
298
+ ]),
299
+ ).toMatchObject({ text: "The older answer.", role: "assistant" });
300
+ });
301
+
302
+ test("falls back to the User's own words when the reply was empty", () => {
303
+ expect(
304
+ sidebarMessagePreviewFromRunsV1([run({ responseText: "" })]),
305
+ ).toEqual({
306
+ schemaVersion: 1,
307
+ text: "What is the plan?",
308
+ at: "2026-08-31T00:02:00.000Z",
309
+ role: "user",
310
+ });
311
+ });
312
+
313
+ test("a Bot with no settled chat Turn still has no preview", () => {
314
+ expect(sidebarMessagePreviewFromRunsV1([])).toBeUndefined();
315
+ expect(
316
+ sidebarMessagePreviewFromRunsV1([
317
+ run({ status: "running", responseText: undefined, input: "" }),
318
+ ]),
319
+ ).toBeUndefined();
320
+ });
321
+
322
+ // A read of durable data never throws: a run whose stamps are unreadable
323
+ // costs the row, not the whole sidebar.
324
+ test("skips a run whose stored timestamps cannot be read", () => {
325
+ expect(
326
+ sidebarMessagePreviewFromRunsV1([
327
+ run({ acceptedAt: "not a time", events: [] }),
328
+ run({ responseText: "The readable one." }),
329
+ ]),
330
+ ).toMatchObject({ text: "The readable one." });
331
+ });
332
+ });
333
+
266
334
  describe("the unread command", () => {
267
335
  test("decodes each type and refuses a mismatched cursor", () => {
268
336
  expect(
package/src/unread.ts CHANGED
@@ -260,6 +260,59 @@ export function sidebarMessagePreviewForTurnV1(
260
260
  });
261
261
  }
262
262
 
263
+ /** The little a settled run has to expose for a preview to be derived from it. */
264
+ export interface SidebarPreviewRunV1 {
265
+ acceptedAt: string;
266
+ input: string;
267
+ responseText?: string;
268
+ status: string;
269
+ /** Only the timestamps are read: the newest one is when the Turn settled. */
270
+ events?: readonly { timestamp?: string }[];
271
+ /** Absent means the Turn was admitted as `chat`, as everywhere else. */
272
+ admission?: { turnType?: string };
273
+ }
274
+
275
+ /**
276
+ * The preview for a Bot that has a transcript but no preview record.
277
+ *
278
+ * The record is written at settlement, so every Turn that settled before that
279
+ * projection existed left one behind — and the row then claimed "No messages
280
+ * yet" over a full conversation. A read cannot write the record it is missing,
281
+ * so it derives the same line from the runs that are already durable. Runs
282
+ * arrive newest-first and the walk stops at the first settled chat Turn with
283
+ * text, which is exactly the line the settlement would have stored.
284
+ */
285
+ export function sidebarMessagePreviewFromRunsV1(
286
+ runs: readonly SidebarPreviewRunV1[],
287
+ ): SidebarMessagePreviewV1 | undefined {
288
+ for (const run of runs) {
289
+ if (run.status === "running") continue;
290
+ // An automation Turn reaches the User through its own inbox entry and
291
+ // never became this row at settlement either.
292
+ if ((run.admission?.turnType ?? "chat") !== "chat") continue;
293
+ const settledAt = settlementTimestampV1(run);
294
+ try {
295
+ const preview = sidebarMessagePreviewForTurnV1(run, settledAt);
296
+ if (preview) return preview;
297
+ } catch {
298
+ // A run whose stored timestamps cannot be read is not worth the row —
299
+ // and a read of durable data never throws over one bad record.
300
+ continue;
301
+ }
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ /** When a stored run settled: its newest event's stamp, else its admission. */
307
+ function settlementTimestampV1(run: SidebarPreviewRunV1): string {
308
+ for (let index = (run.events?.length ?? 0) - 1; index >= 0; index -= 1) {
309
+ const timestamp = run.events?.[index]?.timestamp;
310
+ if (typeof timestamp === "string" && Number.isFinite(Date.parse(timestamp)))
311
+ return timestamp;
312
+ }
313
+ return run.acceptedAt;
314
+ }
315
+
263
316
  /**
264
317
  * Records a settled chat Turn. Monotonic: a cursor that is not newer than the
265
318
  * one already recorded leaves the record byte-for-byte unchanged, so a