@frockbot/plugin-shell 0.3.4 → 0.3.6

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.
@@ -22,6 +22,7 @@ import {
22
22
  projectClientRunLookupV1,
23
23
  projectClientRunListV1,
24
24
  projectClientRunV1,
25
+ projectClientRunOrDegradedV1,
25
26
  projectClientTurnV1,
26
27
  } from "./run-protocol.js";
27
28
 
@@ -1357,6 +1358,78 @@ describe("dispatched subagents in the run projection", () => {
1357
1358
  expect(() => decodeClientRunPageV1(tampered)).toThrow();
1358
1359
  });
1359
1360
 
1361
+ test("a run whose record cannot be read degrades instead of failing the list", () => {
1362
+ // One badly written record — a resolve that wrote a shape the record does
1363
+ // not allow — used to answer 500 for the whole transcript, for good.
1364
+ const broken = {
1365
+ ...storedRun([], "running"),
1366
+ status: "reconciliation-required",
1367
+ phase: "executing",
1368
+ } as unknown as StoredRun;
1369
+ expect(() => projectClientRunV1(broken)).toThrow();
1370
+
1371
+ const degraded = projectClientRunOrDegradedV1(broken);
1372
+ expect(degraded).toMatchObject({
1373
+ runId: "run-events",
1374
+ status: "failed",
1375
+ outcome: { type: "failed" },
1376
+ });
1377
+ // And the degraded row is itself a valid projection, so the page decodes.
1378
+ expect(
1379
+ decodeClientRunPageV1(
1380
+ createClientRunListV1([degraded], { truncated: false }),
1381
+ ).runs,
1382
+ ).toHaveLength(1);
1383
+ });
1384
+
1385
+ test("an interrupted Turn keeps the text it had already streamed", () => {
1386
+ const streamed: SessionEvent[] = [
1387
+ event({
1388
+ type: "assistant/chunk",
1389
+ seq: 0,
1390
+ timestamp,
1391
+ turn: 1,
1392
+ step: 1,
1393
+ requestId: "request-1",
1394
+ text: "The three things to know are",
1395
+ }),
1396
+ event({
1397
+ type: "assistant/chunk",
1398
+ seq: 1,
1399
+ timestamp,
1400
+ turn: 1,
1401
+ step: 1,
1402
+ requestId: "request-1",
1403
+ text: " first, that",
1404
+ }),
1405
+ ];
1406
+
1407
+ for (const status of ["cancelled", "superseded"] as const) {
1408
+ const projected = projectClientRunV1({
1409
+ ...storedRun(streamed, status),
1410
+ ...(status === "superseded"
1411
+ ? {
1412
+ supersededAt: "2026-08-28T00:00:05.000Z",
1413
+ supersededBy: "run-next",
1414
+ }
1415
+ : {}),
1416
+ });
1417
+ expect(projected.outcome).toMatchObject({
1418
+ type: status,
1419
+ text: "The three things to know are first, that",
1420
+ });
1421
+ // And it survives the wire: the client reads it as the Turn's text, with
1422
+ // the notice kept separately as the line that says why it stops there.
1423
+ const decoded = decodeClientRunPageV1(
1424
+ createClientRunListV1([projected], { truncated: false }),
1425
+ ).runs[0];
1426
+ expect(decoded?.responseText).toBe(
1427
+ "The three things to know are first, that",
1428
+ );
1429
+ expect(decoded?.failure).toBeDefined();
1430
+ }
1431
+ });
1432
+
1360
1433
  test("refuses a chip whose background flag is not a boolean", () => {
1361
1434
  const page = createClientRunListV1(
1362
1435
  [projectClientRunV1(storedRun([dispatched]))],
@@ -52,6 +52,63 @@ export type ClientRunStatusV1 =
52
52
  const CANCELLED_RUN_MESSAGE = "Stopped by an authenticated Stop command.";
53
53
  const SUPERSEDED_RUN_MESSAGE = "Interrupted by your next message.";
54
54
 
55
+ /**
56
+ * Why the Bot declined to admit a Turn. A refusal is an ordinary answer — the
57
+ * Bot is busy with a Turn this command did not ask to replace, is holding an
58
+ * effect only a User can settle, or the command was fenced or already used —
59
+ * so the client shows the reason and keeps the person's text rather than
60
+ * treating it as a failure of the send.
61
+ */
62
+ export type ClientTurnRefusalReasonV1 =
63
+ "busy" | "reconciliation-required" | "fenced" | "duplicate";
64
+
65
+ /** The versioned body a refused Turn answers with, decoded by the client. */
66
+ export interface ClientTurnRefusalV1 {
67
+ schemaVersion: 1;
68
+ status: "refused";
69
+ reason: ClientTurnRefusalReasonV1;
70
+ error: string;
71
+ }
72
+
73
+ const TURN_REFUSAL_REASONS_V1: readonly ClientTurnRefusalReasonV1[] = [
74
+ "busy",
75
+ "reconciliation-required",
76
+ "fenced",
77
+ "duplicate",
78
+ ];
79
+
80
+ /** The refusal a response body carries, or `undefined` when it carries none. */
81
+ export function decodeClientTurnRefusalV1(
82
+ value: unknown,
83
+ ): ClientTurnRefusalV1 | undefined {
84
+ if (typeof value !== "object" || value === null) return undefined;
85
+ const body = value as Record<string, unknown>;
86
+ if (body.schemaVersion !== 1 || body.status !== "refused") return undefined;
87
+ if (typeof body.error !== "string") return undefined;
88
+ const reason = TURN_REFUSAL_REASONS_V1.find(
89
+ (candidate) => candidate === body.reason,
90
+ );
91
+ if (!reason) return undefined;
92
+ return {
93
+ schemaVersion: 1,
94
+ status: "refused",
95
+ reason,
96
+ error: wireString(body, "error", MAX_FAILURE_BYTES, "turn refusal"),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * A refusal, as an error, because that is how a transport reports a non-2xx.
102
+ * The reason survives on the error so the client can tell "the Bot said no"
103
+ * from "the send may or may not have happened".
104
+ */
105
+ export class ClientTurnRefusedErrorV1 extends Error {
106
+ constructor(readonly refusal: ClientTurnRefusalV1) {
107
+ super(refusal.error);
108
+ this.name = "ClientTurnRefusedErrorV1";
109
+ }
110
+ }
111
+
55
112
  export type ClientRunEventV1 =
56
113
  | {
57
114
  type: "run/events-truncated";
@@ -122,9 +179,19 @@ export interface ClientDynamicToolCallInputV1 {
122
179
 
123
180
  export type ClientRunOutcomeV1 =
124
181
  | { type: "completed"; text: string }
125
- | { type: "failed"; message: string }
126
- | { type: "cancelled"; message: string }
127
- | { type: "superseded"; message: string };
182
+ /**
183
+ * A Turn that broke keeps what it had already said, for the same reason a
184
+ * stopped one does: the words arrived, the person read them, and replacing
185
+ * them with a notice would rewrite what they watched happen (ADR 0028).
186
+ */
187
+ | { type: "failed"; message: string; text?: string }
188
+ /**
189
+ * A Turn a Stop or a later message ended keeps what it had already said:
190
+ * `text` is that partial answer, and `message` is the line saying why it
191
+ * ends where it does (ADR 0024).
192
+ */
193
+ | { type: "cancelled"; message: string; text?: string }
194
+ | { type: "superseded"; message: string; text?: string };
128
195
 
129
196
  export interface ClientRunRecoveryV1 {
130
197
  action: "resume";
@@ -565,6 +632,33 @@ function visibleEvents(
565
632
  return projection;
566
633
  }
567
634
 
635
+ /**
636
+ * What an interrupted Turn had already said, read back out of its journal.
637
+ *
638
+ * The kernel records a Turn's answer as it streams, so a Turn stopped or
639
+ * superseded mid-sentence still holds every word it sent. It never reached a
640
+ * `responseText`, because it never completed — but the partial answer is a
641
+ * fact about what the person watched arrive, not a claim that the Turn
642
+ * succeeded, and the thread keeps it instead of replacing it with a notice.
643
+ */
644
+ function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
645
+ let requestId: string | undefined;
646
+ let text = run.responseText ?? "";
647
+ for (const event of run.events) {
648
+ if (event.type === "assistant/chunk") {
649
+ if (event.requestId !== requestId) {
650
+ requestId = event.requestId;
651
+ text = "";
652
+ }
653
+ text += event.text;
654
+ } else if (event.type === "assistant/message") {
655
+ requestId = event.requestId;
656
+ text = event.text;
657
+ }
658
+ }
659
+ return text ? { text: truncateWireString(text, MAX_OUTCOME_BYTES) } : {};
660
+ }
661
+
568
662
  function runStatus(run: StoredRun): ClientRunStatusV1 {
569
663
  return requireStoredRunV1(run).status;
570
664
  }
@@ -584,16 +678,19 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
584
678
  run.failure ?? "Agent request failed.",
585
679
  MAX_FAILURE_BYTES,
586
680
  ),
681
+ ...interruptedOutcomeTextV1(run),
587
682
  } satisfies ClientRunOutcomeV1)
588
683
  : status === "cancelled"
589
684
  ? ({
590
685
  type: "cancelled",
591
686
  message: CANCELLED_RUN_MESSAGE,
687
+ ...interruptedOutcomeTextV1(run),
592
688
  } satisfies ClientRunOutcomeV1)
593
689
  : status === "superseded"
594
690
  ? ({
595
691
  type: "superseded",
596
692
  message: SUPERSEDED_RUN_MESSAGE,
693
+ ...interruptedOutcomeTextV1(run),
597
694
  } satisfies ClientRunOutcomeV1)
598
695
  : undefined;
599
696
  const recovery =
@@ -695,6 +792,39 @@ export function projectClientTurnV1(result: BotTurnCompletion): ClientTurnV1 {
695
792
  };
696
793
  }
697
794
 
795
+ /**
796
+ * One stored run on the wire, degraded rather than thrown when the record
797
+ * cannot be read.
798
+ *
799
+ * A single unreadable run — an older shape, or one a bug wrote badly — used to
800
+ * fail the whole transcript: `GET /turns` answered 500 for every request after
801
+ * it, and the person's entire conversation disappeared behind one bad row. The
802
+ * transcript keeps its shape and says which Turn it could not read.
803
+ */
804
+ export function projectClientRunOrDegradedV1(run: StoredRun): ClientRunV1 {
805
+ try {
806
+ return projectClientRunV1(run);
807
+ } catch {
808
+ const admittedAt =
809
+ typeof run.acceptedAt === "string" &&
810
+ Number.isFinite(Date.parse(run.acceptedAt))
811
+ ? run.acceptedAt
812
+ : new Date(0).toISOString();
813
+ return {
814
+ schemaVersion: 2,
815
+ runId: truncate(String(run.runId ?? "unknown"), MAX_RUN_ID_LENGTH),
816
+ admittedAt,
817
+ input: typeof run.input === "string" ? run.input : "",
818
+ status: "failed",
819
+ events: [],
820
+ outcome: {
821
+ type: "failed",
822
+ message: "This Turn's record could not be read.",
823
+ },
824
+ };
825
+ }
826
+ }
827
+
698
828
  export function projectClientRunListV1(
699
829
  runs: readonly StoredRun[],
700
830
  ): ClientRunListV1 {
@@ -997,29 +1127,42 @@ function decodeOutcome(
997
1127
  };
998
1128
  }
999
1129
  if (outcome.type === "failed" && runStatus === "failed") {
1000
- exactKeys(outcome, ["type", "message"], "run.outcome");
1130
+ exactKeys(outcome, ["type", "message", "text"], "run.outcome");
1001
1131
  return {
1002
1132
  type: "failed",
1003
1133
  message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
1134
+ ...decodeInterruptedTextV1(outcome),
1004
1135
  };
1005
1136
  }
1006
1137
  if (outcome.type === "cancelled" && runStatus === "cancelled") {
1007
- exactKeys(outcome, ["type", "message"], "run.outcome");
1138
+ exactKeys(outcome, ["type", "message", "text"], "run.outcome");
1008
1139
  return {
1009
1140
  type: "cancelled",
1010
1141
  message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
1142
+ ...decodeInterruptedTextV1(outcome),
1011
1143
  };
1012
1144
  }
1013
1145
  if (outcome.type === "superseded" && runStatus === "superseded") {
1014
- exactKeys(outcome, ["type", "message"], "run.outcome");
1146
+ exactKeys(outcome, ["type", "message", "text"], "run.outcome");
1015
1147
  return {
1016
1148
  type: "superseded",
1017
1149
  message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
1150
+ ...decodeInterruptedTextV1(outcome),
1018
1151
  };
1019
1152
  }
1020
1153
  throw new Error("run.outcome does not match run.status");
1021
1154
  }
1022
1155
 
1156
+ /** The partial answer an interrupted Turn kept, when it said anything. */
1157
+ function decodeInterruptedTextV1(outcome: Record<string, unknown>): {
1158
+ text?: string;
1159
+ } {
1160
+ if (outcome.text === undefined) return {};
1161
+ return {
1162
+ text: wireString(outcome, "text", MAX_OUTCOME_BYTES, "run.outcome"),
1163
+ };
1164
+ }
1165
+
1023
1166
  function decodeRecovery(
1024
1167
  value: unknown,
1025
1168
  runStatus: ClientRunStatusV1,
@@ -1113,9 +1256,18 @@ function decodeRun(value: unknown): ClientRun {
1113
1256
  ...(stopRequestedAt ? { stopRequestedAt } : {}),
1114
1257
  ...(run.queued === true ? { queued: true as const } : {}),
1115
1258
  ...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
1116
- ...(outcome?.type === "failed" ? { failure: outcome.message } : {}),
1117
- ...(outcome?.type === "cancelled" ? { failure: outcome.message } : {}),
1118
- ...(outcome?.type === "superseded" ? { failure: outcome.message } : {}),
1259
+ ...(outcome?.type === "failed"
1260
+ ? {
1261
+ failure: outcome.message,
1262
+ ...(outcome.text ? { responseText: outcome.text } : {}),
1263
+ }
1264
+ : {}),
1265
+ ...(outcome?.type === "cancelled" || outcome?.type === "superseded"
1266
+ ? {
1267
+ failure: outcome.message,
1268
+ ...(outcome.text ? { responseText: outcome.text } : {}),
1269
+ }
1270
+ : {}),
1119
1271
  ...(recovery ? { failure: recovery.message, recovery } : {}),
1120
1272
  };
1121
1273
  }
package/src/shared.ts CHANGED
@@ -101,6 +101,12 @@ export interface WebChatMessage {
101
101
  * reached, not a state the User has to understand.
102
102
  */
103
103
  pending?: boolean;
104
+ /**
105
+ * A line under the bubble saying why the Turn ends where it does — it was
106
+ * stopped, or a later message took its place. The text above it is what the
107
+ * Bot had already said, which it keeps (ADR 0024).
108
+ */
109
+ notice?: string;
104
110
  tools: WebToolActivity[];
105
111
  /** The typed payloads this Turn sent to the user, oldest first. */
106
112
  sends: WebSendPayload[];