@dbx-tools/ui-mastra 0.3.8 → 0.3.10

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 CHANGED
@@ -26,19 +26,19 @@
26
26
  "shiki": "^3.0.0",
27
27
  "sql-formatter": "^15.6.9",
28
28
  "streamdown": "^2.5.0",
29
- "@dbx-tools/shared-core": "0.3.8",
30
- "@dbx-tools/shared-mastra": "0.3.8",
31
- "@dbx-tools/shared-model": "0.3.8",
32
- "@dbx-tools/ui-appkit": "0.3.8",
33
- "@dbx-tools/shared-genie": "0.3.8",
34
- "@dbx-tools/ui-branding": "0.3.8"
29
+ "@dbx-tools/shared-core": "0.3.10",
30
+ "@dbx-tools/shared-genie": "0.3.10",
31
+ "@dbx-tools/ui-appkit": "0.3.10",
32
+ "@dbx-tools/ui-branding": "0.3.10",
33
+ "@dbx-tools/shared-mastra": "0.3.10",
34
+ "@dbx-tools/shared-model": "0.3.10"
35
35
  },
36
36
  "main": "index.ts",
37
37
  "license": "UNLICENSED",
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "version": "0.3.8",
41
+ "version": "0.3.10",
42
42
  "types": "index.ts",
43
43
  "type": "module",
44
44
  "exports": {
@@ -44,6 +44,39 @@ import type {
44
44
  // the helpers that surface approval-gated tool calls out of a message's
45
45
  // parts.
46
46
 
47
+ /**
48
+ * Copy `text` to the clipboard, resolving `true` on success. Prefers the
49
+ * async Clipboard API, but that is only available in a secure context
50
+ * (HTTPS / localhost) - over plain HTTP (e.g. a LAN / ZeroTier IP) it is
51
+ * `undefined`, which is why a tap on mobile could silently do nothing. Falls
52
+ * back to a hidden `<textarea>` + `document.execCommand("copy")` so copy works
53
+ * off a non-secure origin too.
54
+ */
55
+ async function copyText(text: string): Promise<boolean> {
56
+ try {
57
+ if (navigator.clipboard?.writeText) {
58
+ await navigator.clipboard.writeText(text);
59
+ return true;
60
+ }
61
+ } catch {
62
+ // Fall through to the execCommand path.
63
+ }
64
+ try {
65
+ const area = document.createElement("textarea");
66
+ area.value = text;
67
+ area.setAttribute("readonly", "");
68
+ area.style.position = "fixed";
69
+ area.style.opacity = "0";
70
+ document.body.appendChild(area);
71
+ area.select();
72
+ const ok = document.execCommand("copy");
73
+ area.remove();
74
+ return ok;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
47
80
  const getReasoningText = (parts: UIMessage["parts"]): string =>
48
81
  parts
49
82
  .filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning")
@@ -274,6 +307,17 @@ export const AssistantBubble = ({
274
307
  );
275
308
  const fullText = textParts.map((p) => p.text).join("");
276
309
  const hasText = fullText.length > 0;
310
+ // Brief "copied" acknowledgement on the copy button (swaps the icon to a
311
+ // check for ~1.5s), so a tap gives visible feedback on touch where there's
312
+ // no hover/tooltip.
313
+ const [copied, setCopied] = useState(false);
314
+ const handleCopy = () => {
315
+ void copyText(fullText).then((ok) => {
316
+ if (!ok) return;
317
+ setCopied(true);
318
+ window.setTimeout(() => setCopied(false), 1500);
319
+ });
320
+ };
277
321
  // Suggestions are deferred until the turn is settled so they don't
278
322
  // pop in mid-stream. A bubble is "settled" if it isn't the active
279
323
  // streaming target - either the agent has returned to `ready` /
@@ -379,12 +423,17 @@ export const AssistantBubble = ({
379
423
  size="icon"
380
424
  variant="ghost"
381
425
  className="size-7"
382
- onClick={() => navigator.clipboard.writeText(fullText)}
426
+ onClick={handleCopy}
427
+ aria-label={copied ? "Copied" : "Copy"}
383
428
  >
384
- <CopyIcon className="size-3" />
429
+ {copied ? (
430
+ <CheckIcon className="size-3" />
431
+ ) : (
432
+ <CopyIcon className="size-3" />
433
+ )}
385
434
  </Button>
386
435
  </TooltipTrigger>
387
- <TooltipContent>Copy</TooltipContent>
436
+ <TooltipContent>{copied ? "Copied" : "Copy"}</TooltipContent>
388
437
  </Tooltip>
389
438
  )}
390
439
  {onExport && (
@@ -35,6 +35,7 @@ import {
35
35
  import { error as errorUtil } from "@dbx-tools/shared-core";
36
36
  import {
37
37
  ArrowDownIcon,
38
+ FastForwardIcon,
38
39
  MessageSquareIcon,
39
40
  PanelLeftIcon,
40
41
  RefreshCwIcon,
@@ -47,7 +48,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
47
48
  import { AssistantBubble, UserBubble } from "./bubbles";
48
49
  import { ExportMenu } from "./export-menu";
49
50
  import { SuggestionPills } from "./suggestion-pills";
50
- import { ThreadSidebar } from "./thread-sidebar";
51
+ import { ThreadSidebar, type ThreadSidebarProps } from "./thread-sidebar";
51
52
  import type { ChatViewProps } from "./types";
52
53
 
53
54
  // Controlled, presentational chat shell: the scroll container, header
@@ -100,6 +101,7 @@ export const ChatView = ({
100
101
  status,
101
102
  error,
102
103
  sendMessage,
104
+ onInterrupt,
103
105
  regenerate,
104
106
  onStop,
105
107
  className,
@@ -247,24 +249,46 @@ export const ChatView = ({
247
249
 
248
250
  const handleSubmit = (e: React.FormEvent) => {
249
251
  e.preventDefault();
250
- // Don't queue a new turn while one is streaming - the Enter-key
251
- // path would otherwise bypass the disabled Send button.
252
- if (isRunning) return;
253
252
  const text = input.trim();
254
253
  if (!text) return;
254
+ // Submitting while a turn streams is a steer: the driver hands the text
255
+ // to the live run (or interrupts + resends). Idle submits start a turn.
255
256
  sendMessage({ text });
256
257
  setInput("");
257
- // Sending is an explicit "I want to see the response" action, so
258
- // re-pin to the bottom even if the user had scrolled up. Without this
259
- // the freshly-appended user turn + waiting row can leave `isAtBottom`
260
- // false, suppressing auto-scroll and forcing a manual scroll down.
258
+ // Sending is an explicit "I want to see the response" action, so re-pin to
259
+ // the bottom even if the user had scrolled up. `setIsAtBottom(true)` makes
260
+ // the message-dep + ResizeObserver auto-scroll effects follow the new turn;
261
+ // the double-rAF is a belt-and-suspenders jump that runs AFTER React
262
+ // commits the appended message (a single frame can fire pre-commit).
261
263
  setIsAtBottom(true);
262
- requestAnimationFrame(() => {
264
+ pinToBottom();
265
+ };
266
+
267
+ // Re-pin the transcript to the bottom after a submit. `setIsAtBottom(true)`
268
+ // drives the auto-scroll effects; the double-rAF jump runs after React
269
+ // commits the appended message (a single frame can fire pre-commit).
270
+ const pinToBottom = () => {
271
+ const pin = () => {
263
272
  const el = scrollRef.current;
264
273
  if (el) el.scrollTop = el.scrollHeight;
274
+ };
275
+ requestAnimationFrame(() => {
276
+ pin();
277
+ requestAnimationFrame(pin);
265
278
  });
266
279
  };
267
280
 
281
+ // Interrupt-submit: force an immediate course-correction (abort the current
282
+ // run, resend with this text) rather than queuing to the live turn.
283
+ const handleInterrupt = () => {
284
+ const text = input.trim();
285
+ if (!text || !onInterrupt) return;
286
+ onInterrupt({ text });
287
+ setInput("");
288
+ setIsAtBottom(true);
289
+ pinToBottom();
290
+ };
291
+
268
292
  const lastMessage = messages.at(-1);
269
293
  const lastEvents = lastMessage ? toolEventsByMessage[lastMessage.id] : undefined;
270
294
  // Single in-flight indicator for the whole turn: visible from the
@@ -360,6 +384,23 @@ export const ChatView = ({
360
384
  const showHeader = showSidebarToggle;
361
385
  const showComposerToolbar = showModelDisplay || showExport || showClear;
362
386
 
387
+ // Props shared by the mobile drawer and the desktop inline sidebar - the two
388
+ // render the SAME `ThreadSidebar`, differing only in framing (overlay vs.
389
+ // inline) and, on mobile, closing the drawer after select / new. Building
390
+ // the prop bag once keeps the two call sites from drifting.
391
+ const sidebarProps: ThreadSidebarProps = {
392
+ threads: threads ?? [],
393
+ ...(activeThreadId ? { activeThreadId } : {}),
394
+ streamingThreadIds,
395
+ isLoading: isLoadingThreads,
396
+ onSelect: (id) => onSelectThread?.(id),
397
+ onHide: toggleSidebar,
398
+ ...(onNewThread ? { onNew: onNewThread } : {}),
399
+ ...(onDeleteThread ? { onDelete: onDeleteThread } : {}),
400
+ ...(onRenameThread ? { onRename: onRenameThread } : {}),
401
+ ...(onCancelThread ? { onCancel: onCancelThread } : {}),
402
+ };
403
+
363
404
  // Clear confirmation is an AppKit `AlertDialog` (a real modal), plus an
364
405
  // in-flight flag so the DELETE can't be double-fired. `clearing` disables
365
406
  // the confirm action while `onClear` runs; the dialog closes on settle.
@@ -395,9 +436,9 @@ export const ChatView = ({
395
436
  /*
396
437
  * Mobile: a fixed overlay drawer with a tap-to-close backdrop, so
397
438
  * the conversation list never eats horizontal space from the chat
398
- * on a phone. Selecting a thread / starting a new one closes it so
399
- * the transcript comes back into view. Session-only + default
400
- * closed (see `mobileDrawerOpen`).
439
+ * on a phone. Selecting a thread / starting a new one also closes
440
+ * the drawer so the transcript comes back into view. Session-only
441
+ * + default closed (see `mobileDrawerOpen`).
401
442
  */
402
443
  mobileDrawerOpen && (
403
444
  <div className="fixed inset-0 z-40 flex">
@@ -407,10 +448,7 @@ export const ChatView = ({
407
448
  aria-hidden="true"
408
449
  />
409
450
  <ThreadSidebar
410
- threads={threads ?? []}
411
- {...(activeThreadId ? { activeThreadId } : {})}
412
- streamingThreadIds={streamingThreadIds}
413
- isLoading={isLoadingThreads}
451
+ {...sidebarProps}
414
452
  onSelect={(id) => {
415
453
  onSelectThread?.(id);
416
454
  toggleSidebar();
@@ -423,10 +461,6 @@ export const ChatView = ({
423
461
  },
424
462
  }
425
463
  : {})}
426
- {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
427
- {...(onRenameThread ? { onRename: onRenameThread } : {})}
428
- {...(onCancelThread ? { onCancel: onCancelThread } : {})}
429
- onHide={toggleSidebar}
430
464
  className="relative z-10 w-[85vw] max-w-xs shadow-xl"
431
465
  />
432
466
  </div>
@@ -434,22 +468,11 @@ export const ChatView = ({
434
468
  ) : (
435
469
  /*
436
470
  * Desktop: an inline flex child sharing the row with the chat
437
- * column, using the persisted open/hide preference.
471
+ * column, using the persisted open/hide preference. Same
472
+ * `sidebarProps` as mobile - only the framing + close-on-select
473
+ * differ.
438
474
  */
439
- desktopSidebarOpen && (
440
- <ThreadSidebar
441
- threads={threads ?? []}
442
- {...(activeThreadId ? { activeThreadId } : {})}
443
- streamingThreadIds={streamingThreadIds}
444
- isLoading={isLoadingThreads}
445
- onSelect={(id) => onSelectThread?.(id)}
446
- {...(onNewThread ? { onNew: onNewThread } : {})}
447
- {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
448
- {...(onRenameThread ? { onRename: onRenameThread } : {})}
449
- {...(onCancelThread ? { onCancel: onCancelThread } : {})}
450
- onHide={toggleSidebar}
451
- />
452
- )
475
+ desktopSidebarOpen && <ThreadSidebar {...sidebarProps} />
453
476
  ))}
454
477
  <div className="flex h-full min-w-0 flex-1 flex-col">
455
478
  {showHeader && (
@@ -628,7 +651,8 @@ export const ChatView = ({
628
651
  className="max-h-48 text-base md:text-sm"
629
652
  />
630
653
  <InputGroupAddon align="inline-end">
631
- {isRunning && onStop ? (
654
+ {isRunning && onStop && !input.trim() ? (
655
+ // Running with an empty composer: the button stops the turn.
632
656
  <InputGroupButton
633
657
  type="button"
634
658
  size="icon-sm"
@@ -639,19 +663,44 @@ export const ChatView = ({
639
663
  <SquareIcon className="size-3 fill-current" />
640
664
  </InputGroupButton>
641
665
  ) : (
642
- <InputGroupButton
643
- type="submit"
644
- size="icon-sm"
645
- variant="default"
646
- disabled={!input.trim() || isRunning}
647
- aria-label="Send message"
648
- >
649
- {isRunning ? (
650
- <Spinner className="size-3" />
651
- ) : (
652
- <SendIcon className="size-3" />
666
+ <>
667
+ {/*
668
+ * Running with typed text: offer an immediate interrupt
669
+ * alongside the steer Send. Interrupt aborts the current
670
+ * turn and resends now; Send folds the message into the
671
+ * live turn (queue-steer).
672
+ */}
673
+ {isRunning && onInterrupt && input.trim() && (
674
+ <Tooltip>
675
+ <TooltipTrigger asChild>
676
+ <InputGroupButton
677
+ type="button"
678
+ size="icon-sm"
679
+ variant="outline"
680
+ onClick={handleInterrupt}
681
+ aria-label="Interrupt and send now"
682
+ >
683
+ <FastForwardIcon className="size-3" />
684
+ </InputGroupButton>
685
+ </TooltipTrigger>
686
+ <TooltipContent>Interrupt &amp; send now</TooltipContent>
687
+ </Tooltip>
653
688
  )}
654
- </InputGroupButton>
689
+ {/*
690
+ * Idle, or running with typed text: the primary button
691
+ * sends. A send mid-run steers the live turn (see the
692
+ * driver's sendMessage).
693
+ */}
694
+ <InputGroupButton
695
+ type="submit"
696
+ size="icon-sm"
697
+ variant="default"
698
+ disabled={!input.trim()}
699
+ aria-label={isRunning ? "Steer response" : "Send message"}
700
+ >
701
+ <SendIcon className="size-3" />
702
+ </InputGroupButton>
703
+ </>
655
704
  )}
656
705
  </InputGroupAddon>
657
706
  </InputGroup>
@@ -21,6 +21,7 @@ import {
21
21
  DEFAULT_THREAD_SESSION_KEY,
22
22
  isSessionRunning,
23
23
  sessionKey,
24
+ terminateRunningToolEvents,
24
25
  type ThreadSession,
25
26
  } from "../support/thread-sessions";
26
27
  import { ChatView } from "./chat-view";
@@ -710,6 +711,9 @@ export const useMastraChat = (
710
711
  error: null,
711
712
  status: "submitted",
712
713
  runToken: token,
714
+ // Superseding an in-flight run (a steer that interrupts, or a rapid
715
+ // re-send) stops its stream, so close any pills it left running.
716
+ toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
713
717
  };
714
718
  });
715
719
  const runIdRef = { current: getSession(threadId).runId };
@@ -798,6 +802,9 @@ export const useMastraChat = (
798
802
  runToken: session.runToken + 1,
799
803
  error: null,
800
804
  status: "ready",
805
+ // Cancelling stops the stream, so close any tool pills still marked
806
+ // running - the closing chunks will never arrive.
807
+ toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
801
808
  };
802
809
  });
803
810
  },
@@ -868,11 +875,12 @@ export const useMastraChat = (
868
875
  [activeKey, driveStream, getSession, mastraClient, agentId, updateSession],
869
876
  );
870
877
 
871
- const sendMessage = useCallback<ChatViewProps["sendMessage"]>(
872
- (message) => {
873
- const text = message.text ?? "";
874
- if (!text) return;
875
- const threadId = activeKey;
878
+ // Append a user message to a thread's transcript, stamping `lastUserText`,
879
+ // thread-activity, and a provisional title for a brand-new thread. Returns
880
+ // the pre-append session (so callers can see whether a run was in flight)
881
+ // and the new message list. Shared by send + interrupt.
882
+ const appendUserMessage = useCallback(
883
+ (threadId: string, text: string): { before: ThreadSession; next: UIMessage[] } => {
876
884
  updateSession(threadId, (session) => ({ ...session, lastUserText: text }));
877
885
  if (activeThreadId) {
878
886
  noteThreadActivity(activeThreadId);
@@ -885,19 +893,61 @@ export const useMastraChat = (
885
893
  }
886
894
  }
887
895
  }
888
- const next = [...getSession(threadId).messages, makeUserMessage(text)];
896
+ const before = getSession(threadId);
897
+ const next = [...before.messages, makeUserMessage(text)];
889
898
  writeMessages(threadId, next);
899
+ return { before, next };
900
+ },
901
+ [activeThreadId, getSession, noteThreadActivity, updateSession, writeMessages],
902
+ );
903
+
904
+ const sendMessage = useCallback<ChatViewProps["sendMessage"]>(
905
+ (message) => {
906
+ const text = message.text ?? "";
907
+ if (!text) return;
908
+ const threadId = activeKey;
909
+ const { before, next } = appendUserMessage(threadId, text);
910
+ // Steering: if a turn is already streaming on this thread, hand the
911
+ // message to the live run so the agent folds it into the current turn
912
+ // (no restart). If the server won't deliver it, fall back to
913
+ // interrupting + resending so a steer never silently drops.
914
+ if (isSessionRunning(before) && before.runId) {
915
+ const runId = before.runId;
916
+ const steerThreadId =
917
+ threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
918
+ void mastraClient
919
+ .queueMessage({ agentId, runId, threadId: steerThreadId, message: text })
920
+ .then((accepted) => {
921
+ if (accepted) {
922
+ logger.info("steer:queued", { runId });
923
+ return;
924
+ }
925
+ logger.info("steer:fallback-interrupt", { runId });
926
+ void runStream(threadId, getSession(threadId).messages);
927
+ });
928
+ return;
929
+ }
890
930
  void runStream(threadId, next);
891
931
  },
892
- [
893
- runStream,
894
- writeMessages,
895
- activeThreadId,
896
- activeKey,
897
- getSession,
898
- noteThreadActivity,
899
- updateSession,
900
- ],
932
+ [appendUserMessage, runStream, activeKey, getSession, mastraClient, agentId],
933
+ );
934
+
935
+ // Interrupt-and-resend: unconditionally abort any in-flight run on the
936
+ // active thread and start a fresh turn with `text` included immediately.
937
+ // The composer offers this alongside the queue-steer send so the user can
938
+ // force an immediate course-correction rather than waiting for the current
939
+ // turn to fold in a queued message. `runStream`/`driveStream` supersede the
940
+ // prior run (abort + runToken bump + pill cleanup).
941
+ const interrupt = useCallback(
942
+ (message: { text?: string }) => {
943
+ const text = message.text ?? "";
944
+ if (!text) return;
945
+ const threadId = activeKey;
946
+ const { next } = appendUserMessage(threadId, text);
947
+ logger.info("steer:interrupt", { threadId });
948
+ void runStream(threadId, next);
949
+ },
950
+ [appendUserMessage, runStream, activeKey],
901
951
  );
902
952
 
903
953
  /**
@@ -1362,6 +1412,7 @@ export const useMastraChat = (
1362
1412
  status: activeSession.status,
1363
1413
  error: activeSession.error,
1364
1414
  sendMessage,
1415
+ onInterrupt: interrupt,
1365
1416
  regenerate,
1366
1417
  onStop: stop,
1367
1418
  suggestions,
@@ -90,6 +90,15 @@ export type ChatViewProps = {
90
90
  */
91
91
  error?: Error | null;
92
92
  sendMessage: (message: { text: string }) => void;
93
+ /**
94
+ * Send a message that interrupts the in-flight turn immediately: abort the
95
+ * current run and start a fresh turn with this message. When provided and
96
+ * the chat is running, the composer shows a small "interrupt" action next
97
+ * to the queue-steer Send, so the user can force an immediate
98
+ * course-correction instead of folding the message into the live turn.
99
+ * Omit to offer only the queue-steer send while running.
100
+ */
101
+ onInterrupt?: (message: { text: string }) => void;
93
102
  regenerate?: () => void;
94
103
  /**
95
104
  * Abort the in-flight response. When provided and the chat is running
@@ -123,6 +123,50 @@ export class MastraPluginClient extends MastraClient {
123
123
  return asMastraStreamResponse(response);
124
124
  }
125
125
 
126
+ /**
127
+ * Steer a live run by handing it another message mid-turn ("queue"), via
128
+ * the agent `/queue-message` route. `behavior` controls what happens when
129
+ * the run is active: `deliver` (default) folds the message into the current
130
+ * turn so the agent picks it up without restarting; `persist` holds it for
131
+ * the next turn; `discard` drops it. Routed per call (own thread header, no
132
+ * shared client state) like {@link streamAgent}.
133
+ *
134
+ * Resolves `true` when the server accepted the message for delivery, `false`
135
+ * otherwise (unknown run / route unsupported / non-2xx) - never throws for a
136
+ * rejected steer, so the caller can fall back to interrupting + resending.
137
+ * Queue / signal routes are `@experimental` in Mastra.
138
+ */
139
+ async queueMessage(params: {
140
+ agentId: string;
141
+ runId: string;
142
+ threadId?: string;
143
+ message: string;
144
+ behavior?: "deliver" | "persist" | "discard";
145
+ }): Promise<boolean> {
146
+ const url = `${this.basePath}/agents/${encodeURIComponent(params.agentId)}/queue-message`;
147
+ try {
148
+ const response = await fetch(url, {
149
+ method: "POST",
150
+ credentials: "include",
151
+ headers: {
152
+ "Content-Type": "application/json",
153
+ ...this.#routingHeaders({ threadId: params.threadId }),
154
+ },
155
+ body: JSON.stringify({
156
+ runId: params.runId,
157
+ ...(params.threadId ? { threadId: params.threadId } : {}),
158
+ message: params.message,
159
+ ifActive: { behavior: params.behavior ?? "deliver" },
160
+ }),
161
+ });
162
+ if (!response.ok) return false;
163
+ const body = (await response.json().catch(() => null)) as { accepted?: boolean } | null;
164
+ return body?.accepted === true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
126
170
  /**
127
171
  * Resume a suspended `requireApproval` tool via `approve-tool-call`.
128
172
  * Reads SSE directly instead of `agent.approveToolCall()` so the
@@ -50,6 +50,28 @@ export function isSessionRunning(session: ThreadSession): boolean {
50
50
  return session.status === "submitted" || session.status === "streaming";
51
51
  }
52
52
 
53
+ /**
54
+ * Settle any tool-progress pills still marked `running` to `done`. A cancelled
55
+ * or interrupted turn stops delivering the `tool-result` / `tool-error` chunks
56
+ * that would otherwise close them, so without this a Genie / chart pill would
57
+ * spin forever after the user hits stop. Returns the same map when nothing was
58
+ * running so callers can skip a needless state update.
59
+ */
60
+ export function terminateRunningToolEvents(
61
+ toolEventsByMessage: Record<string, ToolEvent[]>,
62
+ ): Record<string, ToolEvent[]> {
63
+ let changed = false;
64
+ const next: Record<string, ToolEvent[]> = {};
65
+ for (const [messageId, events] of Object.entries(toolEventsByMessage)) {
66
+ next[messageId] = events.map((event) => {
67
+ if (event.status !== "running") return event;
68
+ changed = true;
69
+ return { ...event, status: "done" as const };
70
+ });
71
+ }
72
+ return changed ? next : toolEventsByMessage;
73
+ }
74
+
53
75
  export function sessionKey(activeThreadId: string | undefined): string {
54
76
  return activeThreadId ?? DEFAULT_THREAD_SESSION_KEY;
55
77
  }