@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.
@@ -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
  }
@@ -78,6 +78,58 @@ function scoped(events: SessionEvent[]): LlmMessage[] {
78
78
  });
79
79
  }
80
80
 
81
+ function scopedWithBudget(events: SessionEvent[], budget: number) {
82
+ return turnScopedMessagesV1({
83
+ events,
84
+ messages: derive(events),
85
+ pointer: automationParentPointerV1,
86
+ sessionId: "bot:scout",
87
+ budget,
88
+ });
89
+ }
90
+
91
+ describe("one request carries a bounded amount of history", () => {
92
+ test("keeps the current Turn whole and drops the oldest, with a notice", () => {
93
+ const events = log([
94
+ ...turn(1, "chat", "the oldest thing", "first reply"),
95
+ ...turn(2, "chat", "the middle thing", "second reply"),
96
+ ...turn(3, "chat", "the newest thing", ""),
97
+ ]);
98
+ // Room for the current Turn and one older one, not for all three.
99
+ const messages = scopedWithBudget(events, 200);
100
+
101
+ const contents = messages.map((message) => message.content);
102
+ expect(contents).toContain("the newest thing");
103
+ expect(contents.join(" ")).not.toContain("the oldest thing");
104
+ expect(contents[0]).toContain("not included here");
105
+ expect(contents[0]).toContain("1 Turn");
106
+ });
107
+
108
+ test("carries everything when it fits, and says nothing about omission", () => {
109
+ const events = log([
110
+ ...turn(1, "chat", "morning", "hello"),
111
+ ...turn(2, "chat", "anything new?", ""),
112
+ ]);
113
+
114
+ expect(
115
+ scopedWithBudget(events, 100_000).map((message) => message.content),
116
+ ).toEqual(["morning", "hello", "anything new?", ""]);
117
+ });
118
+
119
+ test("keeps the current Turn even when it alone exceeds the budget", () => {
120
+ const events = log([
121
+ ...turn(1, "chat", "old", "older"),
122
+ ...turn(2, "chat", "x".repeat(500), ""),
123
+ ]);
124
+ const messages = scopedWithBudget(events, 50);
125
+
126
+ // A Turn is never split: dropping the user message and keeping the reply
127
+ // would be a malformed request, so the current Turn survives whole.
128
+ expect(messages.at(-2)!.content).toBe("x".repeat(500));
129
+ expect(messages[0]!.content).toContain("not included here");
130
+ });
131
+ });
132
+
81
133
  describe("turn-scoped prompt history", () => {
82
134
  test("a chat Turn sees only the Turns admitted as chat", () => {
83
135
  const events = log([
package/src/history.ts CHANGED
@@ -68,6 +68,76 @@ export interface TurnScopedMessagesInputV1 {
68
68
  /** The parent-transcript pointer, used only on a non-chat Turn. */
69
69
  pointer(input: { sessionId: string; chatTurns: number }): string;
70
70
  sessionId: string;
71
+ /**
72
+ * How many characters of history one request may carry. The current Turn is
73
+ * always whole; older Turns fill what is left. Absent means the default.
74
+ */
75
+ budget?: number;
76
+ }
77
+
78
+ /**
79
+ * How much conversation one model request carries.
80
+ *
81
+ * A number in characters, not tokens: this is a Package policy bound whose job
82
+ * is to stop a request growing without limit, and it does not need to agree
83
+ * with any provider's tokenizer to do that. Roughly 150k characters is well
84
+ * inside every model FrockBot resolves today while being far more history than
85
+ * any conversation needs.
86
+ */
87
+ export const CHAT_HISTORY_BUDGET_CHARS_V1 = 150_000;
88
+
89
+ /**
90
+ * The line that stands where the dropped Turns were.
91
+ *
92
+ * It is said plainly, because a model that cannot see the beginning of a
93
+ * conversation and is not told so will confidently answer as though it had.
94
+ */
95
+ export function omittedHistoryNoticeV1(turns: number): string {
96
+ return `Earlier in this conversation there ${turns === 1 ? "was 1 Turn" : `were ${turns} Turns`} that are not included here. They are not summarised: if you need something from them, say so or search your memory rather than guessing.`;
97
+ }
98
+
99
+ function messageChars(message: LlmMessage): number {
100
+ return JSON.stringify(message).length;
101
+ }
102
+
103
+ /**
104
+ * Narrows history to a character budget, oldest Turns first.
105
+ *
106
+ * Eviction is by whole Turn on purpose. A tool result whose call has been
107
+ * dropped is a malformed request to every provider, and a Turn is the
108
+ * smallest unit that always holds both.
109
+ */
110
+ function budgetedMessagesV1(
111
+ messages: readonly LlmMessage[],
112
+ turns: readonly number[],
113
+ current: number,
114
+ budget: number,
115
+ ): LlmMessage[] {
116
+ const total = messages.reduce(
117
+ (sum, message) => sum + messageChars(message),
118
+ 0,
119
+ );
120
+ if (total <= budget) return [...messages];
121
+ const spendByTurn = new Map<number, number>();
122
+ for (const [index, message] of messages.entries()) {
123
+ const turn = turns[index]!;
124
+ spendByTurn.set(turn, (spendByTurn.get(turn) ?? 0) + messageChars(message));
125
+ }
126
+ const ordered = [...spendByTurn.keys()].sort((left, right) => right - left);
127
+ const kept = new Set<number>([current]);
128
+ let spent = spendByTurn.get(current) ?? 0;
129
+ for (const turn of ordered) {
130
+ if (turn === current) continue;
131
+ const cost = spendByTurn.get(turn) ?? 0;
132
+ if (spent + cost > budget) break;
133
+ kept.add(turn);
134
+ spent += cost;
135
+ }
136
+ const dropped = ordered.filter((turn) => !kept.has(turn)).length;
137
+ const narrowed = messages.filter((_, index) => kept.has(turns[index]!));
138
+ return dropped === 0
139
+ ? narrowed
140
+ : [{ role: "user", content: omittedHistoryNoticeV1(dropped) }, ...narrowed];
71
141
  }
72
142
 
73
143
  /**
@@ -92,7 +162,15 @@ export function turnScopedMessagesV1(
92
162
  const current = currentTurnV1(input.events);
93
163
  const chatTurn = (turn: number) => (types.get(turn) ?? "chat") === "chat";
94
164
  if (chatTurn(current)) {
95
- return input.messages.filter((_, index) => chatTurn(turns[index]!));
165
+ const conversation = input.messages.filter((_, index) =>
166
+ chatTurn(turns[index]!),
167
+ );
168
+ return budgetedMessagesV1(
169
+ conversation,
170
+ turns.filter((turn) => chatTurn(turn)),
171
+ current,
172
+ input.budget ?? CHAT_HISTORY_BUDGET_CHARS_V1,
173
+ );
96
174
  }
97
175
  const own = input.messages.filter((_, index) => turns[index] === current);
98
176
  const chatTurns = new Set(
@@ -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")])
@@ -36,6 +36,8 @@ const MAX_NOTIFICATION_TITLE_BYTES = 512;
36
36
  const MAX_NOTIFICATION_BODY_BYTES = 2_000;
37
37
  const MAX_CLIENT_TURN_BYTES = 256_000;
38
38
  const MAX_CURSOR_LENGTH = 320;
39
+ /** A conversation is named by its Session id, which the kernel bounds. */
40
+ const MAX_SESSION_ID_LENGTH = 320;
39
41
  const MAX_TASK_DESCRIPTION_BYTES = 800;
40
42
  const MAX_TASK_MODEL_BYTES = 512;
41
43
  export const CLIENT_RUN_PAGE_LIMIT = 32;
@@ -251,9 +253,85 @@ export interface ClientRunListV1 {
251
253
  announcements?: ClientAnnouncementV1[];
252
254
  }
253
255
 
256
+ /**
257
+ * One conversation a Bot has had.
258
+ *
259
+ * A Bot holds one conversation at a time and keeps the ones before it: the
260
+ * transcript shows the current one, and an earlier one is still readable.
261
+ */
262
+ export interface ClientConversationV1 {
263
+ schemaVersion: 1;
264
+ /** The Session id this conversation's Turns recorded. */
265
+ conversationId: string;
266
+ ordinal: number;
267
+ startedAt: string;
268
+ /** Absent while this is the conversation the Bot is on. */
269
+ endedAt?: string;
270
+ }
271
+
272
+ export interface ClientConversationListV1 {
273
+ schemaVersion: 1;
274
+ /** Newest first; the first entry is the conversation the Bot is on. */
275
+ conversations: ClientConversationV1[];
276
+ }
277
+
278
+ export function decodeClientConversationListV1(
279
+ input: unknown,
280
+ ): ClientConversationListV1 {
281
+ const list = record(input, "conversation list");
282
+ exactKeys(list, ["schemaVersion", "conversations"], "conversation list");
283
+ if (list.schemaVersion !== 1) {
284
+ throw new Error("conversation list.schemaVersion is invalid");
285
+ }
286
+ if (!Array.isArray(list.conversations)) {
287
+ throw new Error("conversation list.conversations is invalid");
288
+ }
289
+ return {
290
+ schemaVersion: 1,
291
+ conversations: list.conversations.map((entry) => {
292
+ const conversation = record(entry, "conversation");
293
+ exactKeys(
294
+ conversation,
295
+ ["schemaVersion", "conversationId", "ordinal", "startedAt", "endedAt"],
296
+ "conversation",
297
+ );
298
+ if (conversation.schemaVersion !== 1) {
299
+ throw new Error("conversation.schemaVersion is invalid");
300
+ }
301
+ if (
302
+ typeof conversation.ordinal !== "number" ||
303
+ !Number.isSafeInteger(conversation.ordinal) ||
304
+ conversation.ordinal < 1
305
+ ) {
306
+ throw new Error("conversation.ordinal is invalid");
307
+ }
308
+ return {
309
+ schemaVersion: 1 as const,
310
+ conversationId: string(
311
+ conversation,
312
+ "conversationId",
313
+ MAX_SESSION_ID_LENGTH,
314
+ "conversation",
315
+ ),
316
+ ordinal: conversation.ordinal,
317
+ startedAt: string(conversation, "startedAt", 64, "conversation"),
318
+ ...(conversation.endedAt === undefined
319
+ ? {}
320
+ : { endedAt: string(conversation, "endedAt", 64, "conversation") }),
321
+ };
322
+ }),
323
+ };
324
+ }
325
+
254
326
  export interface ClientRunListQueryV1 {
255
327
  schemaVersion: 1;
256
328
  before?: string;
329
+ /**
330
+ * The conversation to read. Absent means the one the Bot is on now, which
331
+ * is what the transcript shows; an earlier conversation is named by the
332
+ * Session id `listConversations` gave for it.
333
+ */
334
+ conversationId?: string;
257
335
  }
258
336
 
259
337
  export interface ClientTurnCommandV1 {
@@ -422,6 +500,14 @@ interface ProjectionUnitV1 {
422
500
  droppable: boolean;
423
501
  }
424
502
 
503
+ /**
504
+ * What a settled Turn's tool call shows when the durable record holds no
505
+ * result for it. Same register as the rest of the transcript copy: it tells
506
+ * the person what is missing rather than naming an occurrence id.
507
+ */
508
+ export const UNRECORDED_TOOL_RESULT_TEXT_V1 =
509
+ "No result was recorded for this tool call.";
510
+
425
511
  function dynamicToolCallInput(
426
512
  value: unknown,
427
513
  ): ClientDynamicToolCallInputV1 | undefined {
@@ -580,12 +666,23 @@ function projectionUnits(
580
666
  }
581
667
  }
582
668
  if (isTerminalRunStatus(status)) {
583
- const orphaned = units.find((unit) => !unit.droppable);
584
- if (orphaned) {
585
- const call = orphaned.events[0] as ClientToolCallV1;
586
- throw new Error(
587
- `terminal run has no result for tool call "${call.call.id}"`,
588
- );
669
+ for (const unit of units) {
670
+ if (unit.droppable) continue;
671
+ // A settled Turn owes every tool call a result, and `Session`'s
672
+ // interruption repairs now write one. Records already durable from
673
+ // before that do not have it, and a READ must never throw on them: one
674
+ // malformed row used to brick the whole transcript endpoint for ever.
675
+ // The row degrades instead, and says exactly what is missing — not
676
+ // through `projectClientRunOrDegradedV1`, which throws the whole Turn
677
+ // away for an unreadable record. Everything else here is readable.
678
+ const call = unit.events[0] as ClientToolCallV1;
679
+ unit.events.push({
680
+ type: "tool/result",
681
+ callId: call.call.id,
682
+ content: UNRECORDED_TOOL_RESULT_TEXT_V1,
683
+ isError: true,
684
+ });
685
+ unit.droppable = true;
589
686
  }
590
687
  }
591
688
  return units;
@@ -1059,10 +1156,12 @@ function decodeEvent(value: unknown): ClientRunEventV1 {
1059
1156
  throw new Error("run event.type is invalid");
1060
1157
  }
1061
1158
 
1062
- function decodeEvents(
1063
- values: unknown[],
1064
- runStatus: ClientRunStatusV1,
1065
- ): ClientTurnEvent[] {
1159
+ /**
1160
+ * The event walk a wire run is decoded through. It no longer takes the run's
1161
+ * status: a settled Turn's tool call with no result is a row the projection
1162
+ * has already degraded, not a message to refuse.
1163
+ */
1164
+ function decodeEvents(values: unknown[]): ClientTurnEvent[] {
1066
1165
  const events = values.map(decodeEvent);
1067
1166
  let index = 0;
1068
1167
  if (events[0]?.type === "run/events-truncated") index = 1;
@@ -1100,9 +1199,10 @@ function decodeEvents(
1100
1199
  index += 2;
1101
1200
  continue;
1102
1201
  }
1103
- if (isTerminalRunStatus(runStatus)) {
1104
- throw new Error(`terminal run has no result for tool call "${id}"`);
1105
- }
1202
+ // A settled Turn whose call has no result is a degraded row, not a bad
1203
+ // wire message: the projection above already renders it as "no result
1204
+ // recorded", and refusing it here would put the whole transcript behind
1205
+ // one durable record nobody can now repair.
1106
1206
  index += 1;
1107
1207
  }
1108
1208
  return events;
@@ -1252,7 +1352,7 @@ function decodeRun(value: unknown): ClientRun {
1252
1352
  admittedAt,
1253
1353
  input: wireString(run, "input", MAX_INPUT_BYTES, "run"),
1254
1354
  status: runStatus,
1255
- events: decodeEvents(run.events, runStatus),
1355
+ events: decodeEvents(run.events),
1256
1356
  ...(stopRequestedAt ? { stopRequestedAt } : {}),
1257
1357
  ...(run.queued === true ? { queued: true as const } : {}),
1258
1358
  ...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
@@ -1376,7 +1476,7 @@ export function decodeClientTurnV1(input: unknown): ClientTurnResponse {
1376
1476
  return {
1377
1477
  runId,
1378
1478
  text: wireString(turn, "text", MAX_OUTCOME_BYTES, "turn"),
1379
- events: decodeEvents(turn.events, "completed"),
1479
+ events: decodeEvents(turn.events),
1380
1480
  ...(notification ? { notification } : {}),
1381
1481
  };
1382
1482
  }
@@ -1385,7 +1485,11 @@ export function decodeClientRunListQueryV1(
1385
1485
  input: unknown,
1386
1486
  ): ClientRunListQueryV1 {
1387
1487
  const query = record(input, "run list query");
1388
- exactKeys(query, ["schemaVersion", "before"], "run list query");
1488
+ exactKeys(
1489
+ query,
1490
+ ["schemaVersion", "before", "conversationId"],
1491
+ "run list query",
1492
+ );
1389
1493
  if (query.schemaVersion !== 1) {
1390
1494
  throw new Error("run list query.schemaVersion is invalid");
1391
1495
  }
@@ -1400,7 +1504,20 @@ export function decodeClientRunListQueryV1(
1400
1504
  throw new Error("run list query.before is invalid");
1401
1505
  }
1402
1506
  }
1403
- return { schemaVersion: 1, ...(before ? { before } : {}) };
1507
+ const conversationId =
1508
+ query.conversationId === undefined
1509
+ ? undefined
1510
+ : string(
1511
+ query,
1512
+ "conversationId",
1513
+ MAX_SESSION_ID_LENGTH,
1514
+ "run list query",
1515
+ );
1516
+ return {
1517
+ schemaVersion: 1,
1518
+ ...(before ? { before } : {}),
1519
+ ...(conversationId ? { conversationId } : {}),
1520
+ };
1404
1521
  }
1405
1522
 
1406
1523
  export function decodeClientTurnCommandV1(input: unknown): ClientTurnCommandV1 {
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[];
@@ -161,6 +168,60 @@ export interface PluginCatalogItem {
161
168
  settings?: PackageSettingDefinition[];
162
169
  }
163
170
 
171
+ /** What an external authorization redirect told the app on the way back. */
172
+ export interface ConnectionReturnV1 {
173
+ /** The Package that owns the Connection, e.g. `composio`. */
174
+ packageId: string;
175
+ status: "ready" | "pending" | "failed";
176
+ /** A provider- or callback-supplied explanation, when there is one. */
177
+ reason?: string;
178
+ }
179
+
180
+ const CONNECTION_RETURN_PARAM = "connection";
181
+ const CONNECTION_RETURN_REASON_PARAM = "connection_reason";
182
+ const MAX_CONNECTION_RETURN_REASON = 300;
183
+
184
+ /**
185
+ * Read an authorization return out of a URL query string.
186
+ *
187
+ * The callback redirects to `/?connection=<packageId>-<status>`. Left unread it
188
+ * is a stale query string and nothing else: the User is returned to the app
189
+ * with no confirmation, and a `failed` grant vanishes entirely.
190
+ */
191
+ export function decodeConnectionReturnV1(
192
+ search: string,
193
+ ): ConnectionReturnV1 | undefined {
194
+ let params: URLSearchParams;
195
+ try {
196
+ params = new URLSearchParams(search);
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ const raw = params.get(CONNECTION_RETURN_PARAM);
201
+ if (!raw) return undefined;
202
+ const separator = raw.lastIndexOf("-");
203
+ if (separator <= 0) return undefined;
204
+ const packageId = raw.slice(0, separator);
205
+ const status = raw.slice(separator + 1);
206
+ if (status !== "ready" && status !== "pending" && status !== "failed") {
207
+ return undefined;
208
+ }
209
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(packageId)) return undefined;
210
+ const reason = params
211
+ .get(CONNECTION_RETURN_REASON_PARAM)
212
+ ?.slice(0, MAX_CONNECTION_RETURN_REASON);
213
+ return { packageId, status, ...(reason ? { reason } : {}) };
214
+ }
215
+
216
+ /** The same query string with the return parameters removed. */
217
+ export function withoutConnectionReturnV1(search: string): string {
218
+ const params = new URLSearchParams(search);
219
+ params.delete(CONNECTION_RETURN_PARAM);
220
+ params.delete(CONNECTION_RETURN_REASON_PARAM);
221
+ const rest = params.toString();
222
+ return rest ? `?${rest}` : "";
223
+ }
224
+
164
225
  export interface FrockBotWebData {
165
226
  connection: WebConnection;
166
227
  modelLabel: string;
@@ -261,6 +322,12 @@ export interface FrockBotWebData {
261
322
  */
262
323
  mcpServers?: McpServerStatusViewV1;
263
324
  settingsError?: string;
325
+ /**
326
+ * What the browser came back from an external authorization with. Read once
327
+ * from the return URL at boot and cleared when the User has seen it, so a
328
+ * cancelled or failed grant is reported rather than silently discarded.
329
+ */
330
+ connectionReturn?: ConnectionReturnV1;
264
331
  selectBot(botId: string): Promise<void>;
265
332
  loadBotSettings(): Promise<void>;
266
333
  saveBotProfile(profile: BotProfile): Promise<void>;
@@ -365,6 +432,11 @@ export interface FrockBotWebData {
365
432
  values?: Record<string, JsonValue>,
366
433
  ): Promise<void>;
367
434
  uninstallPackage(packageId: string): Promise<void>;
435
+ /**
436
+ * Puts this conversation down and starts the next one. Memory is kept; only
437
+ * the history the next Turn carries is new (ADR 0027).
438
+ */
439
+ startConversation(): Promise<void>;
368
440
  startConnection(
369
441
  packageId: string,
370
442
  connectionTypeId: string,