@frockbot/plugin-shell 0.3.12 → 0.3.14

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.
@@ -1526,16 +1526,95 @@ describe("active durable Turn projection", () => {
1526
1526
  ],
1527
1527
  );
1528
1528
 
1529
- expect(state.messages[1]?.sends).toEqual([
1529
+ // One message per send, in the order they were sent, and nothing merged:
1530
+ // the last line is the Turn's own, and it carries no send at all.
1531
+ expect(
1532
+ state.messages.slice(1).map((message) => [message.id, message.sends]),
1533
+ ).toEqual([
1534
+ [
1535
+ "run-send:send:0",
1536
+ [{ kind: "payload", payload: { type: "text", text: "On it." } }],
1537
+ ],
1538
+ [
1539
+ "run-send:send:1",
1540
+ [
1541
+ {
1542
+ kind: "payload",
1543
+ payload: {
1544
+ type: "widget",
1545
+ widget: { prompt: "Which day?", options: ["Tue"] },
1546
+ },
1547
+ },
1548
+ ],
1549
+ ],
1550
+ ["run-send:send:2", [{ kind: "unsupported" }]],
1551
+ ["run-send:assistant", []],
1552
+ ]);
1553
+ });
1554
+
1555
+ test("a second send is a new bubble, and never rewrites the first", () => {
1556
+ const state: Pick<
1557
+ FrockBotWebData,
1558
+ "messages" | "activeRunId" | "activeRun"
1559
+ > = { messages: [] };
1560
+ const run = (
1561
+ events: ClientRun["events"],
1562
+ status: ClientRun["status"] = "running",
1563
+ ): ClientRun => ({
1564
+ runId: "run-stack",
1565
+ input: "Book it",
1566
+ admittedAt: "2026-09-04T00:00:00.000Z",
1567
+ status,
1568
+ events,
1569
+ });
1570
+
1571
+ // The acknowledgement arrives first, on its own.
1572
+ projectDurableRuns(state, [], [
1573
+ run([
1574
+ { type: "send/to-user", payload: { type: "text", text: "On it." } },
1575
+ ]),
1576
+ ] as ClientRun[]);
1577
+ const acknowledgement = state.messages[1];
1578
+ expect(acknowledgement?.sends).toEqual([
1530
1579
  { kind: "payload", payload: { type: "text", text: "On it." } },
1531
- {
1532
- kind: "payload",
1533
- payload: {
1534
- type: "widget",
1535
- widget: { prompt: "Which day?", options: ["Tue"] },
1536
- },
1537
- },
1538
- { kind: "unsupported" },
1580
+ ]);
1581
+
1582
+ // The result follows in the same Turn. It appends: the first bubble is
1583
+ // still the first bubble, still saying what it said.
1584
+ projectDurableRuns(state, [], [
1585
+ run(
1586
+ [
1587
+ {
1588
+ type: "send/to-user",
1589
+ payload: { type: "text", text: "On it." },
1590
+ },
1591
+ { type: "send/to-user", payload: { type: "text", text: "Booked." } },
1592
+ ],
1593
+ "completed",
1594
+ ),
1595
+ ] as ClientRun[]);
1596
+
1597
+ expect(
1598
+ state.messages
1599
+ .filter((message) => message.sends.length > 0)
1600
+ .map((message) => [message.id, message.sends]),
1601
+ ).toEqual([
1602
+ [
1603
+ "run-stack:send:0",
1604
+ [{ kind: "payload", payload: { type: "text", text: "On it." } }],
1605
+ ],
1606
+ [
1607
+ "run-stack:send:1",
1608
+ [{ kind: "payload", payload: { type: "text", text: "Booked." } }],
1609
+ ],
1610
+ ]);
1611
+ // The Turn's lines stay together and in order behind the prompt that
1612
+ // started it, so a reload draws the thread that was watched being written.
1613
+ expect(state.messages.map((message) => message.id)).toEqual([
1614
+ "run-stack:user",
1615
+ "run-stack:send:0",
1616
+ "run-stack:send:1",
1617
+ "run-stack:assistant",
1539
1618
  ]);
1540
1619
  });
1541
1620
 
@@ -1561,8 +1640,8 @@ describe("active durable Turn projection", () => {
1561
1640
  ],
1562
1641
  );
1563
1642
 
1564
- expect(state.messages[1]?.text).toBe("");
1565
1643
  expect(state.messages[1]?.sends).toHaveLength(1);
1644
+ expect(state.messages[2]?.text).toBe("");
1566
1645
  });
1567
1646
 
1568
1647
  test("a Turn that sent nothing still draws the model's text", () => {
@@ -1712,8 +1791,8 @@ describe("active durable Turn projection", () => {
1712
1791
  },
1713
1792
  ],
1714
1793
  );
1715
- expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
1716
1794
  expect(state.messages[1]?.sends).toHaveLength(1);
1795
+ expect(state.messages[2]).toMatchObject({ text: "", status: "streaming" });
1717
1796
  });
1718
1797
 
1719
1798
  test("projects reconciliation-required recovery state", () => {
@@ -3215,11 +3294,14 @@ describe("a message sent while a Turn is running", () => {
3215
3294
  expect(state.messages.every((message) => !message.pending)).toBe(true);
3216
3295
  expect(state.activeRunId).toBe("run-2");
3217
3296
  expect(state.runningRunId).toBe("run-2");
3218
- // The superseded Turn keeps the quiet treatment a stopped one gets.
3219
- expect(state.messages[1]).toMatchObject({
3220
- status: "aborted",
3221
- notice: "Interrupted by your next message.",
3222
- });
3297
+ // The superseded Turn is left unlabelled: the message that replaced it is
3298
+ // right underneath, and it says why the Turn ends there better than a
3299
+ // notice of ours would.
3300
+ expect(state.messages[1]).toMatchObject({ status: "aborted" });
3301
+ expect(state.messages[1]?.notice).toBeUndefined();
3302
+ expect(state.messages.some((message) => message.notice !== undefined)).toBe(
3303
+ false,
3304
+ );
3223
3305
  });
3224
3306
 
3225
3307
  test("a reload reconstructs the greyed state from durable runs alone", () => {
@@ -3254,6 +3336,11 @@ describe("a message sent while a Turn is running", () => {
3254
3336
  text: "second",
3255
3337
  pending: true,
3256
3338
  });
3339
+ // The durable record still says why the Turn ended; the transcript does
3340
+ // not repeat it at the reader.
3341
+ expect(
3342
+ reloaded.messages.some((message) => message.notice !== undefined),
3343
+ ).toBe(false);
3257
3344
  expect(reloaded.runningRunId).toBeUndefined();
3258
3345
  expect(reloaded.activeRunId).toBe("run-2");
3259
3346
  });
@@ -15,6 +15,7 @@ import {
15
15
  } from "@frockbot/client-core";
16
16
  import { clientSurfaceRegistryKey } from "@frockbot/client-core";
17
17
  import { COMPACTED_ANNOUNCEMENT_TEXT_V1 } from "../compaction.js";
18
+ import { voiceCaptureSupportedV1 } from "./voice-microphone.js";
18
19
  import { readViewerFocusV1, shouldNotifyForBotV1 } from "../focus.js";
19
20
  // Connection mutations use the provider-neutral hosted command contract.
20
21
  import type {
@@ -58,6 +59,7 @@ import {
58
59
  import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
59
60
  import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
60
61
  import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
62
+ import { knownFailureCopyV1 } from "../run-failure-copy.js";
61
63
  import {
62
64
  ClientTurnRefusedErrorV1,
63
65
  type ClientTurnRefusalReasonV1,
@@ -293,13 +295,28 @@ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
293
295
  * The Bot's voice is its sends. When a Turn delivered anything to the User the
294
296
  * model's own assistant text is scratch space and the thread does not draw it
295
297
  * (issue 153): drawing both is how a one-word reply arrived twice, once as the
296
- * model's text and once as the bubble that was actually delivered.
298
+ * model's text and once as the bubble that was actually delivered — the Turn's
299
+ * derived text is the last text send when the model wrote no message of its
300
+ * own (`backend-runner.ts`'s `lastSentTextV1`), so it is literally that copy.
297
301
  *
298
302
  * A running Turn has no `responseText` yet — that is written only at
299
303
  * settlement — so it draws the words it has written so far. They occupy the
300
- * same bubble the settled answer will, and the same send gate applies to
301
- * both: a Turn that has already delivered a bubble streams nothing into a
302
- * second one.
304
+ * same bubble the settled answer will, and that bubble is the Turn's own line,
305
+ * which follows the sends rather than replacing any of them.
306
+ *
307
+ * The gate is absolute, and it has to be. Relaxing it to "suppress only text
308
+ * that duplicates a send" looked safer and was not: the model's *last* step
309
+ * routinely writes something of its own after the step that spoke — the e2e
310
+ * that pins this sends "pong" through the tool and then answers again in text —
311
+ * and comparing the two drew both. Two bubbles for one reply is the exact
312
+ * regression issue 153 named.
313
+ *
314
+ * So a Turn that sent anything is drawn entirely from its sends, and text the
315
+ * model wrote beside them is scratch space. That is what makes the promotion in
316
+ * `promoteAssistantTextToSendV1` the right shape: an acknowledgement reaches
317
+ * the person by *becoming* a send — and under the per-send projection it is
318
+ * then its own bubble, in the order it was journaled — rather than being drawn
319
+ * as text next to one.
303
320
  */
304
321
  function visibleAssistantText(run: ClientRun, fallback = ""): string {
305
322
  if (sendsFrom(run.events).length > 0) return "";
@@ -315,6 +332,33 @@ function isTerminalRun(run: ClientRun): boolean {
315
332
  );
316
333
  }
317
334
 
335
+ /**
336
+ * One message per `send_to_user`, in the order the Bot sent them.
337
+ *
338
+ * A Turn is not one bubble. The Bot acknowledges, works, and reports back,
339
+ * and each of those is a message in the conversation exactly as it would be
340
+ * from a person (issue 153). The order is the durable order of the run's
341
+ * events, so the thread a reload draws is the thread that was watched being
342
+ * written, and a bubble is never edited once it is in the transcript: a later
343
+ * send appends, it does not replace.
344
+ */
345
+ function sendMessages(run: ClientRun): WebChatMessage[] {
346
+ return sendsFrom(run.events).map((send, index) => ({
347
+ id: `${run.runId}:send:${index}`,
348
+ runId: run.runId,
349
+ role: "assistant" as const,
350
+ text: "",
351
+ status: "completed" as const,
352
+ tools: [],
353
+ sends: [send],
354
+ }));
355
+ }
356
+
357
+ /**
358
+ * The Turn's own line: the model's words, why the Turn ended where it did,
359
+ * the tools it ran and the subagents it dispatched. It closes the run, under
360
+ * whatever the Turn had already sent.
361
+ */
318
362
  function assistantMessage(
319
363
  run: ClientRun,
320
364
  notification: ClientNotificationIntent | undefined,
@@ -332,22 +376,23 @@ function assistantMessage(
332
376
  // message is the whole of what the thread says about it.
333
377
  ...(run.queued ? { pending: true } : {}),
334
378
  tools: toolsFrom(run.events),
335
- sends: sendsFrom(run.events),
379
+ sends: [],
336
380
  tasks: tasksFrom(run.events),
337
381
  };
338
382
  }
339
383
  if (run.status === "superseded") {
340
- // The same quiet treatment a stopped Turn gets. It keeps everything it
341
- // already sent; the line only says why it ends where it does.
384
+ // Quieter than a stopped Turn: it keeps everything it already sent and
385
+ // carries no notice at all. The message that superseded it is sitting
386
+ // right underneath, in the person's own words, and it explains the ending
387
+ // better than a line of ours would (ADR 0024).
342
388
  return {
343
389
  id: `${run.runId}:assistant`,
344
390
  runId: run.runId,
345
391
  role: "assistant",
346
392
  text: visibleAssistantText(run),
347
- notice: "Interrupted by your next message.",
348
393
  status: "aborted",
349
394
  tools: toolsFrom(run.events),
350
- sends: sendsFrom(run.events),
395
+ sends: [],
351
396
  tasks: tasksFrom(run.events),
352
397
  };
353
398
  }
@@ -367,7 +412,7 @@ function assistantMessage(
367
412
  notice: "This reply stopped partway. Try again to continue it.",
368
413
  status: "reconciliation-required",
369
414
  tools: toolsFrom(run.events),
370
- sends: sendsFrom(run.events),
415
+ sends: [],
371
416
  tasks: tasksFrom(run.events),
372
417
  };
373
418
  }
@@ -380,7 +425,7 @@ function assistantMessage(
380
425
  notice: "You stopped this.",
381
426
  status: "aborted",
382
427
  tools: toolsFrom(run.events),
383
- sends: sendsFrom(run.events),
428
+ sends: [],
384
429
  tasks: tasksFrom(run.events),
385
430
  };
386
431
  }
@@ -394,14 +439,18 @@ function assistantMessage(
394
439
  runId: run.runId,
395
440
  role: "assistant",
396
441
  text: run.responseText,
397
- // The same sentence the reply-less failure gets. The durable failure
398
- // text is a provider's, not the product's `Bot turn ended with
399
- // outcome model-error`, a status code, once a run UUID and under a
400
- // bubble it reads as part of what the Bot was saying.
401
- notice: "This Bot couldn't finish its reply. Try again.",
442
+ // The durable failure text is a provider's, not the product's — `Bot
443
+ // turn ended with outcome model-error`, a status code, once a run UUID —
444
+ // and under a bubble it reads as part of what the Bot was saying. By the
445
+ // time it reaches here it is already the sentence for a person: the
446
+ // projection maps it through `runFailureCopyV1` before it crosses the
447
+ // wire, so this keeps whatever that chose — the model-deadline copy says
448
+ // something the outcome alone cannot — and falls back to the same line a
449
+ // reply-less failure gets.
450
+ notice: knownFailureCopyV1(run.failure),
402
451
  status: "error",
403
452
  tools: toolsFrom(run.events),
404
- sends: sendsFrom(run.events),
453
+ sends: [],
405
454
  tasks: tasksFrom(run.events),
406
455
  };
407
456
  }
@@ -416,11 +465,11 @@ function assistantMessage(
416
465
  // Why the Turn ends there, under whatever it had already said — never as
417
466
  // the bubble's own text, which reads as the Bot saying it.
418
467
  ...(run.status === "failed"
419
- ? { notice: "This Bot couldn't finish its reply. Try again." }
468
+ ? { notice: knownFailureCopyV1(run.failure) }
420
469
  : {}),
421
470
  status: run.status === "failed" ? "error" : "completed",
422
471
  tools: toolsFrom(run.events),
423
- sends: sendsFrom(run.events),
472
+ sends: [],
424
473
  tasks: tasksFrom(run.events),
425
474
  };
426
475
  }
@@ -521,16 +570,12 @@ export function projectDurableRuns(
521
570
  if (userIndex >= 0) state.messages[userIndex] = user;
522
571
  else state.messages.push(user);
523
572
 
524
- const assistantIndex = state.messages.findIndex(
525
- (message) => message.runId === run.runId && message.role === "assistant",
573
+ replaceTurnMessages(
574
+ state.messages,
575
+ run.runId,
576
+ [...sendMessages(run), assistantMessage(run, notification)],
577
+ run.admittedAt,
526
578
  );
527
- const assistant = assistantMessage(run, notification);
528
- const assistantAt =
529
- run.admittedAt ??
530
- (assistantIndex >= 0 ? state.messages[assistantIndex]?.at : undefined);
531
- if (assistantAt) assistant.at = assistantAt;
532
- if (assistantIndex >= 0) state.messages[assistantIndex] = assistant;
533
- else state.messages.push(assistant);
534
579
 
535
580
  activeRun = activeRunView(run) ?? activeRun;
536
581
  if (run.status === "running" || run.status === "reconciliation-required") {
@@ -1373,6 +1418,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1373
1418
  modelSource: "none",
1374
1419
  settingsAvailable: true,
1375
1420
  connectionsAvailable: ctx.transport.connectionsAvailable !== false,
1421
+ voiceAvailable:
1422
+ typeof ctx.transport.openVoiceDictation === "function" &&
1423
+ voiceCaptureSupportedV1(),
1376
1424
  activeBotId: undefined,
1377
1425
  composerContext: undefined,
1378
1426
  transcripts: {
@@ -2638,19 +2686,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2638
2686
  message.runId = result.runId;
2639
2687
  message.id = `${result.runId}:${message.role}`;
2640
2688
  }
2641
- replaceMessage(web.value.messages, result.runId, {
2642
- id: `${result.runId}:assistant`,
2689
+ // Projected exactly as the durable read projects it, from the same
2690
+ // events: one bubble per send, in order, and the model's own text
2691
+ // after them (issue 153). The POST's copy of a Turn and the transcript
2692
+ // read must draw the same thread, or the reply rearranges itself on
2693
+ // the next reload.
2694
+ const settled: ClientRun = {
2643
2695
  runId: result.runId,
2644
- role: "assistant",
2645
- // The same rule the durable projection follows: a Turn that
2646
- // delivered something speaks through its sends, not through the
2647
- // model's own text (issue 153).
2648
- text: sendsFrom(result.events).length > 0 ? "" : result.text,
2649
- at: optimisticAt,
2696
+ input: text,
2650
2697
  status: "completed",
2651
- tools: toolsFrom(result.events),
2652
- sends: sendsFrom(result.events),
2653
- });
2698
+ responseText: result.text,
2699
+ events: result.events,
2700
+ };
2701
+ replaceTurnMessages(
2702
+ web.value.messages,
2703
+ result.runId,
2704
+ [...sendMessages(settled), assistantMessage(settled, undefined)],
2705
+ optimisticAt,
2706
+ );
2654
2707
  try {
2655
2708
  await deliverNotifications(botId, generation);
2656
2709
  } catch (error) {
@@ -2709,16 +2762,23 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2709
2762
  message.runId === pendingRunId && message.role === "user",
2710
2763
  )?.at ?? optimisticAt,
2711
2764
  );
2712
- replaceMessage(web.value.messages, pendingRunId, {
2713
- id: `${pendingRunId}:assistant`,
2714
- runId: pendingRunId,
2715
- role: "assistant",
2716
- text: "Checking whether your message went through…",
2717
- at: placeholderAt,
2718
- status: "interrupted",
2719
- tools: [],
2720
- sends: [],
2721
- });
2765
+ replaceTurnMessages(
2766
+ web.value.messages,
2767
+ pendingRunId,
2768
+ [
2769
+ {
2770
+ id: `${pendingRunId}:assistant`,
2771
+ runId: pendingRunId,
2772
+ role: "assistant",
2773
+ text: "Checking whether your message went through…",
2774
+ at: placeholderAt,
2775
+ status: "interrupted",
2776
+ tools: [],
2777
+ sends: [],
2778
+ },
2779
+ ],
2780
+ placeholderAt,
2781
+ );
2722
2782
  if (aborted) {
2723
2783
  web.value.error = undefined;
2724
2784
  } else {
@@ -2744,17 +2804,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2744
2804
  // have to improvise — and with the Turn no longer running, so Stop
2745
2805
  // stops standing for a Turn nobody is executing.
2746
2806
  if (disposition === "unreachable") {
2747
- replaceMessage(web.value.messages, pendingRunId, {
2748
- id: `${pendingRunId}:assistant`,
2749
- runId: pendingRunId,
2750
- role: "assistant",
2751
- text: UNREACHABLE_BOT_MESSAGE_V1,
2752
- at: placeholderAt,
2753
- status: "error",
2754
- retry: "resend",
2755
- tools: [],
2756
- sends: [],
2757
- });
2807
+ replaceTurnMessages(
2808
+ web.value.messages,
2809
+ pendingRunId,
2810
+ [
2811
+ {
2812
+ id: `${pendingRunId}:assistant`,
2813
+ runId: pendingRunId,
2814
+ role: "assistant",
2815
+ text: UNREACHABLE_BOT_MESSAGE_V1,
2816
+ at: placeholderAt,
2817
+ status: "error",
2818
+ retry: "resend",
2819
+ tools: [],
2820
+ sends: [],
2821
+ },
2822
+ ],
2823
+ placeholderAt,
2824
+ );
2758
2825
  // The bubble is the report, and it is the one carrying the Retry.
2759
2826
  // Saying the same sentence again in the banner above it is what the
2760
2827
  // thread already looked like when it was broken — the same string
@@ -2774,19 +2841,26 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2774
2841
  // retry, and one system line says so.
2775
2842
  const notAdmitted =
2776
2843
  "Your message didn't go through. Try sending it again.";
2777
- replaceMessage(web.value.messages, pendingRunId, {
2778
- id: `${pendingRunId}:assistant`,
2779
- runId: pendingRunId,
2780
- role: "system",
2781
- text: notAdmitted,
2782
- at: placeholderAt,
2783
- status: "error",
2784
- // The same affordance an unreachable send gets: the draft is back
2785
- // in the composer, and this sends it again.
2786
- retry: "resend",
2787
- tools: [],
2788
- sends: [],
2789
- });
2844
+ replaceTurnMessages(
2845
+ web.value.messages,
2846
+ pendingRunId,
2847
+ [
2848
+ {
2849
+ id: `${pendingRunId}:assistant`,
2850
+ runId: pendingRunId,
2851
+ role: "system",
2852
+ text: notAdmitted,
2853
+ at: placeholderAt,
2854
+ status: "error",
2855
+ // The same affordance an unreachable send gets: the draft is
2856
+ // back in the composer, and this sends it again.
2857
+ retry: "resend",
2858
+ tools: [],
2859
+ sends: [],
2860
+ },
2861
+ ],
2862
+ placeholderAt,
2863
+ );
2790
2864
  return { accepted: false, error: notAdmitted };
2791
2865
  }
2792
2866
  return { accepted: true, runId: pendingRunId };
@@ -2808,6 +2882,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2808
2882
  }
2809
2883
  }
2810
2884
  },
2885
+ openVoiceDictation(observer) {
2886
+ return ctx.transport.openVoiceDictation?.(observer);
2887
+ },
2811
2888
  async resumeRun(runId: string): Promise<void> {
2812
2889
  if (!ctx.transport.reconcileRun) {
2813
2890
  web.value.settingsError = "Can't retry this right now.";
@@ -3081,15 +3158,46 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
3081
3158
  ];
3082
3159
  };
3083
3160
 
3084
- function replaceMessage(
3161
+ /**
3162
+ * Puts a Turn's Bot-side lines in the thread, in order, in one place.
3163
+ *
3164
+ * A Turn is one user message and then however many the Bot sent, so a merge
3165
+ * cannot key one bubble by run id and overwrite it — that is exactly how a
3166
+ * second `send_to_user` used to replace the first. Every Bot-side line the
3167
+ * run already has is lifted out and the new ones go back at the same
3168
+ * position, which keeps the sends in their durable order and keeps the whole
3169
+ * Turn together between the Turn before it and the Turn after it.
3170
+ *
3171
+ * Where a line already carried a timestamp and the caller offers none, the
3172
+ * one it had is kept: the thread sorts by time and a line must not jump.
3173
+ */
3174
+ function replaceTurnMessages(
3085
3175
  messages: WebChatMessage[],
3086
3176
  runId: string,
3087
- replacement: WebChatMessage,
3177
+ replacements: WebChatMessage[],
3178
+ at?: string,
3088
3179
  ): void {
3089
- const index = messages.findIndex(
3090
- (message) => message.runId === runId && message.role === "assistant",
3091
- );
3092
- if (index >= 0) messages[index] = replacement;
3180
+ let start = -1;
3181
+ let existingAt: string | undefined;
3182
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
3183
+ const message = messages[index];
3184
+ if (!message || message.runId !== runId || message.role !== "assistant")
3185
+ continue;
3186
+ existingAt = message.at ?? existingAt;
3187
+ start = index;
3188
+ messages.splice(index, 1);
3189
+ }
3190
+ if (start < 0) {
3191
+ // No Bot-side line yet: it belongs directly after this Turn's prompt, so
3192
+ // an older Turn's reply can never come between them.
3193
+ const userIndex = messages.findIndex(
3194
+ (message) => message.runId === runId && message.role === "user",
3195
+ );
3196
+ start = userIndex >= 0 ? userIndex + 1 : messages.length;
3197
+ }
3198
+ const stamp = at ?? existingAt;
3199
+ if (stamp) for (const message of replacements) message.at = stamp;
3200
+ messages.splice(start, 0, ...replacements);
3093
3201
  }
3094
3202
 
3095
3203
  /** Takes back both optimistic lines of a send the Bot never admitted. */