@assistant-ui/react-ai-sdk 1.3.32 → 1.3.35

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.
Files changed (94) hide show
  1. package/dist/client.d.ts +9 -0
  2. package/dist/client.js +8 -0
  3. package/dist/frontendTools.d.ts +1 -1
  4. package/dist/frontendTools.js.map +1 -1
  5. package/dist/generativeTools.d.ts +18 -4
  6. package/dist/generativeTools.d.ts.map +1 -1
  7. package/dist/generativeTools.js +14 -5
  8. package/dist/generativeTools.js.map +1 -1
  9. package/dist/index.d.ts +3 -4
  10. package/dist/index.js +3 -4
  11. package/dist/index.native.d.ts +9 -0
  12. package/dist/index.native.js +8 -0
  13. package/dist/injectQuoteContext.js.map +1 -1
  14. package/dist/mcp-stdio.node.d.ts +2 -0
  15. package/dist/mcp-stdio.node.js +2 -0
  16. package/dist/mcp-stdio.unsupported.d.ts +7 -0
  17. package/dist/mcp-stdio.unsupported.d.ts.map +1 -0
  18. package/dist/mcp-stdio.unsupported.js +11 -0
  19. package/dist/mcp-stdio.unsupported.js.map +1 -0
  20. package/dist/modelContentEnvelope.d.ts +2 -1
  21. package/dist/modelContentEnvelope.d.ts.map +1 -1
  22. package/dist/toolOutputConversion.d.ts +1 -1
  23. package/dist/ui/resumable.d.ts +2 -1
  24. package/dist/ui/resumable.d.ts.map +1 -1
  25. package/dist/ui/resumable.js +1 -1
  26. package/dist/ui/use-chat/AssistantChatTransport.js +2 -2
  27. package/dist/ui/use-chat/AssistantChatTransport.js.map +1 -1
  28. package/dist/ui/use-chat/useAISDKRuntime.d.ts +10 -0
  29. package/dist/ui/use-chat/useAISDKRuntime.d.ts.map +1 -1
  30. package/dist/ui/use-chat/useAISDKRuntime.js +13 -4
  31. package/dist/ui/use-chat/useAISDKRuntime.js.map +1 -1
  32. package/dist/ui/use-chat/useChatRuntime.d.ts +1 -0
  33. package/dist/ui/use-chat/useChatRuntime.d.ts.map +1 -1
  34. package/dist/ui/use-chat/useChatRuntime.js +3 -2
  35. package/dist/ui/use-chat/useChatRuntime.js.map +1 -1
  36. package/dist/ui/use-chat/useExternalHistory.d.ts +4 -1
  37. package/dist/ui/use-chat/useExternalHistory.d.ts.map +1 -1
  38. package/dist/ui/use-chat/useExternalHistory.js +25 -1
  39. package/dist/ui/use-chat/useExternalHistory.js.map +1 -1
  40. package/dist/ui/use-chat/useStreamingTiming.js +1 -1
  41. package/dist/ui/utils/convertMessage.d.ts +2 -2
  42. package/dist/ui/utils/convertMessage.d.ts.map +1 -1
  43. package/dist/ui/utils/convertMessage.js +5 -2
  44. package/dist/ui/utils/convertMessage.js.map +1 -1
  45. package/dist/ui/utils/sliceMessagesUntil.js.map +1 -1
  46. package/dist/ui/utils/toCreateMessage.js.map +1 -1
  47. package/dist/usage.js.map +1 -1
  48. package/package.json +28 -12
  49. package/src/client.ts +18 -0
  50. package/src/generativeTools.test.ts +29 -47
  51. package/src/generativeTools.ts +18 -4
  52. package/src/index.native.ts +3 -0
  53. package/src/index.ts +1 -16
  54. package/src/mcp-stdio.node.ts +1 -0
  55. package/src/mcp-stdio.unsupported.ts +12 -0
  56. package/src/ui/use-chat/useAISDKRuntime.test.ts +104 -1
  57. package/src/ui/use-chat/useAISDKRuntime.ts +31 -1
  58. package/src/ui/use-chat/useChatRuntime.ts +3 -0
  59. package/src/ui/use-chat/useExternalHistory.ts +30 -1
  60. package/src/ui/utils/convertMessage.test.ts +36 -5
  61. package/src/ui/utils/convertMessage.ts +9 -1
  62. package/dist/assistant-stream/dist/core/AssistantStream.d.ts +0 -1
  63. package/dist/assistant-stream/dist/core/AssistantStreamChunk.d.ts +0 -1
  64. package/dist/assistant-stream/dist/core/accumulators/AssistantMessageStream.d.ts +0 -1
  65. package/dist/assistant-stream/dist/core/accumulators/assistant-message-accumulator.d.ts +0 -1
  66. package/dist/assistant-stream/dist/core/modules/assistant-stream.d.ts +0 -1
  67. package/dist/assistant-stream/dist/core/modules/text.d.ts +0 -1
  68. package/dist/assistant-stream/dist/core/modules/tool-call.d.ts +0 -1
  69. package/dist/assistant-stream/dist/core/serialization/PlainText.d.ts +0 -1
  70. package/dist/assistant-stream/dist/core/serialization/assistant-transport/AssistantTransport.d.ts +0 -1
  71. package/dist/assistant-stream/dist/core/serialization/data-stream/DataStream.d.ts +0 -1
  72. package/dist/assistant-stream/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts +0 -1
  73. package/dist/assistant-stream/dist/core/tool/ToolExecutionStream.d.ts +0 -1
  74. package/dist/assistant-stream/dist/core/tool/schema-utils.d.ts +0 -15
  75. package/dist/assistant-stream/dist/core/tool/schema-utils.d.ts.map +0 -1
  76. package/dist/assistant-stream/dist/core/tool/schema-utils.js +0 -59
  77. package/dist/assistant-stream/dist/core/tool/schema-utils.js.map +0 -1
  78. package/dist/assistant-stream/dist/core/tool/tool-types.d.ts +0 -69
  79. package/dist/assistant-stream/dist/core/tool/tool-types.d.ts.map +0 -1
  80. package/dist/assistant-stream/dist/core/tool/toolResultStream.d.ts +0 -1
  81. package/dist/assistant-stream/dist/core/utils/stream/AssistantMetaTransformStream.d.ts +0 -1
  82. package/dist/assistant-stream/dist/core/utils/stream/AssistantTransformStream.d.ts +0 -1
  83. package/dist/assistant-stream/dist/core/utils/types.d.ts +0 -1
  84. package/dist/assistant-stream/dist/index.d.ts +0 -2
  85. package/dist/assistant-stream/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +0 -140
  86. package/dist/assistant-stream/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +0 -1
  87. package/dist/assistant-stream/dist/resumable/createResumableAssistantStreamResponse.d.ts +0 -6
  88. package/dist/assistant-stream/dist/resumable/createResumableAssistantStreamResponse.d.ts.map +0 -1
  89. package/dist/assistant-stream/dist/resumable/createResumableAssistantStreamResponse.js +0 -6
  90. package/dist/assistant-stream/dist/resumable/createResumableAssistantStreamResponse.js.map +0 -1
  91. package/dist/assistant-stream/dist/resumable/index.d.ts +0 -1
  92. package/dist/assistant-stream/dist/utils/json/json-value.d.ts +0 -10
  93. package/dist/assistant-stream/dist/utils/json/json-value.d.ts.map +0 -1
  94. package/dist/assistant-stream/dist/utils.d.ts +0 -1
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { AISDKToolkit, generativeTools } from "./generativeTools";
2
+ import { AISDKToolkit } from "./generativeTools";
3
3
  import { wrapModelContentEnvelope } from "./modelContentEnvelope";
4
4
 
5
5
  const mocks = vi.hoisted(() => ({
@@ -19,20 +19,9 @@ vi.mock("@ai-sdk/mcp/mcp-stdio", () => ({
19
19
  })),
20
20
  }));
21
21
 
22
- describe("generativeTools", () => {
23
- beforeEach(() => {
24
- mocks.close.mockReset();
25
- mocks.tools.mockReset();
26
- mocks.createMCPClient.mockReset();
27
- });
28
-
29
- it("merges frontend tools with toolkit tools", () => {
30
- const toolSet = generativeTools({
31
- frontendTools: {
32
- clientTool: {
33
- parameters: { type: "object", properties: {} },
34
- },
35
- },
22
+ describe("AISDKToolkit.tools()", () => {
23
+ it("merges frontend tools with toolkit tools", async () => {
24
+ const toolSet = await new AISDKToolkit({
36
25
  toolkit: {
37
26
  serverTool: {
38
27
  type: "backend",
@@ -41,6 +30,12 @@ describe("generativeTools", () => {
41
30
  execute: async () => "ok",
42
31
  } as never,
43
32
  },
33
+ }).tools({
34
+ frontend: {
35
+ clientTool: {
36
+ parameters: { type: "object", properties: {} },
37
+ },
38
+ },
44
39
  });
45
40
 
46
41
  expect(toolSet.clientTool).toBeDefined();
@@ -48,8 +43,8 @@ describe("generativeTools", () => {
48
43
  expect(toolSet.serverTool?.execute).toBeTypeOf("function");
49
44
  });
50
45
 
51
- it("keeps a flat toolkit tool named tools", () => {
52
- const toolSet = generativeTools({
46
+ it("keeps a flat toolkit tool named tools", async () => {
47
+ const toolSet = await new AISDKToolkit({
53
48
  toolkit: {
54
49
  tools: {
55
50
  type: "backend",
@@ -58,27 +53,14 @@ describe("generativeTools", () => {
58
53
  execute: async () => "ok",
59
54
  } as never,
60
55
  },
61
- });
56
+ }).tools();
62
57
 
63
58
  expect(toolSet.tools?.description).toBe("Actually a tool, not config");
64
59
  expect(toolSet.tools?.execute).toBeTypeOf("function");
65
60
  });
66
61
 
67
- it("rejects MCP entries because they require pooled clients", () => {
68
- expect(() =>
69
- generativeTools({
70
- toolkit: {
71
- docs: {
72
- type: "mcp",
73
- server: { type: "http", url: "http://localhost:3001/mcp" },
74
- },
75
- },
76
- }),
77
- ).toThrow(/requires AISDKToolkit/);
78
- });
79
-
80
- it("converts provider tools without an execute function", () => {
81
- const toolSet = generativeTools({
62
+ it("converts provider tools without an execute function", async () => {
63
+ const toolSet = await new AISDKToolkit({
82
64
  toolkit: {
83
65
  web_search: {
84
66
  type: "provider",
@@ -86,7 +68,7 @@ describe("generativeTools", () => {
86
68
  args: { searchContextSize: "low" },
87
69
  },
88
70
  },
89
- });
71
+ }).tools();
90
72
 
91
73
  expect(toolSet.web_search).toMatchObject({
92
74
  type: "provider",
@@ -97,8 +79,8 @@ describe("generativeTools", () => {
97
79
  expect(toolSet.web_search).not.toHaveProperty("execute");
98
80
  });
99
81
 
100
- it("forwards provider tool parameters and providerOptions when present", () => {
101
- const toolSet = generativeTools({
82
+ it("forwards provider tool parameters and providerOptions when present", async () => {
83
+ const toolSet = await new AISDKToolkit({
102
84
  toolkit: {
103
85
  web_search: {
104
86
  type: "provider",
@@ -116,7 +98,7 @@ describe("generativeTools", () => {
116
98
  },
117
99
  },
118
100
  },
119
- });
101
+ }).tools();
120
102
 
121
103
  expect(toolSet.web_search).toMatchObject({
122
104
  type: "provider",
@@ -129,8 +111,8 @@ describe("generativeTools", () => {
129
111
  expect(toolSet.web_search).toHaveProperty("inputSchema");
130
112
  });
131
113
 
132
- it("forwards explicit false supportsDeferredResults", () => {
133
- const toolSet = generativeTools({
114
+ it("forwards explicit false supportsDeferredResults", async () => {
115
+ const toolSet = await new AISDKToolkit({
134
116
  toolkit: {
135
117
  web_search: {
136
118
  type: "provider",
@@ -139,7 +121,7 @@ describe("generativeTools", () => {
139
121
  supportsDeferredResults: false,
140
122
  },
141
123
  },
142
- });
124
+ }).tools();
143
125
 
144
126
  expect(toolSet.web_search).toMatchObject({
145
127
  supportsDeferredResults: false,
@@ -324,18 +306,18 @@ describe("AISDKToolkit", () => {
324
306
  });
325
307
  });
326
308
 
327
- describe("generativeTools toModelOutput", () => {
309
+ describe("AISDKToolkit toModelOutput", () => {
328
310
  const createWeatherTools = (toModelOutput?: any) =>
329
- generativeTools({
311
+ new AISDKToolkit({
330
312
  toolkit: {
331
313
  get_weather: {
332
314
  ...(toModelOutput && { toModelOutput }),
333
315
  },
334
316
  } as any,
335
- });
317
+ }).tools();
336
318
 
337
319
  it("adapts assistant-ui model content parts to the AI SDK tool output shape", async () => {
338
- const tools = createWeatherTools(({ output }: any) => [
320
+ const tools = await createWeatherTools(({ output }: any) => [
339
321
  { type: "text", text: `Weather card displayed: ${output.location}` },
340
322
  ]);
341
323
 
@@ -353,7 +335,7 @@ describe("generativeTools toModelOutput", () => {
353
335
 
354
336
  it("uses stored model content envelopes without re-running the custom projector", async () => {
355
337
  let called = false;
356
- const tools = createWeatherTools(() => {
338
+ const tools = await createWeatherTools(() => {
357
339
  called = true;
358
340
  return [{ type: "text", text: "recomputed" }];
359
341
  });
@@ -374,7 +356,7 @@ describe("generativeTools toModelOutput", () => {
374
356
  });
375
357
 
376
358
  it("falls back to default model output when no custom projector is defined", async () => {
377
- const tools = createWeatherTools();
359
+ const tools = await createWeatherTools();
378
360
 
379
361
  const output = await tools.get_weather!.toModelOutput!({
380
362
  toolCallId: "tc-weather",
@@ -389,7 +371,7 @@ describe("generativeTools toModelOutput", () => {
389
371
  });
390
372
 
391
373
  it("uses stored model content envelopes when no custom projector is defined", async () => {
392
- const tools = createWeatherTools();
374
+ const tools = await createWeatherTools();
393
375
 
394
376
  const output = await tools.get_weather!.toModelOutput!({
395
377
  toolCallId: "tc-weather",
@@ -1,7 +1,7 @@
1
1
  import { jsonSchema, type ToolSet } from "ai";
2
2
  import type { MCPClient, MCPClientConfig } from "@ai-sdk/mcp";
3
3
  import { createMCPClient } from "@ai-sdk/mcp";
4
- import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
4
+ import { Experimental_StdioMCPTransport } from "#mcp-stdio";
5
5
  import {
6
6
  toJSONSchema,
7
7
  type Tool,
@@ -31,6 +31,11 @@ const neverAbort = new AbortController().signal;
31
31
  const parametersToInputSchema = (parameters: Tool["parameters"] | undefined) =>
32
32
  jsonSchema(parameters ? toJSONSchema(parameters) : EMPTY_SCHEMA);
33
33
 
34
+ /**
35
+ * @deprecated Options for the deprecated {@link generativeTools}. Use
36
+ * {@link AISDKToolkit} with {@link AISDKToolkitOptions} /
37
+ * {@link AISDKToolkitToolsOptions} instead.
38
+ */
34
39
  export interface GenerativeToolsOptions {
35
40
  /**
36
41
  * The server build of a generative toolkit (schema + server `execute`). Typed
@@ -66,16 +71,25 @@ export type AISDKToolkitToolsOptions = {
66
71
  * resolves to the server build — schema + `execute`, with `render` stripped) and
67
72
  * pass it here. Tools without an `execute` are still exposed to the model but
68
73
  * left for the client to fulfill. `frontendTools` lets the client contribute
69
- * tools that aren't in the static toolkit. Use {@link AISDKToolkit} when the
70
- * toolkit contains MCP entries.
74
+ * tools that aren't in the static toolkit.
75
+ *
76
+ * @deprecated Use {@link AISDKToolkit} instead:
77
+ * `new AISDKToolkit({ toolkit }).tools({ frontend })`. It is a strict superset
78
+ * (it also opens MCP server connections), so it replaces `generativeTools`
79
+ * everywhere. The `frontendTools` option is named `frontend` on `.tools()`, and
80
+ * `.tools()` is async. `generativeTools` will be removed in a future version.
71
81
  *
72
82
  * @example
73
83
  * ```ts
84
+ * // Define once at module scope so any MCP connections pool across requests.
85
+ * const aiToolkit = new AISDKToolkit({ toolkit: docsToolkit });
86
+ *
87
+ * // In your route handler:
74
88
  * const { tools } = await req.json();
75
89
  * streamText({
76
90
  * model,
77
91
  * messages,
78
- * tools: generativeTools({ toolkit: docsToolkit, frontendTools: tools }),
92
+ * tools: await aiToolkit.tools({ frontend: tools }),
79
93
  * });
80
94
  * ```
81
95
  */
@@ -0,0 +1,3 @@
1
+ /// <reference types="@assistant-ui/core/react" />
2
+
3
+ export * from "./client";
package/src/index.ts CHANGED
@@ -1,18 +1,6 @@
1
1
  /// <reference types="@assistant-ui/core/react" />
2
2
 
3
- export { useAISDKRuntime } from "./ui/use-chat/useAISDKRuntime";
4
- export { useChatRuntime } from "./ui/use-chat/useChatRuntime";
5
- export type { UseChatRuntimeOptions } from "./ui/use-chat/useChatRuntime";
6
- export { AssistantChatTransport } from "./ui/use-chat/AssistantChatTransport";
7
- export {
8
- RESUMABLE_STREAM_ID_HEADER,
9
- createResumableSessionStorage,
10
- } from "./ui/resumable";
11
- export type {
12
- AssistantChatResumableOptions,
13
- ResumableClientStorage,
14
- } from "./ui/resumable";
15
- export { frontendTools } from "./frontendTools";
3
+ export * from "./client";
16
4
  export {
17
5
  AISDKToolkit,
18
6
  generativeTools,
@@ -20,6 +8,3 @@ export {
20
8
  type AISDKToolkitToolsOptions,
21
9
  type GenerativeToolsOptions,
22
10
  } from "./generativeTools";
23
- export { injectQuoteContext } from "./injectQuoteContext";
24
- export type { ThreadTokenUsage, TokenUsageExtractableMessage } from "./usage";
25
- export { getThreadMessageTokenUsage, useThreadTokenUsage } from "./usage";
@@ -0,0 +1 @@
1
+ export { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
@@ -0,0 +1,12 @@
1
+ import type { Experimental_StdioMCPTransport as NodeStdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
2
+
3
+ class UnsupportedStdioMCPTransport {
4
+ constructor() {
5
+ throw new Error(
6
+ "stdio MCP transport requires a runtime that can spawn a subprocess, such as Node, Bun, or Deno (with --allow-run). Use an HTTP or SSE MCP server config in browser, React Native, edge, or worker runtimes.",
7
+ );
8
+ }
9
+ }
10
+
11
+ export const Experimental_StdioMCPTransport =
12
+ UnsupportedStdioMCPTransport as unknown as typeof NodeStdioMCPTransport;
@@ -8,10 +8,14 @@ import { validateUIMessages } from "ai";
8
8
  // in isolation). Every other dependency — useExternalStoreRuntime,
9
9
  // useToolInvocations, the message converter — runs for real.
10
10
  vi.mock("./useExternalHistory", () => ({
11
- useExternalHistory: vi.fn(() => false),
11
+ useExternalHistory: vi.fn(() => ({
12
+ isLoading: false,
13
+ deleteMessage: vi.fn().mockResolvedValue(undefined),
14
+ })),
12
15
  toExportedMessageRepository: vi.fn(),
13
16
  }));
14
17
 
18
+ import { useExternalHistory } from "./useExternalHistory";
15
19
  import { useAISDKRuntime } from "./useAISDKRuntime";
16
20
 
17
21
  const createChatHelpers = (messages: any[] = []) => {
@@ -37,9 +41,19 @@ const createChatHelpers = (messages: any[] = []) => {
37
41
  return chatHelpers;
38
42
  };
39
43
 
44
+ const textOf = (message: any): string =>
45
+ message.content
46
+ .filter((part: any) => part.type === "text")
47
+ .map((part: any) => part.text)
48
+ .join("|");
49
+
40
50
  describe("useAISDKRuntime", () => {
41
51
  beforeEach(() => {
42
52
  vi.clearAllMocks();
53
+ vi.mocked(useExternalHistory).mockReturnValue({
54
+ isLoading: false,
55
+ deleteMessage: vi.fn().mockResolvedValue(undefined),
56
+ });
43
57
  });
44
58
 
45
59
  it("sends a new user message through the runtime", async () => {
@@ -271,6 +285,45 @@ describe("useAISDKRuntime", () => {
271
285
  );
272
286
  });
273
287
 
288
+ it("deletes only the selected message from AI SDK state", async () => {
289
+ const deleteMessage = vi.fn().mockResolvedValue(undefined);
290
+ vi.mocked(useExternalHistory).mockReturnValue({
291
+ isLoading: false,
292
+ deleteMessage,
293
+ });
294
+ const chat = createChatHelpers([
295
+ { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
296
+ {
297
+ id: "a1",
298
+ role: "assistant",
299
+ parts: [{ type: "text", text: "first-answer" }],
300
+ },
301
+ { id: "u2", role: "user", parts: [{ type: "text", text: "second" }] },
302
+ {
303
+ id: "a2",
304
+ role: "assistant",
305
+ parts: [{ type: "text", text: "second-answer" }],
306
+ },
307
+ ]);
308
+
309
+ const { result } = renderHook(() => useAISDKRuntime(chat));
310
+
311
+ await waitFor(() => {
312
+ expect(result.current.thread.getState().messages).toHaveLength(4);
313
+ });
314
+
315
+ await act(async () => {
316
+ result.current.thread.getMessageById("u2").delete();
317
+ });
318
+
319
+ expect(deleteMessage).toHaveBeenCalledWith("u2");
320
+ expect(chat.messages.map((message: any) => message.id)).toEqual([
321
+ "u1",
322
+ "a1",
323
+ "a2",
324
+ ]);
325
+ });
326
+
274
327
  it("edit slices history to parentId and sends the edited message", async () => {
275
328
  const chat = createChatHelpers([
276
329
  { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
@@ -439,4 +492,54 @@ describe("useAISDKRuntime", () => {
439
492
  const { result } = renderHook(() => useAISDKRuntime(chat, { suggestions }));
440
493
  expect(result.current.thread.getState().suggestions).toEqual(suggestions);
441
494
  });
495
+
496
+ it("merges consecutive assistant messages into one turn by default", async () => {
497
+ const chat = createChatHelpers([
498
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
499
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "first" }] },
500
+ {
501
+ id: "a2",
502
+ role: "assistant",
503
+ parts: [{ type: "text", text: "second" }],
504
+ },
505
+ ]);
506
+
507
+ const { result } = renderHook(() => useAISDKRuntime(chat));
508
+
509
+ await waitFor(() => {
510
+ expect(result.current.thread.getState().messages.length).toBe(2);
511
+ });
512
+
513
+ const messages = result.current.thread.getState().messages;
514
+ expect(messages.map((m: any) => m.role)).toEqual(["user", "assistant"]);
515
+ expect(textOf(messages[1])).toBe("first|second");
516
+ });
517
+
518
+ it('keeps consecutive assistant messages separate when joinStrategy is "none"', async () => {
519
+ const chat = createChatHelpers([
520
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
521
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "first" }] },
522
+ {
523
+ id: "a2",
524
+ role: "assistant",
525
+ parts: [{ type: "text", text: "second" }],
526
+ },
527
+ ]);
528
+
529
+ const { result } = renderHook(() =>
530
+ useAISDKRuntime(chat, { joinStrategy: "none" }),
531
+ );
532
+
533
+ await waitFor(() => {
534
+ expect(result.current.thread.getState().messages.length).toBe(3);
535
+ });
536
+
537
+ const messages = result.current.thread.getState().messages;
538
+ expect(messages.map((m: any) => m.role)).toEqual([
539
+ "user",
540
+ "assistant",
541
+ "assistant",
542
+ ]);
543
+ expect(messages.slice(1).map(textOf)).toEqual(["first", "second"]);
544
+ });
442
545
  });
@@ -6,6 +6,7 @@ import { isToolUIPart, generateId } from "ai";
6
6
  import {
7
7
  useExternalStoreRuntime,
8
8
  useRuntimeAdapters,
9
+ type JoinStrategy,
9
10
  } from "@assistant-ui/core/react";
10
11
  import type { ToolExecutionStatus } from "@assistant-ui/core";
11
12
  import type {
@@ -82,6 +83,15 @@ export type AISDKRuntimeAdapter = ExternalStoreSharedOptions & {
82
83
  * (for example, an SSE reconnect endpoint keyed by turn id).
83
84
  */
84
85
  onResume?: ExternalStoreAdapter["onResume"];
86
+ /**
87
+ * How consecutive assistant messages are rendered.
88
+ *
89
+ * `"concat-content"` (the default) merges them into a single thread message.
90
+ * `"none"` keeps each assistant message as its own thread message, which is
91
+ * useful when a backend persists proactive or consecutive assistant messages
92
+ * as separate entries.
93
+ */
94
+ joinStrategy?: JoinStrategy | undefined;
85
95
  };
86
96
 
87
97
  export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
@@ -93,6 +103,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
93
103
  toCreateMessage: customToCreateMessage,
94
104
  cancelPendingToolCallsOnSend = true,
95
105
  onResume,
106
+ joinStrategy,
96
107
  } = adapter;
97
108
  const contextAdapters = useRuntimeAdapters();
98
109
  const [toolStatuses, setToolStatuses] = useState<
@@ -126,6 +137,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
126
137
  const messages = AISDKMessageConverter.useThreadMessages({
127
138
  isRunning,
128
139
  messages: chatHelpers.messages,
140
+ joinStrategy,
129
141
  metadata: useMemo(
130
142
  () => ({
131
143
  toolStatuses,
@@ -146,7 +158,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
146
158
  },
147
159
  }));
148
160
 
149
- const isLoading = useExternalHistory(
161
+ const { isLoading, deleteMessage: deleteHistoryMessage } = useExternalHistory(
150
162
  runtimeRef,
151
163
  adapters?.history ?? contextAdapters?.history,
152
164
  AISDKMessageConverter.toThreadMessages as (
@@ -303,6 +315,24 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
303
315
  metadata: message.runConfig,
304
316
  });
305
317
  },
318
+ onDelete: async (messageId) => {
319
+ const threadMessages = runtimeRef.current.thread.getState().messages;
320
+ const messageIndex = threadMessages.findIndex(
321
+ (message) => message.id === messageId,
322
+ );
323
+ if (messageIndex === -1) return;
324
+
325
+ await deleteHistoryMessage(messageId);
326
+
327
+ const deleteIds = new Set(
328
+ getExternalStoreMessages<UI_MESSAGE>(threadMessages[messageIndex]!).map(
329
+ (message) => message.id,
330
+ ),
331
+ );
332
+ chatHelpers.setMessages((current) =>
333
+ current.filter((message) => !deleteIds.has(message.id)),
334
+ );
335
+ },
306
336
  onReload: async (parentId: string | null, config) => {
307
337
  lastRunConfigRef.current = config.runConfig;
308
338
  const newMessages = sliceMessagesUntil(chatHelpers.messages, parentId);
@@ -29,6 +29,7 @@ export type UseChatRuntimeOptions<UI_MESSAGE extends UIMessage = UIMessage> =
29
29
  adapters?: AISDKRuntimeAdapter["adapters"] | undefined;
30
30
  toCreateMessage?: CustomToCreateMessageFunction;
31
31
  onResume?: AISDKRuntimeAdapter["onResume"];
32
+ joinStrategy?: AISDKRuntimeAdapter["joinStrategy"];
32
33
  };
33
34
 
34
35
  const useDynamicChatTransport = <UI_MESSAGE extends UIMessage = UIMessage>(
@@ -78,6 +79,7 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
78
79
  unstable_capabilities: _unstable_capabilities,
79
80
  suggestions: _suggestions,
80
81
  onResume,
82
+ joinStrategy,
81
83
  ...chatOptions
82
84
  } = options ?? {};
83
85
  // peel guard: any shared key left in `chatOptions` collapses this to `never`
@@ -103,6 +105,7 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
103
105
  ...pickExternalStoreSharedOptions(options ?? {}),
104
106
  ...(toCreateMessage && { toCreateMessage }),
105
107
  ...(onResume && { onResume }),
108
+ ...(joinStrategy && { joinStrategy }),
106
109
  });
107
110
 
108
111
  if (transport instanceof AssistantChatTransport) {
@@ -292,5 +292,34 @@ export const useExternalHistory = <TMessage>(
292
292
  };
293
293
  }, [formatAdapter, storageFormatAdapter, runtimeRef]);
294
294
 
295
- return isLoading;
295
+ const deleteMessage = useCallback(
296
+ async (messageId: string) => {
297
+ if (!formatAdapter?.delete) return;
298
+
299
+ const messages = runtimeRef.current.thread.getState().messages;
300
+ const messageIndex = messages.findIndex((m) => m.id === messageId);
301
+ if (messageIndex === -1) return;
302
+
303
+ const previousInnerMessages = messages
304
+ .slice(0, messageIndex)
305
+ .flatMap(getExternalStoreMessages<TMessage>);
306
+ let parentId = previousInnerMessages.at(-1)
307
+ ? storageFormatAdapter.getId(previousInnerMessages.at(-1)!)
308
+ : null;
309
+ const itemsToDelete = getExternalStoreMessages<TMessage>(
310
+ messages[messageIndex]!,
311
+ ).map((message) => {
312
+ const item = { parentId, message };
313
+ parentId = storageFormatAdapter.getId(message);
314
+ return item;
315
+ });
316
+
317
+ await formatAdapter.delete(itemsToDelete);
318
+
319
+ historyIds.current.delete(messageId);
320
+ },
321
+ [formatAdapter, runtimeRef, storageFormatAdapter],
322
+ );
323
+
324
+ return { isLoading, deleteMessage };
296
325
  };
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import type { ReadonlyJSONObject } from "assistant-stream/utils";
2
+ import {
3
+ getPartialJsonObjectFieldState,
4
+ type ReadonlyJSONObject,
5
+ } from "assistant-stream/utils";
3
6
  import {
4
7
  AISDKMessageConverter,
5
8
  type AISDKMessageConverterMetadata,
@@ -365,6 +368,34 @@ describe("AISDKMessageConverter", () => {
365
368
  expect(toolCall?.argsText).toBe('{"city":"NYC');
366
369
  });
367
370
 
371
+ it("attaches partial-JSON meta marking the trailing streaming field", () => {
372
+ const converted = AISDKMessageConverter.toThreadMessages([
373
+ {
374
+ id: "a1",
375
+ role: "assistant",
376
+ parts: [
377
+ {
378
+ type: "tool-weather",
379
+ toolCallId: "tc-1",
380
+ state: "input-streaming",
381
+ input: { city: "NYC", units: "F" },
382
+ },
383
+ ],
384
+ } as any,
385
+ ]);
386
+
387
+ const toolCall = converted[0]?.content.find(
388
+ (part): part is any => part.type === "tool-call",
389
+ );
390
+ expect(toolCall?.args).toMatchObject({ city: "NYC", units: "F" });
391
+ expect(getPartialJsonObjectFieldState(toolCall!.args, ["city"])).toBe(
392
+ "complete",
393
+ );
394
+ expect(getPartialJsonObjectFieldState(toolCall!.args, ["units"])).toBe(
395
+ "partial",
396
+ );
397
+ });
398
+
368
399
  it("keeps observed key order from streaming snapshots for final tool args", () => {
369
400
  const metadata: AISDKMessageConverterMetadata = {
370
401
  toolArgsKeyOrderCache: new Map<string, Map<string, string[]>>(),
@@ -518,19 +549,19 @@ describe("AISDKMessageConverter", () => {
518
549
 
519
550
  const first = convertWithInput({ city: "NYC" });
520
551
  expect(first?.argsText).toBe('{"city":"NYC');
521
- expect(first?.args).toEqual({ city: "NYC" });
552
+ expect(first?.args).toMatchObject({ city: "NYC" });
522
553
 
523
554
  const dropped = convertWithInput(null);
524
555
  expect(dropped?.argsText).toBe('{"city":"NYC');
525
- expect(dropped?.args).toEqual({ city: "NYC" });
556
+ expect(dropped?.args).toMatchObject({ city: "NYC" });
526
557
 
527
558
  const undef = convertWithInput(undefined);
528
559
  expect(undef?.argsText).toBe('{"city":"NYC');
529
- expect(undef?.args).toEqual({ city: "NYC" });
560
+ expect(undef?.args).toMatchObject({ city: "NYC" });
530
561
 
531
562
  const grown = convertWithInput({ city: "NYC", units: "F" });
532
563
  expect(grown?.argsText).toBe('{"city":"NYC","units":"F');
533
- expect(grown?.args).toEqual({ city: "NYC", units: "F" });
564
+ expect(grown?.args).toMatchObject({ city: "NYC", units: "F" });
534
565
  });
535
566
 
536
567
  it("preserves last good input across terminal state transitions", () => {
@@ -15,7 +15,10 @@ import {
15
15
  type ThreadMessageLike,
16
16
  type McpAppMetadata,
17
17
  } from "@assistant-ui/core";
18
- import type { ReadonlyJSONObject } from "assistant-stream/utils";
18
+ import {
19
+ parsePartialJsonObject,
20
+ type ReadonlyJSONObject,
21
+ } from "assistant-stream/utils";
19
22
  import { unwrapModelContentEnvelope } from "../../modelContentEnvelope";
20
23
 
21
24
  type MessageMetadata = ThreadMessageLike["metadata"];
@@ -259,6 +262,11 @@ function convertParts(
259
262
  if (part.state === "input-streaming") {
260
263
  // strip closing delimiters added by the AI SDK's fix-json
261
264
  argsText = stripClosingDelimiters(argsText);
265
+ // Re-parse so args carries the partial-JSON meta that marks which
266
+ // field is still mid-arrival, like every argsText-based runtime.
267
+ // The key-order cache appends new keys last, so the trailing field
268
+ // of the stripped text is the streaming frontier.
269
+ args = parsePartialJsonObject(argsText) ?? args;
262
270
  } else {
263
271
  metadata.toolArgsKeyOrderCache?.delete(argsKeyOrderCacheKey);
264
272
  if (
@@ -1 +0,0 @@
1
- export { };
@@ -1 +0,0 @@
1
- export { };
@@ -1 +0,0 @@
1
- export { };