@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.
- package/package.json +31 -31
- package/src/backend-routines.ts +4 -0
- package/src/backend-subagent-blocking.test.ts +222 -0
- package/src/backend.ts +110 -19
- package/src/client/FrockBotApp.vue +53 -1
- package/src/client/index.test.ts +205 -0
- package/src/client/index.ts +80 -8
- package/src/client/styles.css +34 -0
- package/src/client/turn-limits.test.ts +41 -0
- package/src/client/turn-limits.ts +40 -0
- package/src/client/uncertain-admission.test.ts +69 -0
- package/src/client/uncertain-admission.ts +78 -0
- package/src/debug-protocol.test.ts +38 -2
- package/src/debug-protocol.ts +39 -6
- package/src/run-protocol.test.ts +130 -3
- package/src/run-protocol.ts +86 -18
- package/src/shared.ts +7 -0
- package/src/unread.test.ts +95 -0
- package/src/unread.ts +62 -1
|
@@ -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(
|
|
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", () => {
|
package/src/debug-protocol.ts
CHANGED
|
@@ -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
|
|
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))
|
|
130
|
+
if (!isRecord(input)) {
|
|
131
|
+
throw new BotDebugQueryRefusedErrorV1("debug query is invalid");
|
|
132
|
+
}
|
|
106
133
|
if (input.schemaVersion !== 1) {
|
|
107
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
169
|
+
throw new BotDebugQueryRefusedErrorV1(
|
|
170
|
+
"debug query events must be true or false",
|
|
171
|
+
);
|
|
139
172
|
}
|
|
140
173
|
query.events = input.events;
|
|
141
174
|
}
|
package/src/run-protocol.test.ts
CHANGED
|
@@ -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]))],
|
package/src/run-protocol.ts
CHANGED
|
@@ -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
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
|
749
|
+
export function assistantTextSoFarV1(
|
|
750
|
+
events: readonly SessionEvent[],
|
|
751
|
+
responseText = "",
|
|
752
|
+
): string {
|
|
723
753
|
let requestId: string | undefined;
|
|
724
|
-
let text =
|
|
725
|
-
for (const event of
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
1182
|
-
|
|
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
|
|
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
|
|
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[];
|
package/src/unread.test.ts
CHANGED
|
@@ -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(
|