@frockbot/plugin-shell 0.3.2 → 0.3.4

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.
@@ -416,6 +416,46 @@ describe("client run protocol v1", () => {
416
416
  }),
417
417
  ).toThrow("turn command.schemaVersion is invalid");
418
418
 
419
+ // Supersede intent is carried by the field's presence. The composer sends
420
+ // it on every send, and names a run only when it observed one.
421
+ expect(
422
+ decodeClientTurnCommandV1({
423
+ schemaVersion: 1,
424
+ commandId: "command-1",
425
+ text: "hello",
426
+ supersedes: {},
427
+ }),
428
+ ).toEqual({
429
+ schemaVersion: 1,
430
+ commandId: "command-1",
431
+ text: "hello",
432
+ supersedes: {},
433
+ });
434
+ expect(
435
+ decodeClientTurnCommandV1({
436
+ schemaVersion: 1,
437
+ commandId: "command-1",
438
+ text: "hello",
439
+ supersedes: { runId: "run-1" },
440
+ }).supersedes,
441
+ ).toEqual({ runId: "run-1" });
442
+ expect(() =>
443
+ decodeClientTurnCommandV1({
444
+ schemaVersion: 1,
445
+ commandId: "command-1",
446
+ text: "hello",
447
+ supersedes: { runId: "not a run id" },
448
+ }),
449
+ ).toThrow("turn command.supersedes.runId is invalid");
450
+ expect(() =>
451
+ decodeClientTurnCommandV1({
452
+ schemaVersion: 1,
453
+ commandId: "command-1",
454
+ text: "hello",
455
+ supersedes: { runId: "run-1", extra: 1 },
456
+ }),
457
+ ).toThrow();
458
+
419
459
  expect(
420
460
  decodeClientNotificationAcknowledgementCommandV1({
421
461
  schemaVersion: 1,
@@ -571,6 +611,58 @@ describe("client run protocol v1", () => {
571
611
  }
572
612
  });
573
613
 
614
+ test("projects dynamic call identity so the client can show the inner tool", () => {
615
+ const projected = projectClientTurnV1({
616
+ runId: "run-dynamic-tool",
617
+ text: "",
618
+ events: [
619
+ event({
620
+ type: "tool/call",
621
+ seq: 0,
622
+ timestamp,
623
+ turn: 1,
624
+ step: 1,
625
+ occurrenceId: "tool:1:1:0",
626
+ name: "call_dynamic_tool",
627
+ input: {
628
+ namespace: "user-Github--acme",
629
+ toolName: "search_issues",
630
+ arguments: { query: "is:open" },
631
+ mcpDetails: { description: "Find open issues." },
632
+ },
633
+ }),
634
+ event({
635
+ type: "tool/result",
636
+ seq: 1,
637
+ timestamp,
638
+ turn: 1,
639
+ step: 1,
640
+ occurrenceId: "tool:1:1:0",
641
+ name: "call_dynamic_tool",
642
+ content: "[]",
643
+ isError: false,
644
+ status: "completed",
645
+ }),
646
+ ],
647
+ });
648
+
649
+ expect(projected.events[0]).toEqual({
650
+ type: "tool/call",
651
+ call: {
652
+ id: "tool-1",
653
+ name: "call_dynamic_tool",
654
+ input: {
655
+ namespace: "user-Github--acme",
656
+ toolName: "search_issues",
657
+ argumentsJson: '{"query":"is:open"}',
658
+ },
659
+ },
660
+ });
661
+ expect(decodeClientTurnV1(structuredClone(projected)).events[0]).toEqual(
662
+ projected.events[0],
663
+ );
664
+ });
665
+
574
666
  test("projects only bounded user-visible run state", () => {
575
667
  const stored = {
576
668
  runId: "run-1",
@@ -42,9 +42,15 @@ export const CLIENT_RUN_PAGE_LIMIT = 32;
42
42
  export const CLIENT_RUN_LIST_MAX_BYTES = 512_000;
43
43
 
44
44
  export type ClientRunStatusV1 =
45
- "running" | "completed" | "failed" | "cancelled" | "reconciliation-required";
45
+ | "running"
46
+ | "completed"
47
+ | "failed"
48
+ | "cancelled"
49
+ | "superseded"
50
+ | "reconciliation-required";
46
51
 
47
52
  const CANCELLED_RUN_MESSAGE = "Stopped by an authenticated Stop command.";
53
+ const SUPERSEDED_RUN_MESSAGE = "Interrupted by your next message.";
48
54
 
49
55
  export type ClientRunEventV1 =
50
56
  | {
@@ -53,7 +59,11 @@ export type ClientRunEventV1 =
53
59
  }
54
60
  | {
55
61
  type: "tool/call";
56
- call: { id: string; name: string };
62
+ call: {
63
+ id: string;
64
+ name: string;
65
+ input?: ClientDynamicToolCallInputV1;
66
+ };
57
67
  }
58
68
  | {
59
69
  type: "tool/result";
@@ -102,10 +112,19 @@ export type ClientRunEventV1 =
102
112
  background: boolean;
103
113
  };
104
114
 
115
+ /** The bounded, public identity needed to present a dynamic tool call. */
116
+ export interface ClientDynamicToolCallInputV1 {
117
+ namespace: string;
118
+ toolName: string;
119
+ /** JSON keeps this cross-runtime DTO shallow while preserving tool input. */
120
+ argumentsJson?: string;
121
+ }
122
+
105
123
  export type ClientRunOutcomeV1 =
106
124
  | { type: "completed"; text: string }
107
125
  | { type: "failed"; message: string }
108
- | { type: "cancelled"; message: string };
126
+ | { type: "cancelled"; message: string }
127
+ | { type: "superseded"; message: string };
109
128
 
110
129
  export interface ClientRunRecoveryV1 {
111
130
  action: "resume";
@@ -126,6 +145,12 @@ export interface ClientRunV1 {
126
145
  events: ClientRunEventV1[];
127
146
  /** Durable Stop intent, projected independently of the run status. */
128
147
  stopRequestedAt?: string;
148
+ /**
149
+ * True while the Turn is admitted and waiting rather than running. The
150
+ * thread draws it as an ordinary message the Bot has not reached yet, and
151
+ * the flag is durable state, so a reload draws the same thing.
152
+ */
153
+ queued?: true;
129
154
  outcome?: ClientRunOutcomeV1;
130
155
  recovery?: ClientRunRecoveryV1;
131
156
  }
@@ -175,6 +200,20 @@ export interface ClientTurnCommandV1 {
175
200
  * by pretending to invoke one.
176
201
  */
177
202
  skills?: SkillRefV1[];
203
+ /**
204
+ * The explicit authenticated intent to replace whatever the Bot is doing
205
+ * with this message. Without it a second command is refused exactly as it
206
+ * always was, so a reconnecting client never interrupts a Turn by accident.
207
+ *
208
+ * `runId` is provenance, not the target, and it is optional. The composer
209
+ * sends this intent on every send, because "replace what you are doing with
210
+ * this" is what a person means by typing — and whether the client had yet
211
+ * *observed* a run when they pressed send is a race, not a decision they
212
+ * made. A composer that names no run still supersedes whatever is actually
213
+ * active; one that names a run may name a stale one, and the Bot Durable
214
+ * Object supersedes the active Turn either way.
215
+ */
216
+ supersedes?: { runId?: string };
178
217
  }
179
218
 
180
219
  export interface ClientNotificationAcknowledgementCommandV1 {
@@ -282,7 +321,10 @@ function publicEventId(value: string, label: string): string {
282
321
 
283
322
  function isTerminalRunStatus(status: ClientRunStatusV1): boolean {
284
323
  return (
285
- status === "completed" || status === "failed" || status === "cancelled"
324
+ status === "completed" ||
325
+ status === "failed" ||
326
+ status === "cancelled" ||
327
+ status === "superseded"
286
328
  );
287
329
  }
288
330
 
@@ -313,6 +355,67 @@ interface ProjectionUnitV1 {
313
355
  droppable: boolean;
314
356
  }
315
357
 
358
+ function dynamicToolCallInput(
359
+ value: unknown,
360
+ ): ClientDynamicToolCallInputV1 | undefined {
361
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
362
+ return undefined;
363
+ }
364
+ const input = value as Record<string, unknown>;
365
+ if (
366
+ typeof input.namespace !== "string" ||
367
+ typeof input.toolName !== "string"
368
+ ) {
369
+ return undefined;
370
+ }
371
+ const argumentsJson = Object.hasOwn(input, "arguments")
372
+ ? JSON.stringify(input.arguments)
373
+ : undefined;
374
+ return {
375
+ namespace: truncateWireString(input.namespace, MAX_EVENT_NAME_BYTES),
376
+ toolName: truncateWireString(input.toolName, MAX_EVENT_NAME_BYTES),
377
+ ...(argumentsJson !== undefined &&
378
+ wireBytes(argumentsJson) <= MAX_INPUT_BYTES
379
+ ? { argumentsJson }
380
+ : {}),
381
+ };
382
+ }
383
+
384
+ function decodeDynamicToolCallInput(
385
+ value: unknown,
386
+ ): ClientDynamicToolCallInputV1 {
387
+ const input = record(value, "run event.call.input");
388
+ exactKeys(
389
+ input,
390
+ ["namespace", "toolName", "argumentsJson"],
391
+ "run event.call.input",
392
+ );
393
+ return {
394
+ namespace: wireString(
395
+ input,
396
+ "namespace",
397
+ MAX_EVENT_NAME_BYTES,
398
+ "run event.call.input",
399
+ ),
400
+ toolName: wireString(
401
+ input,
402
+ "toolName",
403
+ MAX_EVENT_NAME_BYTES,
404
+ "run event.call.input",
405
+ ),
406
+ ...(Object.hasOwn(input, "argumentsJson")
407
+ ? {
408
+ argumentsJson: wireString(
409
+ input,
410
+ "argumentsJson",
411
+ MAX_INPUT_BYTES,
412
+ "run event.call.input",
413
+ ),
414
+ }
415
+ : {}),
416
+ };
417
+ }
418
+
316
419
  function projectionUnits(
317
420
  events: readonly SessionEvent[],
318
421
  status: ClientRunStatusV1,
@@ -328,11 +431,16 @@ function projectionUnits(
328
431
  );
329
432
  }
330
433
  callCount += 1;
434
+ const dynamicInput =
435
+ event.name === "call_dynamic_tool"
436
+ ? dynamicToolCallInput(event.input)
437
+ : undefined;
331
438
  const call: ClientToolCallV1 = {
332
439
  type: "tool/call",
333
440
  call: {
334
441
  id: `tool-${callCount}`,
335
442
  name: truncateWireString(event.name, MAX_EVENT_NAME_BYTES),
443
+ ...(dynamicInput ? { input: dynamicInput } : {}),
336
444
  },
337
445
  };
338
446
  const unit: ProjectionUnitV1 = { events: [call], droppable: false };
@@ -482,7 +590,12 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
482
590
  type: "cancelled",
483
591
  message: CANCELLED_RUN_MESSAGE,
484
592
  } satisfies ClientRunOutcomeV1)
485
- : undefined;
593
+ : status === "superseded"
594
+ ? ({
595
+ type: "superseded",
596
+ message: SUPERSEDED_RUN_MESSAGE,
597
+ } satisfies ClientRunOutcomeV1)
598
+ : undefined;
486
599
  const recovery =
487
600
  status === "reconciliation-required"
488
601
  ? ({
@@ -508,6 +621,9 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
508
621
  stopRequestedAt: truncate(run.stopRequestedAt, MAX_TIMESTAMP_LENGTH),
509
622
  }
510
623
  : {}),
624
+ ...(status === "running" && run.phase === "queued"
625
+ ? { queued: true as const }
626
+ : {}),
511
627
  ...(outcome ? { outcome } : {}),
512
628
  ...(recovery ? { recovery } : {}),
513
629
  };
@@ -516,9 +632,7 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
516
632
  function lookupState(
517
633
  status: ClientRunStatusV1,
518
634
  ): Exclude<ClientRunLookupStateV1, "not-admitted"> {
519
- if (status === "completed" || status === "failed" || status === "cancelled") {
520
- return "terminal";
521
- }
635
+ if (isTerminalRunStatus(status)) return "terminal";
522
636
  if (status === "reconciliation-required") {
523
637
  return "reconciliation-required";
524
638
  }
@@ -687,6 +801,7 @@ function status(value: unknown): ClientRunStatusV1 {
687
801
  value !== "completed" &&
688
802
  value !== "failed" &&
689
803
  value !== "cancelled" &&
804
+ value !== "superseded" &&
690
805
  value !== "reconciliation-required"
691
806
  ) {
692
807
  throw new Error("run.status is invalid");
@@ -714,7 +829,21 @@ function decodeEvent(value: unknown): ClientRunEventV1 {
714
829
  if (event.type === "tool/call") {
715
830
  exactKeys(event, ["type", "call"], "run event");
716
831
  const call = record(event.call, "run event.call");
717
- exactKeys(call, ["id", "name"], "run event.call");
832
+ exactKeys(call, ["id", "name", "input"], "run event.call");
833
+ const name = wireString(
834
+ call,
835
+ "name",
836
+ MAX_EVENT_NAME_BYTES,
837
+ "run event.call",
838
+ );
839
+ const input = Object.hasOwn(call, "input")
840
+ ? decodeDynamicToolCallInput(call.input)
841
+ : undefined;
842
+ if (input && name !== "call_dynamic_tool") {
843
+ throw new Error(
844
+ "run event.call.input is valid only for a dynamic tool call",
845
+ );
846
+ }
718
847
  return {
719
848
  type: "tool/call",
720
849
  call: {
@@ -722,7 +851,8 @@ function decodeEvent(value: unknown): ClientRunEventV1 {
722
851
  string(call, "id", MAX_EVENT_ID_LENGTH, "run event.call"),
723
852
  "run event.call.id",
724
853
  ),
725
- name: wireString(call, "name", MAX_EVENT_NAME_BYTES, "run event.call"),
854
+ name,
855
+ ...(input ? { input } : {}),
726
856
  },
727
857
  };
728
858
  }
@@ -880,6 +1010,13 @@ function decodeOutcome(
880
1010
  message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
881
1011
  };
882
1012
  }
1013
+ if (outcome.type === "superseded" && runStatus === "superseded") {
1014
+ exactKeys(outcome, ["type", "message"], "run.outcome");
1015
+ return {
1016
+ type: "superseded",
1017
+ message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
1018
+ };
1019
+ }
883
1020
  throw new Error("run.outcome does not match run.status");
884
1021
  }
885
1022
 
@@ -919,6 +1056,7 @@ function decodeRun(value: unknown): ClientRun {
919
1056
  "status",
920
1057
  "events",
921
1058
  "stopRequestedAt",
1059
+ "queued",
922
1060
  "outcome",
923
1061
  "recovery",
924
1062
  ],
@@ -960,6 +1098,12 @@ function decodeRun(value: unknown): ClientRun {
960
1098
  if (runStatus === "cancelled" && stopRequestedAt === undefined) {
961
1099
  throw new Error("cancelled run.stopRequestedAt is required");
962
1100
  }
1101
+ if (run.queued !== undefined && run.queued !== true) {
1102
+ throw new Error("run.queued is invalid");
1103
+ }
1104
+ if (run.queued === true && runStatus !== "running") {
1105
+ throw new Error("only a running run may be queued");
1106
+ }
963
1107
  return {
964
1108
  runId,
965
1109
  admittedAt,
@@ -967,9 +1111,11 @@ function decodeRun(value: unknown): ClientRun {
967
1111
  status: runStatus,
968
1112
  events: decodeEvents(run.events, runStatus),
969
1113
  ...(stopRequestedAt ? { stopRequestedAt } : {}),
1114
+ ...(run.queued === true ? { queued: true as const } : {}),
970
1115
  ...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
971
1116
  ...(outcome?.type === "failed" ? { failure: outcome.message } : {}),
972
1117
  ...(outcome?.type === "cancelled" ? { failure: outcome.message } : {}),
1118
+ ...(outcome?.type === "superseded" ? { failure: outcome.message } : {}),
973
1119
  ...(recovery ? { failure: recovery.message, recovery } : {}),
974
1120
  };
975
1121
  }
@@ -1109,7 +1255,7 @@ export function decodeClientTurnCommandV1(input: unknown): ClientTurnCommandV1 {
1109
1255
  const command = record(input, "turn command");
1110
1256
  exactKeys(
1111
1257
  command,
1112
- ["schemaVersion", "commandId", "text", "skills"],
1258
+ ["schemaVersion", "commandId", "text", "skills", "supersedes"],
1113
1259
  "turn command",
1114
1260
  );
1115
1261
  if (command.schemaVersion !== 1) {
@@ -1131,12 +1277,42 @@ export function decodeClientTurnCommandV1(input: unknown): ClientTurnCommandV1 {
1131
1277
  "turn command",
1132
1278
  ).trim();
1133
1279
  if (!text) throw new Error("turn command.text is required");
1134
- if (command.skills === undefined)
1135
- return { schemaVersion: 1, commandId, text };
1136
- const skills = decodeSkillRefsV1(command.skills, "turn command.skills");
1137
- return skills.length > 0
1138
- ? { schemaVersion: 1, commandId, text, skills }
1139
- : { schemaVersion: 1, commandId, text };
1280
+ let supersedes: { runId?: string } | undefined;
1281
+ if (command.supersedes !== undefined) {
1282
+ const named = record(command.supersedes, "turn command.supersedes");
1283
+ exactKeys(named, ["runId"], "turn command.supersedes");
1284
+ if (named.runId === undefined) {
1285
+ // Intent with no provenance: the composer sent while it had observed no
1286
+ // running Turn. It still means "replace whatever you are doing".
1287
+ supersedes = {};
1288
+ } else {
1289
+ try {
1290
+ supersedes = {
1291
+ runId: decodeRunIdV1(
1292
+ string(
1293
+ named,
1294
+ "runId",
1295
+ MAX_RUN_ID_LENGTH,
1296
+ "turn command.supersedes",
1297
+ ),
1298
+ ),
1299
+ };
1300
+ } catch {
1301
+ throw new Error("turn command.supersedes.runId is invalid");
1302
+ }
1303
+ }
1304
+ }
1305
+ const skills =
1306
+ command.skills === undefined
1307
+ ? []
1308
+ : decodeSkillRefsV1(command.skills, "turn command.skills");
1309
+ return {
1310
+ schemaVersion: 1,
1311
+ commandId,
1312
+ text,
1313
+ ...(skills.length > 0 ? { skills } : {}),
1314
+ ...(supersedes ? { supersedes } : {}),
1315
+ };
1140
1316
  }
1141
1317
 
1142
1318
  export function decodeClientNotificationAcknowledgementCommandV1(
package/src/shared.ts CHANGED
@@ -45,6 +45,7 @@ export interface WebToolAttachment {
45
45
  export interface WebToolActivity {
46
46
  id: string;
47
47
  name: string;
48
+ input?: unknown;
48
49
  status: "running" | "completed" | "failed";
49
50
  text?: string;
50
51
  attachments?: WebToolAttachment[];
@@ -93,6 +94,13 @@ export interface WebChatMessage {
93
94
  | "error"
94
95
  | "interrupted"
95
96
  | "reconciliation-required";
97
+ /**
98
+ * True while this line's Turn is admitted but has not started, because the
99
+ * User sent it while the Bot was still on the previous one. The thread
100
+ * greys it and nothing else: it is an ordinary message the Bot has not
101
+ * reached, not a state the User has to understand.
102
+ */
103
+ pending?: boolean;
96
104
  tools: WebToolActivity[];
97
105
  /** The typed payloads this Turn sent to the user, oldest first. */
98
106
  sends: WebSendPayload[];
@@ -161,7 +169,17 @@ export interface FrockBotWebData {
161
169
  activeBotId?: string;
162
170
  composerContext?: unknown;
163
171
  messages: WebChatMessage[];
172
+ /**
173
+ * The newest Turn that has not settled — running, or admitted and waiting.
174
+ * A message sent now supersedes this one.
175
+ */
164
176
  activeRunId?: string;
177
+ /**
178
+ * The Turn actually executing, which is what Stop targets. It differs from
179
+ * `activeRunId` only while a message the User sent mid-Turn is waiting: Stop
180
+ * cancels what the Bot is doing and never discards what they just sent.
181
+ */
182
+ runningRunId?: string;
165
183
  activeRun?: WebActiveRun;
166
184
  error?: string;
167
185
  botSettings?: BotSettingsViewV1;
@@ -6,7 +6,10 @@
6
6
  // records where the Turn earned one — a firing with two inbox entries — and
7
7
  // nobody would notice until they counted.
8
8
  import { describe, expect, test } from "bun:test";
9
- import { shellTerminalRecordsV1 } from "./terminal-records.js";
9
+ import {
10
+ shellTerminalRecordsV1,
11
+ supersededTurnRecordsV1,
12
+ } from "./terminal-records.js";
10
13
  import { SIDEBAR_PREVIEW_KEY, UNREAD_STATE_KEY } from "./unread.js";
11
14
  import { approvalKeyV1, decodeApprovalRecordV1 } from "./approvals.js";
12
15
  import { decodeRoutineInboxEntryV1 } from "@frockbot/plugin-routines/inbox";
@@ -215,3 +218,51 @@ describe("the settling transaction's records", () => {
215
218
  );
216
219
  });
217
220
  });
221
+
222
+ describe("what a superseded Turn leaves for the Turn that replaced it", () => {
223
+ const run = {
224
+ runId: "run-1",
225
+ sessionId: "user-1:primary",
226
+ acceptedAt: "2026-09-03T00:00:00.000Z",
227
+ input: "first",
228
+ events: [] as { type: string }[],
229
+ };
230
+ const now = "2026-09-03T00:00:05.000Z";
231
+ const read = <T>(): Promise<T | undefined> => Promise.resolve(undefined);
232
+
233
+ test("one durable input, keyed by the Turn it replaced", async () => {
234
+ const records = await supersededTurnRecordsV1({ run, now, read });
235
+
236
+ const values = Object.values(records);
237
+ expect(values).toHaveLength(2);
238
+ expect(values).toContainEqual({
239
+ schemaVersion: 1,
240
+ kind: "superseded-turn",
241
+ runId: "run-1",
242
+ unfinishedWork: false,
243
+ createdAt: now,
244
+ });
245
+ });
246
+
247
+ test("a Turn that dispatched a subagent says so, because it is still running", async () => {
248
+ const records = await supersededTurnRecordsV1({
249
+ run: { ...run, events: [{ type: "task/dispatched" }] },
250
+ now,
251
+ read,
252
+ });
253
+
254
+ expect(Object.values(records)).toContainEqual(
255
+ expect.objectContaining({ unfinishedWork: true }),
256
+ );
257
+ });
258
+
259
+ test("an automation Turn contributes nothing: a firing is not the conversation", async () => {
260
+ expect(
261
+ await supersededTurnRecordsV1({
262
+ run: { ...run, admission: { turnType: "automation" } },
263
+ now,
264
+ read,
265
+ }),
266
+ ).toEqual({});
267
+ });
268
+ });
@@ -30,6 +30,8 @@ import {
30
30
  SIDEBAR_PREVIEW_KEY,
31
31
  UNREAD_STATE_KEY,
32
32
  } from "./unread.js";
33
+ import { enqueuePendingBotInputV1 } from "@frockbot/plugin-routines/inbox-store";
34
+ import type { PendingBotInputV1 } from "@frockbot/plugin-routines/inbox";
33
35
  import { approvalTerminalRecordsV1 } from "./approvals.js";
34
36
  import { routineTerminalRecordsForRunV1 } from "./backend-routines.js";
35
37
 
@@ -132,6 +134,52 @@ const SHELL_TERMINAL_PRODUCERS_V1 = [
132
134
  approvalRecordsV1,
133
135
  ] as const;
134
136
 
137
+ /**
138
+ * What a Turn the User's next message replaced leaves behind.
139
+ *
140
+ * One durable input, drained once by the next conversational Turn. The session
141
+ * log already carries what the Turn sent and what its tools returned; this is
142
+ * the part that is *not* in the log — that it was cut off, that nothing still
143
+ * in flight completed, and that a subagent it dispatched is still working.
144
+ * Background work survives a supersede, so the reminder is how the Bot learns
145
+ * that an answer is still coming rather than losing track of it.
146
+ *
147
+ * An automation Turn contributes nothing: a firing is not the conversation,
148
+ * and it reaches the User through its own inbox entry.
149
+ */
150
+ export async function supersededTurnRecordsV1(input: {
151
+ run: ShellTerminalRunV1;
152
+ now: string;
153
+ read<T>(key: string): Promise<T | undefined>;
154
+ }): Promise<Record<string, unknown>> {
155
+ if ((input.run.admission?.turnType ?? "chat") !== "chat") return {};
156
+ const pending = {
157
+ schemaVersion: 1,
158
+ kind: "superseded-turn",
159
+ runId: input.run.runId,
160
+ unfinishedWork: input.run.events.some(
161
+ (event) => event.type === "task/dispatched",
162
+ ),
163
+ createdAt: input.now,
164
+ } satisfies PendingBotInputV1;
165
+ const records: Record<string, unknown> = {};
166
+ await enqueuePendingBotInputV1(
167
+ {
168
+ get: <T>(key: string) => input.read<T>(key),
169
+ // A settling transaction cannot list. De-duplication is by the input's
170
+ // id, which is this run's, and a run settles once.
171
+ list: <T>() => Promise.resolve(new Map<string, T>()),
172
+ put: (key: string, value: unknown) => {
173
+ records[key] = value;
174
+ return Promise.resolve();
175
+ },
176
+ delete: () => Promise.resolve(false),
177
+ },
178
+ pending,
179
+ );
180
+ return records;
181
+ }
182
+
135
183
  export async function shellTerminalRecordsV1(
136
184
  input: ShellTerminalInputV1,
137
185
  ): Promise<Record<string, unknown>> {