@frockbot/plugin-shell 0.3.7 → 0.3.9

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.
@@ -3,6 +3,7 @@ import {
3
3
  BOT_DEBUG_RUN_LIMIT_V1,
4
4
  boundDebugEventsV1,
5
5
  decodeBotDebugQueryV1,
6
+ isBotDebugQueryRefusalV1,
6
7
  } from "./debug-protocol.js";
7
8
 
8
9
  describe("debug query", () => {
@@ -36,13 +37,48 @@ describe("debug query", () => {
36
37
  ).toThrow("debug query has invalid fields");
37
38
  });
38
39
 
39
- test("rejects a limit past the page bound", () => {
40
+ test("rejects a limit past the page bound, in words that name the range", () => {
40
41
  expect(() =>
41
42
  decodeBotDebugQueryV1({
42
43
  schemaVersion: 1,
43
44
  limit: BOT_DEBUG_RUN_LIMIT_V1 + 1,
44
45
  }),
45
- ).toThrow("debug query limit is invalid");
46
+ ).toThrow(
47
+ `debug query limit must be a whole number from 1 to ${BOT_DEBUG_RUN_LIMIT_V1}`,
48
+ );
49
+ });
50
+
51
+ // The refusal rides on the error's name, which is all a Durable Object RPC
52
+ // preserves: a bad query has to arrive at the gateway as a 400 and not as
53
+ // an uncaught failure in the Bot's isolate.
54
+ test("refuses a bad query as a refusal, not as an ordinary failure", () => {
55
+ for (const input of [
56
+ { schemaVersion: 1, limit: 0 },
57
+ { schemaVersion: 1, limit: BOT_DEBUG_RUN_LIMIT_V1 + 1 },
58
+ { schemaVersion: 1, limit: 1.5 },
59
+ { schemaVersion: 1, limit: Number.NaN },
60
+ { schemaVersion: 1, sql: "select 1" },
61
+ { schemaVersion: 2 },
62
+ "not a query",
63
+ ]) {
64
+ let refusal: unknown;
65
+ try {
66
+ decodeBotDebugQueryV1(input);
67
+ } catch (error) {
68
+ refusal = error;
69
+ }
70
+ expect(isBotDebugQueryRefusalV1(refusal)).toBe(true);
71
+ }
72
+ });
73
+
74
+ test("accepts both ends of the allowed range", () => {
75
+ expect(decodeBotDebugQueryV1({ schemaVersion: 1, limit: 1 }).limit).toBe(1);
76
+ expect(
77
+ decodeBotDebugQueryV1({
78
+ schemaVersion: 1,
79
+ limit: BOT_DEBUG_RUN_LIMIT_V1,
80
+ }).limit,
81
+ ).toBe(BOT_DEBUG_RUN_LIMIT_V1);
46
82
  });
47
83
 
48
84
  test("rejects a wrong schema version", () => {
@@ -90,21 +90,50 @@ export interface BotDebugSnapshotV1 {
90
90
  nextCursor?: string;
91
91
  }
92
92
 
93
+ /**
94
+ * A debug query the caller got wrong: an unknown field, a `limit` past the cap.
95
+ * The request is what is bad, not the Bot, so the surface owes a 400 rather
96
+ * than an uncaught failure in the isolate. The name is what carries that
97
+ * across the Durable Object RPC boundary — which keeps an error's `name` and
98
+ * `message` and drops everything else — exactly as `BotTurnRefusedError` does
99
+ * for a refused admission.
100
+ */
101
+ export class BotDebugQueryRefusedErrorV1 extends Error {
102
+ constructor(message: string) {
103
+ super(message);
104
+ this.name = "BotDebugQueryRefusedErrorV1";
105
+ }
106
+ }
107
+
108
+ /** Whether an error — including one that has crossed RPC — is that refusal. */
109
+ export function isBotDebugQueryRefusalV1(error: unknown): boolean {
110
+ return (
111
+ typeof error === "object" &&
112
+ error !== null &&
113
+ "name" in error &&
114
+ String((error as { name: unknown }).name) === "BotDebugQueryRefusedErrorV1"
115
+ );
116
+ }
117
+
93
118
  function isRecord(value: unknown): value is Record<string, unknown> {
94
119
  return typeof value === "object" && value !== null && !Array.isArray(value);
95
120
  }
96
121
 
97
122
  function boundedString(value: unknown, maximum: number, field: string): string {
98
123
  if (typeof value !== "string" || value.length < 1 || value.length > maximum) {
99
- throw new Error(`debug query ${field} is invalid`);
124
+ throw new BotDebugQueryRefusedErrorV1(`debug query ${field} is invalid`);
100
125
  }
101
126
  return value;
102
127
  }
103
128
 
104
129
  export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
105
- if (!isRecord(input)) throw new Error("debug query is invalid");
130
+ if (!isRecord(input)) {
131
+ throw new BotDebugQueryRefusedErrorV1("debug query is invalid");
132
+ }
106
133
  if (input.schemaVersion !== 1) {
107
- throw new Error("debug query schemaVersion is invalid");
134
+ throw new BotDebugQueryRefusedErrorV1(
135
+ "debug query schemaVersion is invalid",
136
+ );
108
137
  }
109
138
  const allowed = new Set([
110
139
  "schemaVersion",
@@ -114,7 +143,7 @@ export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
114
143
  "events",
115
144
  ]);
116
145
  if (!Object.keys(input).every((key) => allowed.has(key))) {
117
- throw new Error("debug query has invalid fields");
146
+ throw new BotDebugQueryRefusedErrorV1("debug query has invalid fields");
118
147
  }
119
148
  const query: BotDebugQueryV1 = { schemaVersion: 1 };
120
149
  if (input.runId !== undefined) {
@@ -129,13 +158,17 @@ export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
129
158
  (input.limit as number) < 1 ||
130
159
  (input.limit as number) > BOT_DEBUG_RUN_LIMIT_V1
131
160
  ) {
132
- throw new Error("debug query limit is invalid");
161
+ throw new BotDebugQueryRefusedErrorV1(
162
+ `debug query limit must be a whole number from 1 to ${BOT_DEBUG_RUN_LIMIT_V1}`,
163
+ );
133
164
  }
134
165
  query.limit = input.limit as number;
135
166
  }
136
167
  if (input.events !== undefined) {
137
168
  if (typeof input.events !== "boolean") {
138
- throw new Error("debug query events is invalid");
169
+ throw new BotDebugQueryRefusedErrorV1(
170
+ "debug query events must be true or false",
171
+ );
139
172
  }
140
173
  query.events = input.events;
141
174
  }
@@ -24,6 +24,7 @@ import {
24
24
  projectClientRunV1,
25
25
  projectClientRunOrDegradedV1,
26
26
  projectClientTurnV1,
27
+ UNRECORDED_TOOL_RESULT_TEXT_V1,
27
28
  } from "./run-protocol.js";
28
29
 
29
30
  const timestamp = "2026-08-29T00:00:00.000Z";
@@ -1061,14 +1062,59 @@ describe("client run protocol v1", () => {
1061
1062
  expect(() => projectClientRunListV1([storedRun([result])])).toThrow(
1062
1063
  'tool result has no matching occurrence "tool:1:1:0"',
1063
1064
  );
1064
- expect(() => projectClientRunListV1([storedRun([call])])).toThrow(
1065
- 'terminal run has no result for tool call "tool-1"',
1066
- );
1067
1065
  expect(() =>
1068
1066
  projectClientRunListV1([storedRun([call, call, result])]),
1069
1067
  ).toThrow('tool occurrence "tool:1:1:0" has duplicate intent');
1070
1068
  });
1071
1069
 
1070
+ // A READ never throws on a record that is already durable. A settled Turn
1071
+ // whose tool call was never settled used to fail the whole transcript
1072
+ // endpoint — a 500 on every later request — so one malformed row bricked the
1073
+ // conversation for ever. It degrades to a row saying nothing was recorded.
1074
+ test("degrades a settled Turn's unsettled tool call instead of throwing", () => {
1075
+ const call = toolEvents(1)[0]!;
1076
+
1077
+ const projected = projectClientRunListV1([storedRun([call])]).runs[0];
1078
+
1079
+ expect(projected?.events).toEqual([
1080
+ { type: "tool/call", call: { id: "tool-1", name: "lookup" } },
1081
+ {
1082
+ type: "tool/result",
1083
+ callId: "tool-1",
1084
+ content: UNRECORDED_TOOL_RESULT_TEXT_V1,
1085
+ isError: true,
1086
+ },
1087
+ ]);
1088
+ // And the degraded row survives the wire decode, which used to refuse it
1089
+ // for the same reason the projection did.
1090
+ expect(
1091
+ decodeClientRunListV1({
1092
+ schemaVersion: 1,
1093
+ runs: [projected],
1094
+ page: { truncated: false },
1095
+ })[0]?.events,
1096
+ ).toHaveLength(2);
1097
+ });
1098
+
1099
+ test("accepts a settled Turn on the wire whose call carries no result", () => {
1100
+ const projected = projectClientRunListV1([storedRun([])]).runs[0]!;
1101
+
1102
+ expect(
1103
+ decodeClientRunListV1({
1104
+ schemaVersion: 1,
1105
+ runs: [
1106
+ {
1107
+ ...projected,
1108
+ events: [
1109
+ { type: "tool/call", call: { id: "tool-1", name: "lookup" } },
1110
+ ],
1111
+ },
1112
+ ],
1113
+ page: { truncated: false },
1114
+ })[0]?.events,
1115
+ ).toEqual([{ type: "tool/call", call: { id: "tool-1", name: "lookup" } }]);
1116
+ });
1117
+
1072
1118
  test("retains pending calls only for nonterminal runs", () => {
1073
1119
  const call = toolEvents(1)[0]!;
1074
1120
  const projected = projectClientRunListV1([storedRun([call], "running")])
@@ -1430,6 +1476,87 @@ describe("dispatched subagents in the run projection", () => {
1430
1476
  }
1431
1477
  });
1432
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
+
1433
1560
  test("refuses a chip whose background flag is not a boolean", () => {
1434
1561
  const page = createClientRunListV1(
1435
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
  }
@@ -500,6 +508,14 @@ interface ProjectionUnitV1 {
500
508
  droppable: boolean;
501
509
  }
502
510
 
511
+ /**
512
+ * What a settled Turn's tool call shows when the durable record holds no
513
+ * result for it. Same register as the rest of the transcript copy: it tells
514
+ * the person what is missing rather than naming an occurrence id.
515
+ */
516
+ export const UNRECORDED_TOOL_RESULT_TEXT_V1 =
517
+ "No result was recorded for this tool call.";
518
+
503
519
  function dynamicToolCallInput(
504
520
  value: unknown,
505
521
  ): ClientDynamicToolCallInputV1 | undefined {
@@ -658,12 +674,23 @@ function projectionUnits(
658
674
  }
659
675
  }
660
676
  if (isTerminalRunStatus(status)) {
661
- const orphaned = units.find((unit) => !unit.droppable);
662
- if (orphaned) {
663
- const call = orphaned.events[0] as ClientToolCallV1;
664
- throw new Error(
665
- `terminal run has no result for tool call "${call.call.id}"`,
666
- );
677
+ for (const unit of units) {
678
+ if (unit.droppable) continue;
679
+ // A settled Turn owes every tool call a result, and `Session`'s
680
+ // interruption repairs now write one. Records already durable from
681
+ // before that do not have it, and a READ must never throw on them: one
682
+ // malformed row used to brick the whole transcript endpoint for ever.
683
+ // The row degrades instead, and says exactly what is missing — not
684
+ // through `projectClientRunOrDegradedV1`, which throws the whole Turn
685
+ // away for an unreadable record. Everything else here is readable.
686
+ const call = unit.events[0] as ClientToolCallV1;
687
+ unit.events.push({
688
+ type: "tool/result",
689
+ callId: call.call.id,
690
+ content: UNRECORDED_TOOL_RESULT_TEXT_V1,
691
+ isError: true,
692
+ });
693
+ unit.droppable = true;
667
694
  }
668
695
  }
669
696
  return units;
@@ -719,10 +746,13 @@ function visibleEvents(
719
746
  * fact about what the person watched arrive, not a claim that the Turn
720
747
  * succeeded, and the thread keeps it instead of replacing it with a notice.
721
748
  */
722
- function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
749
+ export function assistantTextSoFarV1(
750
+ events: readonly SessionEvent[],
751
+ responseText = "",
752
+ ): string {
723
753
  let requestId: string | undefined;
724
- let text = run.responseText ?? "";
725
- for (const event of run.events) {
754
+ let text = responseText;
755
+ for (const event of events) {
726
756
  if (event.type === "assistant/chunk") {
727
757
  if (event.requestId !== requestId) {
728
758
  requestId = event.requestId;
@@ -734,9 +764,31 @@ function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
734
764
  text = event.text;
735
765
  }
736
766
  }
767
+ return text;
768
+ }
769
+
770
+ function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
771
+ const text = assistantTextSoFarV1(run.events, run.responseText ?? "");
737
772
  return text ? { text: truncateWireString(text, MAX_OUTCOME_BYTES) } : {};
738
773
  }
739
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
+
740
792
  function runStatus(run: StoredRun): ClientRunStatusV1 {
741
793
  return requireStoredRunV1(run).status;
742
794
  }
@@ -791,6 +843,7 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
791
843
  input: truncateWireString(run.input, MAX_INPUT_BYTES),
792
844
  status,
793
845
  events: visibleEvents(run.events, status),
846
+ ...(status === "running" ? partialTextV1(run) : {}),
794
847
  ...(run.stopRequestedAt
795
848
  ? {
796
849
  stopRequestedAt: truncate(run.stopRequestedAt, MAX_TIMESTAMP_LENGTH),
@@ -1137,10 +1190,12 @@ function decodeEvent(value: unknown): ClientRunEventV1 {
1137
1190
  throw new Error("run event.type is invalid");
1138
1191
  }
1139
1192
 
1140
- function decodeEvents(
1141
- values: unknown[],
1142
- runStatus: ClientRunStatusV1,
1143
- ): ClientTurnEvent[] {
1193
+ /**
1194
+ * The event walk a wire run is decoded through. It no longer takes the run's
1195
+ * status: a settled Turn's tool call with no result is a row the projection
1196
+ * has already degraded, not a message to refuse.
1197
+ */
1198
+ function decodeEvents(values: unknown[]): ClientTurnEvent[] {
1144
1199
  const events = values.map(decodeEvent);
1145
1200
  let index = 0;
1146
1201
  if (events[0]?.type === "run/events-truncated") index = 1;
@@ -1178,9 +1233,10 @@ function decodeEvents(
1178
1233
  index += 2;
1179
1234
  continue;
1180
1235
  }
1181
- if (isTerminalRunStatus(runStatus)) {
1182
- throw new Error(`terminal run has no result for tool call "${id}"`);
1183
- }
1236
+ // A settled Turn whose call has no result is a degraded row, not a bad
1237
+ // wire message: the projection above already renders it as "no result
1238
+ // recorded", and refusing it here would put the whole transcript behind
1239
+ // one durable record nobody can now repair.
1184
1240
  index += 1;
1185
1241
  }
1186
1242
  return events;
@@ -1278,6 +1334,7 @@ function decodeRun(value: unknown): ClientRun {
1278
1334
  "events",
1279
1335
  "stopRequestedAt",
1280
1336
  "queued",
1337
+ "partialText",
1281
1338
  "outcome",
1282
1339
  "recovery",
1283
1340
  ],
@@ -1325,14 +1382,25 @@ function decodeRun(value: unknown): ClientRun {
1325
1382
  if (run.queued === true && runStatus !== "running") {
1326
1383
  throw new Error("only a running run may be queued");
1327
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
+ }
1328
1395
  return {
1329
1396
  runId,
1330
1397
  admittedAt,
1331
1398
  input: wireString(run, "input", MAX_INPUT_BYTES, "run"),
1332
1399
  status: runStatus,
1333
- events: decodeEvents(run.events, runStatus),
1400
+ events: decodeEvents(run.events),
1334
1401
  ...(stopRequestedAt ? { stopRequestedAt } : {}),
1335
1402
  ...(run.queued === true ? { queued: true as const } : {}),
1403
+ ...(partialText ? { partialText } : {}),
1336
1404
  ...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
1337
1405
  ...(outcome?.type === "failed"
1338
1406
  ? {
@@ -1454,7 +1522,7 @@ export function decodeClientTurnV1(input: unknown): ClientTurnResponse {
1454
1522
  return {
1455
1523
  runId,
1456
1524
  text: wireString(turn, "text", MAX_OUTCOME_BYTES, "turn"),
1457
- events: decodeEvents(turn.events, "completed"),
1525
+ events: decodeEvents(turn.events),
1458
1526
  ...(notification ? { notification } : {}),
1459
1527
  };
1460
1528
  }
package/src/shared.ts CHANGED
@@ -107,6 +107,13 @@ export interface WebChatMessage {
107
107
  * Bot had already said, which it keeps (ADR 0024).
108
108
  */
109
109
  notice?: string;
110
+ /**
111
+ * The line offers to send the draft again. Set only where the client gave
112
+ * up on its own — it could not reach the backend — because that is the one
113
+ * ending the person cannot act on from the thread otherwise: their text is
114
+ * back in the composer, and this is the button that sends it.
115
+ */
116
+ retry?: "resend";
110
117
  tools: WebToolActivity[];
111
118
  /** The typed payloads this Turn sent to the user, oldest first. */
112
119
  sends: WebSendPayload[];
@@ -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";
@@ -235,6 +236,33 @@ describe("the unread projection", () => {
235
236
  expect(view).toMatchObject({ count: 0, capped: false, unread: false });
236
237
  });
237
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
+
238
266
  test("carries the already-bounded latest message without deriving it", () => {
239
267
  const preview = decodeSidebarMessagePreviewV1({
240
268
  schemaVersion: 1,
@@ -263,6 +291,73 @@ describe("the unread projection", () => {
263
291
  });
264
292
  });
265
293
 
294
+ // The record is written at settlement, so a Bot whose Turns settled before
295
+ // that projection existed has a full transcript and no preview — and its
296
+ // sidebar row said "No messages yet" over six messages. A read derives it.
297
+ describe("the sidebar preview derived from stored runs", () => {
298
+ const run = (over: Record<string, unknown> = {}) => ({
299
+ acceptedAt: "2026-08-31T00:02:00.000Z",
300
+ input: "What is the plan?",
301
+ responseText: "Here is the plan.",
302
+ status: "completed",
303
+ events: [{ timestamp: "2026-08-31T00:02:05.000Z" }],
304
+ ...over,
305
+ });
306
+
307
+ test("takes the newest settled chat Turn's reply, stamped when it settled", () => {
308
+ expect(sidebarMessagePreviewFromRunsV1([run()])).toEqual({
309
+ schemaVersion: 1,
310
+ text: "Here is the plan.",
311
+ at: "2026-08-31T00:02:05.000Z",
312
+ role: "assistant",
313
+ });
314
+ });
315
+
316
+ test("walks past a running Turn and an automation to the newest chat reply", () => {
317
+ expect(
318
+ sidebarMessagePreviewFromRunsV1([
319
+ run({ status: "running", responseText: undefined }),
320
+ run({
321
+ admission: { turnType: "automation" },
322
+ responseText: "Routine ran.",
323
+ }),
324
+ run({ responseText: "The older answer." }),
325
+ ]),
326
+ ).toMatchObject({ text: "The older answer.", role: "assistant" });
327
+ });
328
+
329
+ test("falls back to the User's own words when the reply was empty", () => {
330
+ expect(
331
+ sidebarMessagePreviewFromRunsV1([run({ responseText: "" })]),
332
+ ).toEqual({
333
+ schemaVersion: 1,
334
+ text: "What is the plan?",
335
+ at: "2026-08-31T00:02:00.000Z",
336
+ role: "user",
337
+ });
338
+ });
339
+
340
+ test("a Bot with no settled chat Turn still has no preview", () => {
341
+ expect(sidebarMessagePreviewFromRunsV1([])).toBeUndefined();
342
+ expect(
343
+ sidebarMessagePreviewFromRunsV1([
344
+ run({ status: "running", responseText: undefined, input: "" }),
345
+ ]),
346
+ ).toBeUndefined();
347
+ });
348
+
349
+ // A read of durable data never throws: a run whose stamps are unreadable
350
+ // costs the row, not the whole sidebar.
351
+ test("skips a run whose stored timestamps cannot be read", () => {
352
+ expect(
353
+ sidebarMessagePreviewFromRunsV1([
354
+ run({ acceptedAt: "not a time", events: [] }),
355
+ run({ responseText: "The readable one." }),
356
+ ]),
357
+ ).toMatchObject({ text: "The readable one." });
358
+ });
359
+ });
360
+
266
361
  describe("the unread command", () => {
267
362
  test("decodes each type and refuses a mismatched cursor", () => {
268
363
  expect(