@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.
- package/frockbot.json +1 -1
- package/package.json +31 -31
- package/src/backend-authoring.test.ts +72 -0
- package/src/backend-authoring.ts +82 -16
- package/src/backend-configuration.test.ts +6 -4
- package/src/backend-package-catalog.test.ts +23 -3
- package/src/backend-package-catalog.ts +7 -1
- package/src/backend-recovery-integration.test.ts +23 -23
- package/src/backend.ts +119 -29
- package/src/client/AppletCanvas.vue +3 -5
- package/src/client/FrockBotApp.vue +197 -79
- package/src/client/PackageIframeHost.vue +15 -6
- package/src/client/index.test.ts +111 -15
- package/src/client/index.ts +217 -43
- package/src/client/model-presentation.test.ts +4 -2
- package/src/client/model-presentation.ts +2 -2
- package/src/client/styles.css +76 -1
- package/src/run-protocol.test.ts +73 -0
- package/src/run-protocol.ts +161 -9
- package/src/shared.ts +6 -0
package/src/run-protocol.test.ts
CHANGED
|
@@ -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]))],
|
package/src/run-protocol.ts
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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"
|
|
1117
|
-
|
|
1118
|
-
|
|
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[];
|