@assistant-ui/ai-sdk 0.0.4 → 0.0.6

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 (54) hide show
  1. package/dist/adapters/aiSDKFormatAdapter.d.ts +2 -8
  2. package/dist/adapters/aiSDKFormatAdapter.js +1 -25
  3. package/dist/converters/convertMessage.d.ts.map +1 -1
  4. package/dist/converters/convertMessage.js +9 -4
  5. package/dist/converters/convertMessage.js.map +1 -1
  6. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  7. package/dist/runtime/AISDKThreads.js +90 -65
  8. package/dist/runtime/AISDKThreads.js.map +1 -1
  9. package/dist/runtime/sdkIdentity.d.ts +6 -0
  10. package/dist/runtime/sdkIdentity.d.ts.map +1 -0
  11. package/dist/runtime/sdkIdentity.js +9 -0
  12. package/dist/runtime/sdkIdentity.js.map +1 -0
  13. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  14. package/dist/runtime/useAISDKRuntime.js +8 -1
  15. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  16. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  17. package/dist/runtime/useChatRuntime.js +7 -2
  18. package/dist/runtime/useChatRuntime.js.map +1 -1
  19. package/dist/runtime/useChatThread.js +1 -1
  20. package/dist/runtime/useChatThread.js.map +1 -1
  21. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  22. package/dist/runtime/useExternalHistory.js +4 -1
  23. package/dist/runtime/useExternalHistory.js.map +1 -1
  24. package/dist/tools/generativeTools.js +7 -4
  25. package/dist/tools/generativeTools.js.map +1 -1
  26. package/dist/usage.d.ts +8 -0
  27. package/dist/usage.d.ts.map +1 -1
  28. package/dist/usage.js +8 -0
  29. package/dist/usage.js.map +1 -1
  30. package/package.json +12 -12
  31. package/src/adapters/aiSDKFormatAdapter.ts +4 -41
  32. package/src/converters/convertMessage.test.ts +116 -0
  33. package/src/converters/convertMessage.ts +39 -18
  34. package/src/runtime/AISDKChat.integration.test.tsx +48 -27
  35. package/src/runtime/AISDKThreads.cloud.test.ts +12 -3
  36. package/src/runtime/AISDKThreads.test.ts +131 -8
  37. package/src/runtime/AISDKThreads.ts +31 -3
  38. package/src/runtime/__tests__/controlled-transport.ts +21 -0
  39. package/src/runtime/sdkIdentity.ts +9 -0
  40. package/src/runtime/useAISDKRuntime.test.ts +39 -1
  41. package/src/runtime/useAISDKRuntime.ts +17 -1
  42. package/src/runtime/useChatRuntime.integration.test.tsx +137 -4
  43. package/src/runtime/useChatRuntime.test.ts +0 -1
  44. package/src/runtime/useChatRuntime.ts +3 -1
  45. package/src/runtime/useChatThread.test.ts +74 -0
  46. package/src/runtime/useChatThread.ts +1 -1
  47. package/src/runtime/useExternalHistory.test.ts +29 -0
  48. package/src/runtime/useExternalHistory.ts +7 -1
  49. package/src/tools/generativeTools.test.ts +79 -0
  50. package/src/tools/generativeTools.ts +7 -8
  51. package/src/transport/AssistantChatTransport.test.ts +1 -9
  52. package/src/usage.ts +8 -0
  53. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +0 -1
  54. package/dist/adapters/aiSDKFormatAdapter.js.map +0 -1
@@ -188,7 +188,7 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
188
188
  id,
189
189
  isMainThread,
190
190
  getThreadListItem,
191
- stopOnClientDestroy = false,
191
+ stopOnClientDestroy = true,
192
192
  chat: externalChat,
193
193
  messageRepositoryInstance,
194
194
  } = env;
@@ -780,6 +780,35 @@ describe("useExternalHistory persistence", () => {
780
780
  ],
781
781
  expect.any(Object),
782
782
  );
783
+ expect(reportTelemetry.mock.calls[0]![1]).toMatchObject({
784
+ message: expect.objectContaining({
785
+ id: "assistant-a",
786
+ status: { type: "complete", reason: "stop" },
787
+ }),
788
+ });
789
+ });
790
+
791
+ it("reports a run that failed before any assistant message", async () => {
792
+ const { append, reportTelemetry, runCycle, flush } =
793
+ createPersistenceHarness(true);
794
+ const failed = createAssistantMessage(
795
+ {
796
+ type: "incomplete",
797
+ reason: "error",
798
+ error: { code: "AI_APICallError", message: "upstream failed" },
799
+ },
800
+ [],
801
+ );
802
+
803
+ await runCycle([failed]);
804
+ await flush();
805
+
806
+ expect(append).not.toHaveBeenCalled();
807
+ expect(reportTelemetry).toHaveBeenCalledTimes(1);
808
+ expect(reportTelemetry).toHaveBeenCalledWith(
809
+ [],
810
+ expect.objectContaining({ message: failed }),
811
+ );
783
812
  });
784
813
 
785
814
  it("restores deferred telemetry for reloaded paused messages", async () => {
@@ -143,6 +143,9 @@ export const useExternalHistory = <TMessage>(
143
143
 
144
144
  const remoteId = optionalThreadListItem()?.getState().remoteId;
145
145
  if (!remoteId) {
146
+ // History loads asynchronously against the thread list item; without a
147
+ // remote id there is nothing to await, so the flag settles here.
148
+ // eslint-disable-next-line react-hooks/set-state-in-effect
146
149
  setHasLoaded(true);
147
150
  return aui.subscribe(() => {
148
151
  if (optionalThreadListItem()?.getState().remoteId) {
@@ -368,7 +371,10 @@ export const useExternalHistory = <TMessage>(
368
371
 
369
372
  if (deferredTelemetryIds.current.has(message.id) && isTerminal) {
370
373
  deferredTelemetryIds.current.delete(message.id);
371
- adapter.reportTelemetry?.(batchItems, telemetryOptions);
374
+ adapter.reportTelemetry?.(batchItems, {
375
+ ...telemetryOptions,
376
+ message,
377
+ });
372
378
  }
373
379
  }
374
380
  })
@@ -139,6 +139,30 @@ describe("AISDKToolkit", () => {
139
139
  mocks.createMCPClient.mockReset();
140
140
  });
141
141
 
142
+ it("preserves prototype-named MCP tools", async () => {
143
+ const prototypeTool = { inputSchema: {} };
144
+ mocks.tools.mockResolvedValue(
145
+ Object.fromEntries([["__proto__", prototypeTool]]),
146
+ );
147
+ mocks.createMCPClient.mockResolvedValue({
148
+ tools: mocks.tools,
149
+ close: mocks.close,
150
+ });
151
+
152
+ const toolkit = new AISDKToolkit({
153
+ toolkit: {
154
+ docs: {
155
+ type: "mcp",
156
+ server: { type: "http", url: "http://localhost:3001/mcp" },
157
+ },
158
+ },
159
+ });
160
+
161
+ const tools = await toolkit.tools();
162
+ expect(Object.hasOwn(tools, "__proto__")).toBe(true);
163
+ expect(tools["__proto__"]).toBe(prototypeTool);
164
+ });
165
+
142
166
  it("loads MCP tools through pooled clients", async () => {
143
167
  mocks.tools.mockResolvedValue({ echo: { inputSchema: {} } });
144
168
  mocks.createMCPClient.mockResolvedValue({
@@ -454,6 +478,61 @@ describe("AISDKToolkit", () => {
454
478
  }
455
479
  });
456
480
 
481
+ it("does not evict a replacement client after an older listing timeout", async () => {
482
+ vi.useFakeTimers();
483
+ const oldClient = {
484
+ tools: vi.fn(() => never()),
485
+ close: vi.fn().mockResolvedValue(undefined),
486
+ };
487
+ const replacementClient = {
488
+ tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
489
+ close: vi.fn().mockResolvedValue(undefined),
490
+ };
491
+ mocks.createMCPClient
492
+ .mockResolvedValueOnce(oldClient)
493
+ .mockResolvedValue(replacementClient);
494
+
495
+ const toolkit = new AISDKToolkit({
496
+ toolkit: {
497
+ docs: {
498
+ type: "mcp",
499
+ server: {
500
+ type: "http",
501
+ url: "http://localhost:3001/mcp",
502
+ connectionTimeout: 100,
503
+ },
504
+ },
505
+ },
506
+ });
507
+
508
+ try {
509
+ const first = toolkit.tools();
510
+ const firstRejection = expect(first).rejects.toThrow(
511
+ /timed out while listing tools/,
512
+ );
513
+ await vi.advanceTimersByTimeAsync(50);
514
+
515
+ const second = toolkit.tools();
516
+ const secondRejection = expect(second).rejects.toThrow(
517
+ /timed out while listing tools/,
518
+ );
519
+ await vi.advanceTimersByTimeAsync(50);
520
+ await firstRejection;
521
+
522
+ await expect(toolkit.tools()).resolves.toHaveProperty("echo");
523
+ expect(mocks.createMCPClient).toHaveBeenCalledTimes(2);
524
+
525
+ await vi.advanceTimersByTimeAsync(50);
526
+ await secondRejection;
527
+
528
+ await expect(toolkit.tools()).resolves.toHaveProperty("echo");
529
+ expect(mocks.createMCPClient).toHaveBeenCalledTimes(2);
530
+ expect(oldClient.close).toHaveBeenCalledTimes(1);
531
+ } finally {
532
+ vi.useRealTimers();
533
+ }
534
+ });
535
+
457
536
  it("includes the MCP toolkit entry name when listing tools fails", async () => {
458
537
  const error = new Error("list failed");
459
538
  mocks.tools.mockRejectedValue(error);
@@ -227,11 +227,8 @@ export class AISDKToolkit {
227
227
  )
228
228
  .map(async ([name, tool]) => {
229
229
  const startedAt = Date.now();
230
- const client = await this.#mcpClient(
231
- name,
232
- tool.server,
233
- startedAt,
234
- ).catch((error: unknown) => {
230
+ const clientPromise = this.#mcpClient(name, tool.server, startedAt);
231
+ const client = await clientPromise.catch((error: unknown) => {
235
232
  if (error instanceof MCPConnectionTimeoutError) throw error;
236
233
  throw toMcpToolkitError(name, "connect", error);
237
234
  });
@@ -245,8 +242,10 @@ export class AISDKToolkit {
245
242
  return [name, tool, tools] as const;
246
243
  } catch (error) {
247
244
  if (error instanceof MCPConnectionTimeoutError) {
248
- this.#mcpClients.delete(name);
249
- void client.close().catch(() => {});
245
+ if (this.#mcpClients.get(name) === clientPromise) {
246
+ this.#mcpClients.delete(name);
247
+ void client.close().catch(() => {});
248
+ }
250
249
  throw error;
251
250
  }
252
251
  throw toMcpToolkitError(name, "list tools", error);
@@ -254,7 +253,7 @@ export class AISDKToolkit {
254
253
  }),
255
254
  );
256
255
 
257
- const tools: ToolSet = {};
256
+ const tools = Object.create(null) as ToolSet;
258
257
  const toolSources = new Map<string, string>();
259
258
  for (const [serverName, mcpTool, toolSet] of toolSets) {
260
259
  for (const [toolName, tool] of Object.entries(toolSet)) {
@@ -1,5 +1,5 @@
1
1
  import type { UIMessage } from "ai";
2
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { describe, expect, it, vi } from "vitest";
3
3
 
4
4
  import { RESUMABLE_STREAM_ID_HEADER } from "./resumable";
5
5
  import { AssistantChatTransport } from "./AssistantChatTransport";
@@ -26,10 +26,6 @@ const sendMessagesOptions = {
26
26
  };
27
27
 
28
28
  describe("AssistantChatTransport.prepareSendMessagesRequest", () => {
29
- beforeEach(() => {
30
- vi.clearAllMocks();
31
- });
32
-
33
29
  it("passes the initialized remote thread id to prepareSendMessagesRequest as options.id", async () => {
34
30
  const threadListItem = createThreadListItem("remote-thread-id");
35
31
  const captured: { id: unknown } = { id: undefined };
@@ -209,10 +205,6 @@ const wrappedFetchOf = (
209
205
  ).fetch;
210
206
 
211
207
  describe("AssistantChatTransport resumable fetch wrapper", () => {
212
- beforeEach(() => {
213
- vi.clearAllMocks();
214
- });
215
-
216
208
  it("passes a 204 with a non-null empty body through untouched (WebKit)", async () => {
217
209
  const response = nullBodyStatusWithBody(204);
218
210
  const fetchMock = vi.fn(async () => response);
package/src/usage.ts CHANGED
@@ -163,6 +163,14 @@ function findLatestMessageWithUsage(
163
163
  return undefined;
164
164
  }
165
165
 
166
+ /**
167
+ * Reads token usage from the newest assistant message that reports any.
168
+ *
169
+ * A route attaches usage through the AI SDK's `messageMetadata` option. Because
170
+ * a thread message carries a fixed metadata shape, the converter moves every
171
+ * other key the route returns into `metadata.custom`, which is where this hook
172
+ * looks.
173
+ */
166
174
  export function useThreadTokenUsage(): ThreadTokenUsage | undefined {
167
175
  const msg = useAuiState((s) => findLatestMessageWithUsage(s.thread.messages));
168
176
  return useMemo(() => getThreadMessageTokenUsage(msg), [msg]);
@@ -1 +0,0 @@
1
- {"version":3,"file":"aiSDKFormatAdapter.d.ts","names":[],"sources":["../../src/adapters/aiSDKFormatAdapter.ts"],"mappings":";;;KAQY,qBAAqB,KAAK;cAEzB,sBAAsB,qBACjC,WACA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"aiSDKFormatAdapter.js","names":["UIMessage","MessageFormatAdapter","MessageFormatItem","MessageStorageEntry","AISDKStorageFormat","Omit","aiSDKV6FormatAdapter","format","encode","message","id","parts","decode","stored","parentId","parent_id","content","getId"],"sources":["../../src/adapters/aiSDKFormatAdapter.ts"],"sourcesContent":["import type { UIMessage } from \"ai\";\nimport type {\n MessageFormatAdapter,\n MessageFormatItem,\n MessageStorageEntry,\n} from \"@assistant-ui/core\";\n\n// Storage format for AI SDK messages - just the UIMessage\nexport type AISDKStorageFormat = Omit<UIMessage, \"id\">;\n\nexport const aiSDKV6FormatAdapter: MessageFormatAdapter<\n UIMessage,\n AISDKStorageFormat\n> = {\n format: \"ai-sdk/v6\",\n\n encode({\n message: { id, parts, ...message },\n }: MessageFormatItem<UIMessage>): AISDKStorageFormat {\n return {\n ...message,\n parts,\n };\n },\n\n decode(\n stored: MessageStorageEntry<AISDKStorageFormat>,\n ): MessageFormatItem<UIMessage> {\n return {\n parentId: stored.parent_id,\n message: {\n id: stored.id,\n ...stored.content,\n },\n };\n },\n\n getId(message: UIMessage): string {\n return message.id;\n },\n};\n"],"mappings":";AAUA,MAAaM,uBAGT;CACFC,QAAQ;CAERC,OAAO,EACLC,SAAS,EAAEC,IAAIC,OAAO,GAAGF,aAC0B;EACnD,OAAO;GACL,GAAGA;GACHE;EACF;CACF;CAEAC,OACEC,QAC8B;EAC9B,OAAO;GACLC,UAAUD,OAAOE;GACjBN,SAAS;IACPC,IAAIG,OAAOH;IACX,GAAGG,OAAOG;GACZ;EACF;CACF;CAEAC,MAAMR,SAA4B;EAChC,OAAOA,QAAQC;CACjB;AACF"}