@dbx-tools/ui-mastra 0.3.7 → 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.
package/README.md CHANGED
@@ -140,13 +140,26 @@ import {
140
140
  } from "@dbx-tools/ui-mastra/react";
141
141
 
142
142
  const client = new MastraPluginClient(clientConfig);
143
- client.setModelOverride("claude sonnet");
144
- client.setThreadId(activeThreadId);
143
+
144
+ // Routing (thread + model) is passed per call, so concurrent runs on
145
+ // different threads never share state.
146
+ const stream = await client.streamAgent({
147
+ agentId: client.defaultAgent,
148
+ messages: [{ role: "user", content: "Hello" }],
149
+ runId,
150
+ threadId: activeThreadId,
151
+ model: "claude sonnet",
152
+ signal: controller.signal,
153
+ });
145
154
 
146
155
  const models = await client.models();
147
- const history = await client.history({ page: 0, perPage: 20 });
156
+ const history = await client.history({ threadId: activeThreadId, page: 0, perPage: 20 });
148
157
  ```
149
158
 
159
+ Each conversation thread runs independently: start a turn on one thread, switch
160
+ to another and start a second, and both stream concurrently. Cancel one via its
161
+ `AbortSignal` (or the driver's `onCancelThread`) without touching the others.
162
+
150
163
  `MastraPluginClient` extends `@mastra/client-js` with the AppKit-Mastra custom
151
164
  routes. It uses `credentials: "include"` so session cookies travel with streaming
152
165
  and REST calls. The React hooks wrap common route calls for model catalogues,
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.7",
30
- "@dbx-tools/shared-genie": "0.3.7",
31
- "@dbx-tools/shared-mastra": "0.3.7",
32
- "@dbx-tools/ui-appkit": "0.3.7",
33
- "@dbx-tools/shared-model": "0.3.7",
34
- "@dbx-tools/ui-branding": "0.3.7"
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"
35
35
  },
36
36
  "main": "index.ts",
37
37
  "license": "UNLICENSED",
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "version": "0.3.7",
41
+ "version": "0.3.8",
42
42
  "types": "index.ts",
43
43
  "type": "module",
44
44
  "exports": {
@@ -124,6 +124,7 @@ export const ChatView = ({
124
124
  onNewThread,
125
125
  onDeleteThread,
126
126
  onRenameThread,
127
+ onCancelThread,
127
128
  sidebarOpen: sidebarOpenProp,
128
129
  onToggleSidebar,
129
130
  onExportConversation,
@@ -424,6 +425,7 @@ export const ChatView = ({
424
425
  : {})}
425
426
  {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
426
427
  {...(onRenameThread ? { onRename: onRenameThread } : {})}
428
+ {...(onCancelThread ? { onCancel: onCancelThread } : {})}
427
429
  onHide={toggleSidebar}
428
430
  className="relative z-10 w-[85vw] max-w-xs shadow-xl"
429
431
  />
@@ -444,6 +446,7 @@ export const ChatView = ({
444
446
  {...(onNewThread ? { onNew: onNewThread } : {})}
445
447
  {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
446
448
  {...(onRenameThread ? { onRename: onRenameThread } : {})}
449
+ {...(onCancelThread ? { onCancel: onCancelThread } : {})}
447
450
  onHide={toggleSidebar}
448
451
  />
449
452
  )
@@ -630,7 +633,7 @@ export const ChatView = ({
630
633
  type="button"
631
634
  size="icon-sm"
632
635
  variant="default"
633
- onClick={onStop}
636
+ onClick={() => onStop()}
634
637
  aria-label="Stop response"
635
638
  >
636
639
  <SquareIcon className="size-3 fill-current" />
@@ -246,18 +246,20 @@ export const useMastraChat = (
246
246
  // using it as a hook dep doesn't refire the initial-history fetch on
247
247
  // every parent render.
248
248
  const mastraClient = useMastraClient();
249
- // Apply the selected model as a per-request override header in place,
250
- // so the next `agent.stream()` picks it up without rebuilding the
251
- // client (which would refire history loads on every model change).
252
- useEffect(() => {
253
- mastraClient.setModelOverride(model || undefined);
254
- }, [mastraClient, model]);
249
+ // The selected model rides each turn as a per-call override (see
250
+ // `runStream`), never stored on the shared client - so two threads can
251
+ // stream under different models without clobbering each other. Mirror it
252
+ // into a ref so the send path reads the latest selection without adding
253
+ // `model` to its dependency list.
254
+ const modelRef = useRef(model);
255
+ modelRef.current = model;
255
256
  const agentId = options.agentId ?? mastraClient.defaultAgent;
256
257
  // Built-in conversation management. When on, the chat always drives an
257
258
  // explicit client thread id (rather than leaning on the per-session
258
259
  // cookie) so it can reference, persist, and switch between the
259
- // conversations a user owns. Selecting a thread routes every call
260
- // (stream + history + clear) at it via `mastraClient.setThreadId`.
260
+ // conversations a user owns. Each call (stream / history / clear) carries
261
+ // its thread id per request, so many threads can run concurrently without
262
+ // sharing routing state.
261
263
  const enableThreads = options.enableThreads !== false;
262
264
  // Export is opt-in (default off): the host turns it on explicitly.
263
265
  const enableExport = options.enableExport === true;
@@ -693,7 +695,7 @@ export const useMastraChat = (
693
695
  async (
694
696
  threadId: string,
695
697
  assistantId: string,
696
- open: () => Promise<MastraStreamResponse>,
698
+ open: (signal: AbortSignal) => Promise<MastraStreamResponse>,
697
699
  ) => {
698
700
  const controller = new AbortController();
699
701
  let token = 0;
@@ -712,10 +714,7 @@ export const useMastraChat = (
712
714
  });
713
715
  const runIdRef = { current: getSession(threadId).runId };
714
716
  try {
715
- const streamThreadId =
716
- threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
717
- mastraClient.setThreadId(streamThreadId);
718
- const stream = await open();
717
+ const stream = await open(controller.signal);
719
718
  await processStream(threadId, stream, assistantId, runIdRef, controller.signal);
720
719
  updateSession(threadId, (session) => {
721
720
  if (session.runToken !== token) return session;
@@ -745,7 +744,7 @@ export const useMastraChat = (
745
744
  }));
746
745
  }
747
746
  },
748
- [getSession, mastraClient, processStream, refreshThreadsSoon, updateSession],
747
+ [getSession, processStream, refreshThreadsSoon, updateSession],
749
748
  );
750
749
 
751
750
  const runStream = useCallback(
@@ -757,35 +756,53 @@ export const useMastraChat = (
757
756
  assistantId,
758
757
  runId,
759
758
  }));
760
- return driveStream(threadId, assistantId, () => {
761
- const agent = mastraClient.getAgent(agentId);
762
- const messagesForAgent = history.flatMap((m) =>
759
+ // Capture this run's thread + model at send time and pass them per
760
+ // call, so a run keeps its own routing even if the user switches
761
+ // threads or changes the model picker while it streams.
762
+ const streamThreadId =
763
+ threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
764
+ const model = modelRef.current || undefined;
765
+ return driveStream(threadId, assistantId, (signal) => {
766
+ const messages = history.flatMap((m) =>
763
767
  m.parts
764
768
  .filter((p): p is { type: "text"; text: string } => p.type === "text")
765
769
  .map((p) => ({ role: m.role, content: p.text })),
766
- ) as Parameters<typeof agent.stream>[0];
767
- return agent.stream(messagesForAgent, {
770
+ );
771
+ return mastraClient.streamAgent({
772
+ agentId,
773
+ messages,
768
774
  runId,
769
- }) as Promise<MastraStreamResponse>;
775
+ threadId: streamThreadId,
776
+ model,
777
+ signal,
778
+ });
770
779
  });
771
780
  },
772
781
  [driveStream, mastraClient, agentId, updateSession],
773
782
  );
774
783
 
775
- const stop = useCallback(() => {
776
- const threadId = activeKey;
777
- updateSession(threadId, (session) => {
778
- if (!isSessionRunning(session)) return session;
779
- session.abortController?.abort();
780
- return {
781
- ...session,
782
- abortController: null,
783
- runToken: session.runToken + 1,
784
- error: null,
785
- status: "ready",
786
- };
787
- });
788
- }, [activeKey, updateSession]);
784
+ // Cancel a thread's in-flight run. Defaults to the active thread (the
785
+ // composer stop button), but takes an explicit thread id so the sidebar
786
+ // can cancel a background thread without switching to it. Aborting one
787
+ // thread's controller leaves every other run streaming - each run owns
788
+ // its own controller and routing.
789
+ const stop = useCallback(
790
+ (threadId?: string) => {
791
+ const key = threadId ?? activeKey;
792
+ updateSession(key, (session) => {
793
+ if (!isSessionRunning(session)) return session;
794
+ session.abortController?.abort();
795
+ return {
796
+ ...session,
797
+ abortController: null,
798
+ runToken: session.runToken + 1,
799
+ error: null,
800
+ status: "ready",
801
+ };
802
+ });
803
+ },
804
+ [activeKey, updateSession],
805
+ );
789
806
 
790
807
  /**
791
808
  * Approve or deny an in-flight `requireApproval` tool call. The
@@ -830,10 +847,22 @@ export const useMastraChat = (
830
847
  toolCallId,
831
848
  runId,
832
849
  });
833
- await driveStream(activeKey, assistantId, () =>
850
+ const streamThreadId =
851
+ activeKey === DEFAULT_THREAD_SESSION_KEY ? undefined : activeKey;
852
+ await driveStream(activeKey, assistantId, (signal) =>
834
853
  decision.approved
835
- ? mastraClient.approveToolCallStream(agentId, { runId, toolCallId })
836
- : mastraClient.declineToolCallStream(agentId, { runId, toolCallId }),
854
+ ? mastraClient.approveToolCallStream(agentId, {
855
+ runId,
856
+ toolCallId,
857
+ threadId: streamThreadId,
858
+ signal,
859
+ })
860
+ : mastraClient.declineToolCallStream(agentId, {
861
+ runId,
862
+ toolCallId,
863
+ threadId: streamThreadId,
864
+ signal,
865
+ }),
837
866
  );
838
867
  },
839
868
  [activeKey, driveStream, getSession, mastraClient, agentId, updateSession],
@@ -882,7 +911,7 @@ export const useMastraChat = (
882
911
  const handleClear = useCallback(async () => {
883
912
  const threadId = activeKey;
884
913
  try {
885
- const result = await mastraClient.clearHistory({ agentId });
914
+ const result = await mastraClient.clearHistory({ agentId, threadId: activeThreadId });
886
915
  logger.info("history cleared", { cleared: result.cleared });
887
916
  } catch (error) {
888
917
  logger.error("history clear error", {
@@ -1039,7 +1068,6 @@ export const useMastraChat = (
1039
1068
  // refetching or aborting other threads' runs.
1040
1069
  useEffect(() => {
1041
1070
  const threadId = activeKey;
1042
- mastraClient.setThreadId(activeThreadId);
1043
1071
  const session = getSession(threadId);
1044
1072
  if (session.historyLoaded) {
1045
1073
  setIsLoadingHistory(false);
@@ -1053,6 +1081,7 @@ export const useMastraChat = (
1053
1081
  mastraClient
1054
1082
  .history({
1055
1083
  agentId,
1084
+ threadId: activeThreadId,
1056
1085
  page: 0,
1057
1086
  perPage: HISTORY_PAGE_SIZE,
1058
1087
  signal: controller.signal,
@@ -1096,7 +1125,7 @@ export const useMastraChat = (
1096
1125
  const page = session.historyPage;
1097
1126
  updateSession(threadId, (current) => ({ ...current, historyPage: page + 1 }));
1098
1127
  mastraClient
1099
- .history({ agentId, page, perPage: HISTORY_PAGE_SIZE })
1128
+ .history({ agentId, threadId: activeThreadId, page, perPage: HISTORY_PAGE_SIZE })
1100
1129
  .then((response) => {
1101
1130
  const uiMessages = response.uiMessages as unknown as UIMessage[];
1102
1131
  if (uiMessages.length > 0) {
@@ -1123,7 +1152,15 @@ export const useMastraChat = (
1123
1152
  historyInFlightRef.current = false;
1124
1153
  setIsLoadingMore(false);
1125
1154
  });
1126
- }, [activeKey, getSession, mastraClient, agentId, updateSession, writeMessages]);
1155
+ }, [
1156
+ activeKey,
1157
+ activeThreadId,
1158
+ getSession,
1159
+ mastraClient,
1160
+ agentId,
1161
+ updateSession,
1162
+ writeMessages,
1163
+ ]);
1127
1164
 
1128
1165
  // Chat export (opt-in). Resolves `[chart:<id>]` / `[data:<id>]` embeds
1129
1166
  // straight off the client so the export inlines the same charts /
@@ -1356,6 +1393,9 @@ export const useMastraChat = (
1356
1393
  onNewThread: newThread,
1357
1394
  onDeleteThread: deleteThread,
1358
1395
  onRenameThread: renameThread,
1396
+ // Cancel a background thread's run from the sidebar without
1397
+ // switching to it.
1398
+ onCancelThread: stop,
1359
1399
  // Persisted sidebar visibility, controlled from the driver so
1360
1400
  // the show/hide choice survives reloads.
1361
1401
  sidebarOpen,
@@ -13,6 +13,7 @@ import {
13
13
  MessageSquarePlusIcon,
14
14
  PanelLeftIcon,
15
15
  PencilIcon,
16
+ SquareIcon,
16
17
  Trash2Icon,
17
18
  } from "lucide-react";
18
19
  import { useState } from "react";
@@ -42,6 +43,12 @@ export interface ThreadSidebarProps {
42
43
  onDelete?: (threadId: string) => void;
43
44
  /** Rename a thread. Per-row edit affordance (inline text field) hidden when omitted. */
44
45
  onRename?: (threadId: string, title: string) => void;
46
+ /**
47
+ * Cancel a thread's in-flight run. When provided, a streaming row (one in
48
+ * {@link streamingThreadIds}) shows a stop button in place of its spinner
49
+ * on hover, so a background run can be cancelled without switching to it.
50
+ */
51
+ onCancel?: (threadId: string) => void;
45
52
  /** Collapse the sidebar. Renders the hide button in the header when provided. */
46
53
  onHide?: () => void;
47
54
  /** Extra classes merged onto the sidebar root. */
@@ -67,6 +74,7 @@ export const ThreadSidebar = ({
67
74
  onNew,
68
75
  onDelete,
69
76
  onRename,
77
+ onCancel,
70
78
  onHide,
71
79
  className,
72
80
  }: ThreadSidebarProps) => {
@@ -212,12 +220,34 @@ export const ThreadSidebar = ({
212
220
  >
213
221
  <div className="min-w-0 flex-1">
214
222
  <div className="flex items-center gap-1.5 truncate">
215
- {isStreaming && (
216
- <Loader2Icon
217
- aria-label="Streaming"
218
- className="size-3 shrink-0 animate-spin text-primary"
219
- />
220
- )}
223
+ {isStreaming &&
224
+ (onCancel ? (
225
+ <Tooltip>
226
+ <TooltipTrigger asChild>
227
+ <Button
228
+ type="button"
229
+ variant="ghost"
230
+ size="icon"
231
+ onClick={(e) => {
232
+ e.stopPropagation();
233
+ onCancel(thread.id);
234
+ }}
235
+ aria-label="Stop generating"
236
+ className="size-4 shrink-0 text-primary"
237
+ >
238
+ {/* Spinner by default; swap to a stop square on hover/focus. */}
239
+ <Loader2Icon className="size-3 animate-spin group-hover:hidden" />
240
+ <SquareIcon className="hidden size-3 fill-current group-hover:block" />
241
+ </Button>
242
+ </TooltipTrigger>
243
+ <TooltipContent>Stop generating</TooltipContent>
244
+ </Tooltip>
245
+ ) : (
246
+ <Loader2Icon
247
+ aria-label="Streaming"
248
+ className="size-3 shrink-0 animate-spin text-primary"
249
+ />
250
+ ))}
221
251
  <span className="truncate">{threadTitle(thread)}</span>
222
252
  </div>
223
253
  {thread.updatedAt && (
@@ -99,7 +99,7 @@ export type ChatViewProps = {
99
99
  * to hide the Stop affordance (the Send button just disables while a
100
100
  * response streams).
101
101
  */
102
- onStop?: () => void;
102
+ onStop?: (threadId?: string) => void;
103
103
  /** Extra classes merged onto the root layout container. */
104
104
  className?: string;
105
105
  /**
@@ -220,6 +220,13 @@ export type ChatViewProps = {
220
220
  * affordance that swaps the title into an inline text field.
221
221
  */
222
222
  onRenameThread?: (threadId: string, title: string) => void;
223
+ /**
224
+ * Cancel a thread's in-flight run by id, without switching to it. When
225
+ * provided, each running sidebar row shows a stop affordance next to its
226
+ * streaming indicator (see {@link streamingThreadIds}). Isolated to that
227
+ * thread - other threads keep streaming.
228
+ */
229
+ onCancelThread?: (threadId: string) => void;
223
230
  /**
224
231
  * Controlled open/closed state for the conversation sidebar. When
225
232
  * omitted the view manages its own (session-only) open state; pass
@@ -72,43 +72,55 @@ export class MastraPluginClient extends MastraClient {
72
72
  }
73
73
 
74
74
  /**
75
- * Set (or clear) the per-request model override sent on every
76
- * streaming call as `X-Mastra-Model`. The Mastra plugin's middleware
77
- * reads it and overrides the resolved model for that request without
78
- * an agent redeploy. `model` is either a concrete endpoint name
79
- * (fuzzy-matched server-side) or a model class slug
80
- * (`ModelClass.ChatFast` / `"chat-thinking"`); pass `undefined` to
81
- * fall back to the agent's configured default.
75
+ * Build the per-request routing headers for a call: the thread-selection
76
+ * header (`THREAD_ID_HEADER`) so the server pins `RequestContext`'s thread
77
+ * id, and the model-override header (`MODEL_OVERRIDE_HEADER`) so the server
78
+ * swaps the resolved model for that request without an agent redeploy.
82
79
  *
83
- * Mutates the shared `options.headers` in place (rather than
84
- * rebuilding the client) so the client identity stays stable across
85
- * model changes - hooks can depend on it without refiring history
86
- * loads when only the model changes.
80
+ * Routing is per-CALL - never stored on the shared client - so concurrent
81
+ * runs on different threads / models can't clobber each other's headers.
82
+ * `threadId` is a conversation id (omit to fall back to the per-session
83
+ * cookie thread); `model` is a concrete endpoint name (fuzzy-matched
84
+ * server-side) or a model-class slug (`"chat-fast"` / `"chat-thinking"`).
87
85
  */
88
- setModelOverride(model?: string): void {
89
- const headers = (this.options.headers ??= {});
90
- if (model) headers[override.MODEL_OVERRIDE_HEADER] = model;
91
- else delete headers[override.MODEL_OVERRIDE_HEADER];
86
+ #routingHeaders(routing: { threadId?: string; model?: string }): Record<string, string> {
87
+ const headers: Record<string, string> = {};
88
+ if (routing.threadId) headers[thread.THREAD_ID_HEADER] = routing.threadId;
89
+ if (routing.model) headers[override.MODEL_OVERRIDE_HEADER] = routing.model;
90
+ return headers;
92
91
  }
93
92
 
94
93
  /**
95
- * Select (or clear) the conversation thread every subsequent request
96
- * targets, sent as the thread-selection header (`THREAD_ID_HEADER`).
97
- * The plugin's middleware pins `RequestContext`'s thread id to it, so
98
- * the agent stream persists into - and `history()` reads from - the
99
- * chosen thread instead of the default per-session one. Pass
100
- * `undefined` to fall back to the session thread.
101
- *
102
- * Mutates the shared `options.headers` in place (like
103
- * {@link setModelOverride}) so the client identity stays stable; the
104
- * inherited `agent.stream()` and the custom routes
105
- * ({@link history} / {@link clearHistory} / {@link threads}) all read
106
- * these headers, so selecting a thread routes every call at once.
94
+ * Open an agent stream for one conversation turn, routed and cancelled
95
+ * PER CALL. Posts to the agent `/stream` route with this run's own thread
96
+ * id + model as request headers and its own `AbortSignal` on the fetch, so
97
+ * many threads can stream at once and aborting one leaves the others
98
+ * untouched. Reads the SSE directly (like the tool-approval streams) rather
99
+ * than through the inherited `agent.stream()`, whose shared-client headers
100
+ * and single client-level abort signal can't isolate concurrent runs.
107
101
  */
108
- setThreadId(threadId?: string): void {
109
- const headers = (this.options.headers ??= {});
110
- if (threadId) headers[thread.THREAD_ID_HEADER] = threadId;
111
- else delete headers[thread.THREAD_ID_HEADER];
102
+ async streamAgent(params: {
103
+ agentId: string;
104
+ messages: Array<{ role: string; content: string }>;
105
+ runId: string;
106
+ threadId?: string;
107
+ model?: string;
108
+ signal?: AbortSignal;
109
+ }): Promise<MastraStreamResponse> {
110
+ const url = `${this.basePath}/agents/${encodeURIComponent(params.agentId)}/stream`;
111
+ const init: RequestInit = {
112
+ method: "POST",
113
+ credentials: "include",
114
+ headers: {
115
+ "Content-Type": "application/json",
116
+ ...this.#routingHeaders(params),
117
+ },
118
+ body: JSON.stringify({ messages: params.messages, runId: params.runId }),
119
+ };
120
+ if (params.signal) init.signal = params.signal;
121
+ const response = await fetch(url, init);
122
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
123
+ return asMastraStreamResponse(response);
112
124
  }
113
125
 
114
126
  /**
@@ -120,7 +132,7 @@ export class MastraPluginClient extends MastraClient {
120
132
  */
121
133
  async approveToolCallStream(
122
134
  agentId: string,
123
- params: { runId: string; toolCallId: string },
135
+ params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
124
136
  ): Promise<MastraStreamResponse> {
125
137
  return this.#toolApprovalStream(agentId, "approve-tool-call", params);
126
138
  }
@@ -131,7 +143,7 @@ export class MastraPluginClient extends MastraClient {
131
143
  */
132
144
  async declineToolCallStream(
133
145
  agentId: string,
134
- params: { runId: string; toolCallId: string },
146
+ params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
135
147
  ): Promise<MastraStreamResponse> {
136
148
  return this.#toolApprovalStream(agentId, "decline-tool-call", params);
137
149
  }
@@ -139,24 +151,22 @@ export class MastraPluginClient extends MastraClient {
139
151
  async #toolApprovalStream(
140
152
  agentId: string,
141
153
  route: "approve-tool-call" | "decline-tool-call",
142
- params: { runId: string; toolCallId: string },
154
+ params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
143
155
  ): Promise<MastraStreamResponse> {
144
- const response = (await this.request(
145
- `/agents/${encodeURIComponent(agentId)}/${route}`,
146
- {
147
- method: "POST",
148
- body: params,
149
- stream: true,
156
+ const url = `${this.basePath}/agents/${encodeURIComponent(agentId)}/${route}`;
157
+ const init: RequestInit = {
158
+ method: "POST",
159
+ credentials: "include",
160
+ headers: {
161
+ "Content-Type": "application/json",
162
+ ...this.#routingHeaders({ threadId: params.threadId }),
150
163
  },
151
- )) as Response;
152
- if (!response.body) throw new Error("No response body");
153
- return asMastraStreamResponse(
154
- new Response(response.body, {
155
- status: response.status,
156
- statusText: response.statusText,
157
- headers: response.headers,
158
- }),
159
- );
164
+ body: JSON.stringify({ runId: params.runId, toolCallId: params.toolCallId }),
165
+ };
166
+ if (params.signal) init.signal = params.signal;
167
+ const response = await fetch(url, init);
168
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
169
+ return asMastraStreamResponse(response);
160
170
  }
161
171
 
162
172
  /**
@@ -209,11 +219,14 @@ export class MastraPluginClient extends MastraClient {
209
219
  /**
210
220
  * Fetch one page of thread history from `GET ${basePath}/history`.
211
221
  * Messages come back oldest -> newest so the caller can prepend them
212
- * to a live transcript.
222
+ * to a live transcript. `threadId` targets a specific conversation
223
+ * (sent as the `?threadId=` query so it doesn't depend on shared client
224
+ * state); omit it to read the per-session cookie thread.
213
225
  */
214
226
  async history(
215
227
  options: {
216
228
  agentId?: string;
229
+ threadId?: string;
217
230
  page?: number;
218
231
  perPage?: number;
219
232
  signal?: AbortSignal;
@@ -222,6 +235,7 @@ export class MastraPluginClient extends MastraClient {
222
235
  const params = new URLSearchParams();
223
236
  if (options.page !== undefined) params.set("page", String(options.page));
224
237
  if (options.perPage !== undefined) params.set("perPage", String(options.perPage));
238
+ if (options.threadId) params.set(thread.THREAD_ID_QUERY, options.threadId);
225
239
  const qs = params.toString();
226
240
  const base = this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId);
227
241
  return this.#getJson(
@@ -232,19 +246,26 @@ export class MastraPluginClient extends MastraClient {
232
246
  }
233
247
 
234
248
  /**
235
- * Wipe the caller's thread history (`DELETE ${basePath}/history`).
236
- * The session cookie that anchors the thread id is preserved - only
237
- * the messages go away. Idempotent: a fresh thread reports
249
+ * Wipe a thread's history (`DELETE ${basePath}/history`). `threadId`
250
+ * targets a specific conversation via the thread-selection header (so it
251
+ * doesn't depend on shared client state); omit it to clear the per-session
252
+ * cookie thread. The session cookie that anchors the thread id is
253
+ * preserved - only the messages go away. Idempotent: a fresh thread reports
238
254
  * `cleared: 0` without erroring.
239
255
  */
240
256
  async clearHistory(
241
- options: { agentId?: string; signal?: AbortSignal } = {},
257
+ options: { agentId?: string; threadId?: string; signal?: AbortSignal } = {},
242
258
  ): Promise<MastraClearHistoryResponse> {
243
259
  return this.#mutateJson(
244
260
  this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId),
245
261
  "DELETE",
246
262
  wire.MastraClearHistoryResponseSchema,
247
- options.signal ? { signal: options.signal } : {},
263
+ {
264
+ ...(options.threadId
265
+ ? { headers: { [thread.THREAD_ID_HEADER]: options.threadId } }
266
+ : {}),
267
+ ...(options.signal ? { signal: options.signal } : {}),
268
+ },
248
269
  );
249
270
  }
250
271
 
@@ -282,9 +303,8 @@ export class MastraPluginClient extends MastraClient {
282
303
  * clashing with the inherited `MastraClient.deleteThread`, which hits
283
304
  * Mastra's stock thread route rather than our OBO-scoped, custom
284
305
  * mount). The id is sent as the thread-selection header for this one
285
- * call (without disturbing the client's currently-selected thread,
286
- * set via {@link setThreadId}), so the sidebar can remove any thread
287
- * while the user stays on another. Idempotent: deleting an unknown /
306
+ * call, so the sidebar can remove any thread while the user stays on
307
+ * another (routing is per call, never shared). Idempotent: deleting an unknown /
288
308
  * already-removed thread reports `deleted: false` without erroring.
289
309
  */
290
310
  async removeThread(
@@ -306,9 +326,8 @@ export class MastraPluginClient extends MastraClient {
306
326
  * Rename a single conversation thread via the plugin's own
307
327
  * `PATCH ${basePath}/threads` route. The id travels as the
308
328
  * thread-selection header for this one call (mirroring
309
- * {@link removeThread}, so the sidebar can rename any thread without
310
- * disturbing the client's currently-selected thread) and the new
311
- * `title` rides in the JSON body. The server enforces ownership and
329
+ * {@link removeThread}, so the sidebar can rename any thread; routing is
330
+ * per call, never shared) and the new `title` rides in the JSON body. The server enforces ownership and
312
331
  * echoes back the updated thread, so the caller can reflect the new
313
332
  * title immediately. Throws on an unknown / unowned thread (HTTP 404).
314
333
  */
@@ -384,13 +403,13 @@ export class MastraPluginClient extends MastraClient {
384
403
  }
385
404
 
386
405
  /**
387
- * Snapshot of the client's per-request override headers (model +
388
- * thread selection) for the custom-route fetches that don't go
389
- * through `@mastra/client-js`'s own request pipeline. Returns a fresh
390
- * object each call so a caller can safely add one-off headers without
391
- * mutating the shared `options.headers`. The thread-selection header
392
- * here is what routes `history()` / `clearHistory()` / `threads()` to
393
- * the currently-selected thread (see {@link setThreadId}).
406
+ * Snapshot of the client's base headers for the custom-route fetches
407
+ * that don't go through `@mastra/client-js`'s own request pipeline.
408
+ * Returns a fresh object each call so a caller can safely add one-off
409
+ * headers without mutating the shared `options.headers`. Routing
410
+ * (thread selection, model override) is passed per call - via a
411
+ * `?threadId=` query or the thread-selection header on the individual
412
+ * request - not stored here, so concurrent calls never share routing.
394
413
  */
395
414
  #defaultHeaders(): Record<string, string> {
396
415
  return { ...((this.options.headers as Record<string, string>) ?? {}) };
@@ -416,9 +435,8 @@ export class MastraPluginClient extends MastraClient {
416
435
  * `POST` / `DELETE` / `PATCH` + JSON-parse + schema-validate for the
417
436
  * mutating routes (`clearHistory` / `removeThread` / `renameThread` /
418
437
  * `feedback`). A JSON body, when present, sets `Content-Type`;
419
- * `options.headers` add one-off headers (e.g. the thread-selection
420
- * header for a targeted delete / rename) over the client's default
421
- * override headers.
438
+ * `options.headers` add per-call headers (e.g. the thread-selection
439
+ * header for a targeted delete / rename) over the client's base headers.
422
440
  */
423
441
  async #mutateJson<T>(
424
442
  url: string,
@@ -477,12 +495,12 @@ export const useMastraConfig = (): MastraClientConfig =>
477
495
  /**
478
496
  * The single {@link MastraPluginClient} for the plugin, built once from
479
497
  * the published `basePath`. Use it for everything: stream a turn with
480
- * `client.getAgent(agentId).stream(...)`, page history with
481
- * `client.history()`, resolve embeds with `client.chart(id)`, etc.
482
- * Rebuilt only when `basePath` / `defaultAgent` change (e.g. a custom
483
- * mount), so it's safe as a `useMemo` / `useCallback` / `useEffect`
484
- * dependency; the per-request model override is applied in place via
485
- * {@link MastraPluginClient.setModelOverride} without changing identity.
498
+ * `client.streamAgent({ agentId, messages, runId, threadId, model, signal })`,
499
+ * page history with `client.history()`, resolve embeds with
500
+ * `client.chart(id)`, etc. Rebuilt only when `basePath` / `defaultAgent`
501
+ * change (e.g. a custom mount), so it's safe as a `useMemo` / `useCallback`
502
+ * / `useEffect` dependency; per-turn thread + model routing is passed per
503
+ * call rather than stored on the client, so concurrent runs stay isolated.
486
504
  */
487
505
  export const useMastraClient = (): MastraPluginClient => {
488
506
  const config = useMastraConfig();