@tambo-ai/react 0.69.0 → 0.70.0

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 (202) hide show
  1. package/README.md +7 -7
  2. package/dist/hooks/use-tambo-threads.test.js.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/mcp/index.d.ts +4 -5
  7. package/dist/mcp/index.d.ts.map +1 -1
  8. package/dist/mcp/index.js +4 -5
  9. package/dist/mcp/index.js.map +1 -1
  10. package/dist/model/component-metadata.d.ts +88 -241
  11. package/dist/model/component-metadata.d.ts.map +1 -1
  12. package/dist/model/component-metadata.js.map +1 -1
  13. package/dist/model/mcp-server-info.d.ts +3 -3
  14. package/dist/model/mcp-server-info.js.map +1 -1
  15. package/dist/providers/hooks/use-tambo-session-token.test.js.map +1 -1
  16. package/dist/providers/tambo-component-provider.d.ts +2 -2
  17. package/dist/providers/tambo-component-provider.d.ts.map +1 -1
  18. package/dist/providers/tambo-component-provider.js.map +1 -1
  19. package/dist/providers/tambo-interactable-provider.d.ts +1 -1
  20. package/dist/providers/tambo-registry-provider.d.ts +4 -4
  21. package/dist/providers/tambo-registry-provider.d.ts.map +1 -1
  22. package/dist/providers/tambo-registry-provider.js +11 -8
  23. package/dist/providers/tambo-registry-provider.js.map +1 -1
  24. package/dist/providers/tambo-registry-provider.test.js +31 -0
  25. package/dist/providers/tambo-registry-provider.test.js.map +1 -1
  26. package/dist/providers/tambo-registry-schema-compat.test.js +42 -52
  27. package/dist/providers/tambo-registry-schema-compat.test.js.map +1 -1
  28. package/dist/providers/tambo-stubs.d.ts +2 -2
  29. package/dist/providers/tambo-stubs.d.ts.map +1 -1
  30. package/dist/providers/tambo-stubs.js.map +1 -1
  31. package/dist/providers/tambo-thread-provider-initial-messages.test.js.map +1 -1
  32. package/dist/providers/tambo-thread-provider.d.ts.map +1 -1
  33. package/dist/providers/tambo-thread-provider.js +110 -142
  34. package/dist/providers/tambo-thread-provider.js.map +1 -1
  35. package/dist/providers/tambo-thread-provider.test.js +362 -445
  36. package/dist/providers/tambo-thread-provider.test.js.map +1 -1
  37. package/dist/schema/index.d.ts +1 -2
  38. package/dist/schema/index.d.ts.map +1 -1
  39. package/dist/schema/index.js +1 -5
  40. package/dist/schema/index.js.map +1 -1
  41. package/dist/schema/schema.d.ts +7 -24
  42. package/dist/schema/schema.d.ts.map +1 -1
  43. package/dist/schema/schema.js +34 -105
  44. package/dist/schema/schema.js.map +1 -1
  45. package/dist/schema/schema.test.js +26 -124
  46. package/dist/schema/schema.test.js.map +1 -1
  47. package/dist/testing/tools.d.ts +2 -12
  48. package/dist/testing/tools.d.ts.map +1 -1
  49. package/dist/testing/tools.js +1 -20
  50. package/dist/testing/tools.js.map +1 -1
  51. package/dist/testing/types.d.ts +2 -2
  52. package/dist/testing/types.d.ts.map +1 -1
  53. package/dist/testing/types.js.map +1 -1
  54. package/dist/util/registry-validators.d.ts +2 -2
  55. package/dist/util/registry-validators.d.ts.map +1 -1
  56. package/dist/util/registry-validators.js +37 -17
  57. package/dist/util/registry-validators.js.map +1 -1
  58. package/dist/util/registry-validators.test.js +64 -25
  59. package/dist/util/registry-validators.test.js.map +1 -1
  60. package/dist/util/registry.d.ts +4 -10
  61. package/dist/util/registry.d.ts.map +1 -1
  62. package/dist/util/registry.js +6 -22
  63. package/dist/util/registry.js.map +1 -1
  64. package/dist/util/registry.test.js +1 -47
  65. package/dist/util/registry.test.js.map +1 -1
  66. package/dist/util/tool-caller.d.ts +2 -2
  67. package/dist/util/tool-caller.d.ts.map +1 -1
  68. package/dist/util/tool-caller.js +5 -12
  69. package/dist/util/tool-caller.js.map +1 -1
  70. package/dist/v1/index.d.ts +35 -0
  71. package/dist/v1/index.d.ts.map +1 -0
  72. package/dist/v1/index.js +47 -0
  73. package/dist/v1/index.js.map +1 -0
  74. package/dist/v1/types/component.d.ts +47 -0
  75. package/dist/v1/types/component.d.ts.map +1 -0
  76. package/dist/v1/types/component.js +11 -0
  77. package/dist/v1/types/component.js.map +1 -0
  78. package/dist/v1/types/event.d.ts +63 -0
  79. package/dist/v1/types/event.d.ts.map +1 -0
  80. package/dist/v1/types/event.js +9 -0
  81. package/dist/v1/types/event.js.map +1 -0
  82. package/dist/v1/types/message.d.ts +39 -0
  83. package/dist/v1/types/message.d.ts.map +1 -0
  84. package/dist/v1/types/message.js +10 -0
  85. package/dist/v1/types/message.js.map +1 -0
  86. package/dist/v1/types/thread.d.ts +54 -0
  87. package/dist/v1/types/thread.d.ts.map +1 -0
  88. package/dist/v1/types/thread.js +9 -0
  89. package/dist/v1/types/thread.js.map +1 -0
  90. package/dist/v1/types/tool.d.ts +52 -0
  91. package/dist/v1/types/tool.d.ts.map +1 -0
  92. package/dist/v1/types/tool.js +11 -0
  93. package/dist/v1/types/tool.js.map +1 -0
  94. package/esm/hooks/use-tambo-threads.test.js.map +1 -1
  95. package/esm/index.d.ts +1 -1
  96. package/esm/index.d.ts.map +1 -1
  97. package/esm/index.js.map +1 -1
  98. package/esm/mcp/index.d.ts +4 -5
  99. package/esm/mcp/index.d.ts.map +1 -1
  100. package/esm/mcp/index.js +4 -5
  101. package/esm/mcp/index.js.map +1 -1
  102. package/esm/model/component-metadata.d.ts +88 -241
  103. package/esm/model/component-metadata.d.ts.map +1 -1
  104. package/esm/model/component-metadata.js.map +1 -1
  105. package/esm/model/mcp-server-info.d.ts +3 -3
  106. package/esm/model/mcp-server-info.js.map +1 -1
  107. package/esm/providers/hooks/use-tambo-session-token.test.js.map +1 -1
  108. package/esm/providers/tambo-component-provider.d.ts +2 -2
  109. package/esm/providers/tambo-component-provider.d.ts.map +1 -1
  110. package/esm/providers/tambo-component-provider.js.map +1 -1
  111. package/esm/providers/tambo-interactable-provider.d.ts +1 -1
  112. package/esm/providers/tambo-registry-provider.d.ts +4 -4
  113. package/esm/providers/tambo-registry-provider.d.ts.map +1 -1
  114. package/esm/providers/tambo-registry-provider.js +11 -8
  115. package/esm/providers/tambo-registry-provider.js.map +1 -1
  116. package/esm/providers/tambo-registry-provider.test.js +31 -0
  117. package/esm/providers/tambo-registry-provider.test.js.map +1 -1
  118. package/esm/providers/tambo-registry-schema-compat.test.js +42 -52
  119. package/esm/providers/tambo-registry-schema-compat.test.js.map +1 -1
  120. package/esm/providers/tambo-stubs.d.ts +2 -2
  121. package/esm/providers/tambo-stubs.d.ts.map +1 -1
  122. package/esm/providers/tambo-stubs.js.map +1 -1
  123. package/esm/providers/tambo-thread-provider-initial-messages.test.js.map +1 -1
  124. package/esm/providers/tambo-thread-provider.d.ts.map +1 -1
  125. package/esm/providers/tambo-thread-provider.js +110 -142
  126. package/esm/providers/tambo-thread-provider.js.map +1 -1
  127. package/esm/providers/tambo-thread-provider.test.js +329 -445
  128. package/esm/providers/tambo-thread-provider.test.js.map +1 -1
  129. package/esm/schema/index.d.ts +1 -2
  130. package/esm/schema/index.d.ts.map +1 -1
  131. package/esm/schema/index.js +1 -2
  132. package/esm/schema/index.js.map +1 -1
  133. package/esm/schema/schema.d.ts +7 -24
  134. package/esm/schema/schema.d.ts.map +1 -1
  135. package/esm/schema/schema.js +34 -103
  136. package/esm/schema/schema.js.map +1 -1
  137. package/esm/schema/schema.test.js +27 -125
  138. package/esm/schema/schema.test.js.map +1 -1
  139. package/esm/testing/tools.d.ts +2 -12
  140. package/esm/testing/tools.d.ts.map +1 -1
  141. package/esm/testing/tools.js +2 -20
  142. package/esm/testing/tools.js.map +1 -1
  143. package/esm/testing/types.d.ts +2 -2
  144. package/esm/testing/types.d.ts.map +1 -1
  145. package/esm/testing/types.js.map +1 -1
  146. package/esm/util/registry-validators.d.ts +2 -2
  147. package/esm/util/registry-validators.d.ts.map +1 -1
  148. package/esm/util/registry-validators.js +38 -18
  149. package/esm/util/registry-validators.js.map +1 -1
  150. package/esm/util/registry-validators.test.js +64 -25
  151. package/esm/util/registry-validators.test.js.map +1 -1
  152. package/esm/util/registry.d.ts +4 -10
  153. package/esm/util/registry.d.ts.map +1 -1
  154. package/esm/util/registry.js +7 -22
  155. package/esm/util/registry.js.map +1 -1
  156. package/esm/util/registry.test.js +3 -49
  157. package/esm/util/registry.test.js.map +1 -1
  158. package/esm/util/tool-caller.d.ts +2 -2
  159. package/esm/util/tool-caller.d.ts.map +1 -1
  160. package/esm/util/tool-caller.js +5 -12
  161. package/esm/util/tool-caller.js.map +1 -1
  162. package/esm/v1/index.d.ts +35 -0
  163. package/esm/v1/index.d.ts.map +1 -0
  164. package/esm/v1/index.js +46 -0
  165. package/esm/v1/index.js.map +1 -0
  166. package/esm/v1/types/component.d.ts +47 -0
  167. package/esm/v1/types/component.d.ts.map +1 -0
  168. package/esm/v1/types/component.js +10 -0
  169. package/esm/v1/types/component.js.map +1 -0
  170. package/esm/v1/types/event.d.ts +63 -0
  171. package/esm/v1/types/event.d.ts.map +1 -0
  172. package/esm/v1/types/event.js +8 -0
  173. package/esm/v1/types/event.js.map +1 -0
  174. package/esm/v1/types/message.d.ts +39 -0
  175. package/esm/v1/types/message.d.ts.map +1 -0
  176. package/esm/v1/types/message.js +9 -0
  177. package/esm/v1/types/message.js.map +1 -0
  178. package/esm/v1/types/thread.d.ts +54 -0
  179. package/esm/v1/types/thread.d.ts.map +1 -0
  180. package/esm/v1/types/thread.js +8 -0
  181. package/esm/v1/types/thread.js.map +1 -0
  182. package/esm/v1/types/tool.d.ts +52 -0
  183. package/esm/v1/types/tool.d.ts.map +1 -0
  184. package/esm/v1/types/tool.js +10 -0
  185. package/esm/v1/types/tool.js.map +1 -0
  186. package/package.json +18 -8
  187. package/dist/schema/zod.d.ts +0 -57
  188. package/dist/schema/zod.d.ts.map +0 -1
  189. package/dist/schema/zod.js +0 -191
  190. package/dist/schema/zod.js.map +0 -1
  191. package/dist/schema/zod.test.d.ts +0 -2
  192. package/dist/schema/zod.test.d.ts.map +0 -1
  193. package/dist/schema/zod.test.js +0 -663
  194. package/dist/schema/zod.test.js.map +0 -1
  195. package/esm/schema/zod.d.ts +0 -57
  196. package/esm/schema/zod.d.ts.map +0 -1
  197. package/esm/schema/zod.js +0 -180
  198. package/esm/schema/zod.js.map +0 -1
  199. package/esm/schema/zod.test.d.ts +0 -2
  200. package/esm/schema/zod.test.d.ts.map +0 -1
  201. package/esm/schema/zod.test.js +0 -628
  202. package/esm/schema/zod.test.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { advanceStream } from "@tambo-ai/typescript-sdk";
1
+ import TamboAI, { advanceStream } from "@tambo-ai/typescript-sdk";
2
2
  import { act, renderHook } from "@testing-library/react";
3
3
  import React from "react";
4
4
  import { z } from "zod/v4";
@@ -23,9 +23,14 @@ jest.mock("./tambo-client-provider", () => {
23
23
  TamboClientContext: React.createContext(undefined),
24
24
  };
25
25
  });
26
- jest.mock("@tambo-ai/typescript-sdk", () => ({
27
- advanceStream: jest.fn(),
28
- }));
26
+ jest.mock("@tambo-ai/typescript-sdk", () => {
27
+ const actual = jest.requireActual("@tambo-ai/typescript-sdk");
28
+ return {
29
+ __esModule: true,
30
+ ...actual,
31
+ advanceStream: jest.fn(),
32
+ };
33
+ });
29
34
  // Mock the getCustomContext
30
35
  jest.mock("../util/registry", () => ({
31
36
  ...jest.requireActual("../util/registry"),
@@ -68,26 +73,9 @@ const createMockAdvanceResponse = (overrides = {}) => ({
68
73
  });
69
74
  describe("TamboThreadProvider", () => {
70
75
  const mockThread = createMockThread();
71
- const mockThreadsApi = {
72
- messages: {
73
- create: jest.fn(),
74
- },
75
- retrieve: jest.fn(),
76
- advance: jest.fn(),
77
- advanceByID: jest.fn(),
78
- generateName: jest.fn(),
79
- };
80
- const mockProjectsApi = {
81
- getCurrent: jest.fn(),
82
- };
83
- const mockBeta = {
84
- threads: mockThreadsApi,
85
- projects: mockProjectsApi,
86
- };
87
- const mockTamboAI = {
88
- apiKey: "",
89
- beta: mockBeta,
90
- };
76
+ let mockTamboAI;
77
+ let mockThreadsApi;
78
+ let mockProjectsApi;
91
79
  let mockQueryClient;
92
80
  const mockRegistry = [
93
81
  {
@@ -139,8 +127,19 @@ describe("TamboThreadProvider", () => {
139
127
  };
140
128
  // Default wrapper for most tests
141
129
  const Wrapper = createWrapper();
130
+ afterEach(() => {
131
+ jest.restoreAllMocks();
132
+ });
142
133
  beforeEach(() => {
143
134
  jest.clearAllMocks();
135
+ mockTamboAI = new TamboAI({
136
+ apiKey: "",
137
+ fetch: () => {
138
+ throw new Error("Unexpected network call in test");
139
+ },
140
+ });
141
+ mockThreadsApi = mockTamboAI.beta.threads;
142
+ mockProjectsApi = mockTamboAI.beta.projects;
144
143
  // Setup mock query client
145
144
  mockQueryClient = {
146
145
  invalidateQueries: jest.fn().mockResolvedValue(undefined),
@@ -149,21 +148,22 @@ describe("TamboThreadProvider", () => {
149
148
  jest
150
149
  .mocked(useTamboQueryClient)
151
150
  .mockReturnValue(mockQueryClient);
152
- jest.mocked(mockThreadsApi.retrieve).mockResolvedValue(mockThread);
151
+ jest.spyOn(mockThreadsApi, "retrieve").mockResolvedValue(mockThread);
153
152
  jest
154
- .mocked(mockThreadsApi.messages.create)
153
+ .spyOn(mockThreadsApi.messages, "create")
155
154
  .mockResolvedValue(createMockMessage());
156
155
  jest
157
- .mocked(mockThreadsApi.advance)
156
+ .spyOn(mockThreadsApi, "advance")
158
157
  .mockResolvedValue(createMockAdvanceResponse());
159
158
  jest
160
- .mocked(mockThreadsApi.advanceByID)
159
+ .spyOn(mockThreadsApi, "advanceByID")
161
160
  .mockResolvedValue(createMockAdvanceResponse());
162
- jest.mocked(mockThreadsApi.generateName).mockResolvedValue({
161
+ jest.spyOn(mockThreadsApi, "generateName").mockResolvedValue({
163
162
  ...mockThread,
164
163
  name: "Generated Thread Name",
165
164
  });
166
- jest.mocked(mockProjectsApi.getCurrent).mockResolvedValue({
165
+ jest.spyOn(mockThreadsApi, "update").mockResolvedValue({});
166
+ jest.spyOn(mockProjectsApi, "getCurrent").mockResolvedValue({
167
167
  id: "test-project-id",
168
168
  name: "Test Project",
169
169
  isTokenRequired: false,
@@ -231,7 +231,7 @@ describe("TamboThreadProvider", () => {
231
231
  });
232
232
  });
233
233
  it("should send a message and update thread state", async () => {
234
- const mockAdvanceResponse = {
234
+ const mockStreamResponse = {
235
235
  responseMessageDto: {
236
236
  id: "response-1",
237
237
  content: [{ type: "text", text: "Response" }],
@@ -244,14 +244,17 @@ describe("TamboThreadProvider", () => {
244
244
  generationStage: GenerationStage.COMPLETE,
245
245
  mcpAccessToken: "test-mcp-access-token",
246
246
  };
247
- jest
248
- .mocked(mockThreadsApi.advanceByID)
249
- .mockResolvedValue(mockAdvanceResponse);
247
+ const mockAsyncIterator = {
248
+ [Symbol.asyncIterator]: async function* () {
249
+ yield mockStreamResponse;
250
+ },
251
+ };
252
+ jest.mocked(advanceStream).mockResolvedValue(mockAsyncIterator);
250
253
  const { result } = renderHook(() => useTamboThread(), { wrapper: Wrapper });
251
254
  await act(async () => {
252
255
  await result.current.sendThreadMessage("Hello", {
253
256
  threadId: "test-thread-1",
254
- streamResponse: false,
257
+ streamResponse: true,
255
258
  additionalContext: {
256
259
  custom: {
257
260
  message: "additional instructions",
@@ -259,7 +262,7 @@ describe("TamboThreadProvider", () => {
259
262
  },
260
263
  });
261
264
  });
262
- expect(mockThreadsApi.advanceByID).toHaveBeenCalledWith("test-thread-1", {
265
+ expect(advanceStream).toHaveBeenCalledWith(expect.anything(), {
263
266
  messageToAppend: {
264
267
  content: [{ type: "text", text: "Hello" }],
265
268
  role: "user",
@@ -273,7 +276,7 @@ describe("TamboThreadProvider", () => {
273
276
  contextKey: undefined,
274
277
  clientTools: [],
275
278
  toolCallCounts: {},
276
- });
279
+ }, "test-thread-1");
277
280
  expect(result.current.generationStage).toBe(GenerationStage.COMPLETE);
278
281
  });
279
282
  it("should handle streaming responses", async () => {
@@ -308,7 +311,7 @@ describe("TamboThreadProvider", () => {
308
311
  expect(result.current.generationStage).toBe(GenerationStage.COMPLETE);
309
312
  });
310
313
  it("should handle tool calls during message processing.", async () => {
311
- const mockToolCallResponse = {
314
+ const mockToolCallChunk = {
312
315
  responseMessageDto: {
313
316
  id: "tool-call-1",
314
317
  content: [{ type: "text", text: "Tool response" }],
@@ -324,10 +327,7 @@ describe("TamboThreadProvider", () => {
324
327
  generationStage: GenerationStage.COMPLETE,
325
328
  mcpAccessToken: "test-mcp-access-token",
326
329
  };
327
- jest
328
- .mocked(mockThreadsApi.advanceByID)
329
- .mockResolvedValueOnce(mockToolCallResponse)
330
- .mockResolvedValueOnce({
330
+ const mockFinalChunk = {
331
331
  responseMessageDto: {
332
332
  id: "advance-response2",
333
333
  content: [{ type: "text", text: "response 2" }],
@@ -338,12 +338,26 @@ describe("TamboThreadProvider", () => {
338
338
  },
339
339
  generationStage: GenerationStage.COMPLETE,
340
340
  mcpAccessToken: "test-mcp-access-token",
341
- });
341
+ };
342
+ const mockAsyncIterator = {
343
+ [Symbol.asyncIterator]: async function* () {
344
+ yield mockToolCallChunk;
345
+ },
346
+ };
347
+ const mockAsyncIterator2 = {
348
+ [Symbol.asyncIterator]: async function* () {
349
+ yield mockFinalChunk;
350
+ },
351
+ };
352
+ jest
353
+ .mocked(advanceStream)
354
+ .mockResolvedValueOnce(mockAsyncIterator)
355
+ .mockResolvedValueOnce(mockAsyncIterator2);
342
356
  const { result } = renderHook(() => useTamboThread(), { wrapper: Wrapper });
343
357
  await act(async () => {
344
358
  await result.current.sendThreadMessage("Use tool", {
345
359
  threadId: "test-thread-1",
346
- streamResponse: false,
360
+ streamResponse: true,
347
361
  });
348
362
  });
349
363
  expect(result.current.generationStage).toBe(GenerationStage.COMPLETE);
@@ -356,7 +370,7 @@ describe("TamboThreadProvider", () => {
356
370
  const mockOnCallUnregisteredTool = jest
357
371
  .fn()
358
372
  .mockResolvedValue("unregistered-tool-result");
359
- const mockUnregisteredToolCallResponse = {
373
+ const mockUnregisteredToolCallChunk = {
360
374
  responseMessageDto: {
361
375
  id: "unregistered-tool-call-1",
362
376
  content: [{ type: "text", text: "Unregistered tool response" }],
@@ -374,10 +388,7 @@ describe("TamboThreadProvider", () => {
374
388
  generationStage: GenerationStage.COMPLETE,
375
389
  mcpAccessToken: "test-mcp-access-token",
376
390
  };
377
- jest
378
- .mocked(mockThreadsApi.advanceByID)
379
- .mockResolvedValueOnce(mockUnregisteredToolCallResponse)
380
- .mockResolvedValueOnce({
391
+ const mockFinalChunk = {
381
392
  responseMessageDto: {
382
393
  id: "advance-response2",
383
394
  content: [{ type: "text", text: "response 2" }],
@@ -388,7 +399,21 @@ describe("TamboThreadProvider", () => {
388
399
  },
389
400
  generationStage: GenerationStage.COMPLETE,
390
401
  mcpAccessToken: "test-mcp-access-token",
391
- });
402
+ };
403
+ const mockAsyncIterator = {
404
+ [Symbol.asyncIterator]: async function* () {
405
+ yield mockUnregisteredToolCallChunk;
406
+ },
407
+ };
408
+ const mockAsyncIterator2 = {
409
+ [Symbol.asyncIterator]: async function* () {
410
+ yield mockFinalChunk;
411
+ },
412
+ };
413
+ jest
414
+ .mocked(advanceStream)
415
+ .mockResolvedValueOnce(mockAsyncIterator)
416
+ .mockResolvedValueOnce(mockAsyncIterator2);
392
417
  const { result } = renderHook(() => useTamboThread(), {
393
418
  wrapper: createWrapper({
394
419
  onCallUnregisteredTool: mockOnCallUnregisteredTool,
@@ -397,14 +422,14 @@ describe("TamboThreadProvider", () => {
397
422
  await act(async () => {
398
423
  await result.current.sendThreadMessage("Use unregistered tool", {
399
424
  threadId: "test-thread-1",
400
- streamResponse: false,
425
+ streamResponse: true,
401
426
  });
402
427
  });
403
428
  expect(result.current.generationStage).toBe(GenerationStage.COMPLETE);
404
429
  expect(mockOnCallUnregisteredTool).toHaveBeenCalledWith("unregistered-tool", [{ parameterName: "input", parameterValue: "test-input" }]);
405
430
  });
406
431
  it("should handle unregistered tool calls without onCallUnregisteredTool", async () => {
407
- const mockUnregisteredToolCallResponse = {
432
+ const mockUnregisteredToolCallChunk = {
408
433
  responseMessageDto: {
409
434
  id: "unregistered-tool-call-1",
410
435
  content: [{ type: "text", text: "Unregistered tool response" }],
@@ -422,10 +447,7 @@ describe("TamboThreadProvider", () => {
422
447
  generationStage: GenerationStage.COMPLETE,
423
448
  mcpAccessToken: "test-mcp-access-token",
424
449
  };
425
- jest
426
- .mocked(mockThreadsApi.advanceByID)
427
- .mockResolvedValueOnce(mockUnregisteredToolCallResponse)
428
- .mockResolvedValueOnce({
450
+ const mockFinalChunk = {
429
451
  responseMessageDto: {
430
452
  id: "advance-response2",
431
453
  content: [{ type: "text", text: "response 2" }],
@@ -436,12 +458,26 @@ describe("TamboThreadProvider", () => {
436
458
  },
437
459
  generationStage: GenerationStage.COMPLETE,
438
460
  mcpAccessToken: "test-mcp-access-token",
439
- });
461
+ };
462
+ const mockAsyncIterator = {
463
+ [Symbol.asyncIterator]: async function* () {
464
+ yield mockUnregisteredToolCallChunk;
465
+ },
466
+ };
467
+ const mockAsyncIterator2 = {
468
+ [Symbol.asyncIterator]: async function* () {
469
+ yield mockFinalChunk;
470
+ },
471
+ };
472
+ jest
473
+ .mocked(advanceStream)
474
+ .mockResolvedValueOnce(mockAsyncIterator)
475
+ .mockResolvedValueOnce(mockAsyncIterator2);
440
476
  const { result } = renderHook(() => useTamboThread(), { wrapper: Wrapper });
441
477
  await act(async () => {
442
478
  await result.current.sendThreadMessage("Use unregistered tool", {
443
479
  threadId: "test-thread-1",
444
- streamResponse: false,
480
+ streamResponse: true,
445
481
  });
446
482
  });
447
483
  expect(result.current.generationStage).toBe(GenerationStage.COMPLETE);
@@ -502,77 +538,16 @@ describe("TamboThreadProvider", () => {
502
538
  expect(mockThreadsApi.advance).not.toHaveBeenCalled();
503
539
  expect(mockThreadsApi.advanceByID).not.toHaveBeenCalled();
504
540
  });
505
- it("should call advanceById when streamResponse=false for existing thread", async () => {
506
- // Use wrapper with streaming=true to show that explicit streamResponse=false overrides provider setting
541
+ it("should throw error when streamResponse=false (non-streaming not supported)", async () => {
507
542
  const { result } = renderHook(() => useTamboThread(), {
508
543
  wrapper: createWrapper({ streaming: true }),
509
544
  });
510
545
  await act(async () => {
511
- await result.current.sendThreadMessage("Hello non-streaming", {
546
+ await expect(result.current.sendThreadMessage("Hello non-streaming", {
512
547
  threadId: "test-thread-1",
513
548
  streamResponse: false,
514
- additionalContext: {
515
- custom: {
516
- message: "additional instructions",
517
- },
518
- },
519
- });
520
- });
521
- expect(mockThreadsApi.advanceByID).toHaveBeenCalledWith("test-thread-1", {
522
- messageToAppend: {
523
- content: [{ type: "text", text: "Hello non-streaming" }],
524
- role: "user",
525
- additionalContext: {
526
- custom: {
527
- message: "additional instructions",
528
- },
529
- },
530
- },
531
- availableComponents: serializeRegistry(mockRegistry),
532
- contextKey: undefined,
533
- clientTools: [],
534
- forceToolChoice: undefined,
535
- toolCallCounts: {},
536
- });
537
- // Should not call advance or advanceStream
538
- expect(mockThreadsApi.advance).not.toHaveBeenCalled();
539
- expect(advanceStream).not.toHaveBeenCalled();
540
- });
541
- it("should call advanceById when streamResponse is undefined and provider streaming=false", async () => {
542
- // Use wrapper with streaming=false to test that undefined streamResponse respects provider setting
543
- const { result } = renderHook(() => useTamboThread(), {
544
- wrapper: createWrapper({ streaming: false }),
545
- });
546
- await act(async () => {
547
- await result.current.sendThreadMessage("Hello default", {
548
- threadId: "test-thread-1",
549
- // streamResponse is undefined, should use provider's streaming=false
550
- additionalContext: {
551
- custom: {
552
- message: "additional instructions",
553
- },
554
- },
555
- });
556
- });
557
- expect(mockThreadsApi.advanceByID).toHaveBeenCalledWith("test-thread-1", {
558
- messageToAppend: {
559
- content: [{ type: "text", text: "Hello default" }],
560
- role: "user",
561
- additionalContext: {
562
- custom: {
563
- message: "additional instructions",
564
- },
565
- },
566
- },
567
- availableComponents: serializeRegistry(mockRegistry),
568
- contextKey: undefined,
569
- clientTools: [],
570
- forceToolChoice: undefined,
571
- toolCallCounts: {},
549
+ })).rejects.toThrow();
572
550
  });
573
- // Should not call advance or advanceStream
574
- expect(mockThreadsApi.advance).not.toHaveBeenCalled();
575
- expect(advanceStream).not.toHaveBeenCalled();
576
551
  });
577
552
  it("should call advanceStream when streamResponse is undefined and provider streaming=true (default)", async () => {
578
553
  const mockStreamResponse = {
@@ -628,44 +603,6 @@ describe("TamboThreadProvider", () => {
628
603
  expect(mockThreadsApi.advance).not.toHaveBeenCalled();
629
604
  expect(mockThreadsApi.advanceByID).not.toHaveBeenCalled();
630
605
  });
631
- it("should call advance when streamResponse=false for placeholder thread", async () => {
632
- // Use wrapper with streaming=true to show that explicit streamResponse=false overrides provider setting
633
- const { result } = renderHook(() => useTamboThread(), {
634
- wrapper: createWrapper({ streaming: true }),
635
- });
636
- // Start with placeholder thread (which is the default state)
637
- expect(result.current.thread.id).toBe("placeholder");
638
- await act(async () => {
639
- await result.current.sendThreadMessage("Hello new thread", {
640
- threadId: "placeholder",
641
- streamResponse: false,
642
- additionalContext: {
643
- custom: {
644
- message: "additional instructions",
645
- },
646
- },
647
- });
648
- });
649
- expect(mockThreadsApi.advance).toHaveBeenCalledWith({
650
- messageToAppend: {
651
- content: [{ type: "text", text: "Hello new thread" }],
652
- role: "user",
653
- additionalContext: {
654
- custom: {
655
- message: "additional instructions",
656
- },
657
- },
658
- },
659
- availableComponents: serializeRegistry(mockRegistry),
660
- contextKey: undefined,
661
- clientTools: [],
662
- forceToolChoice: undefined,
663
- toolCallCounts: {},
664
- });
665
- // Should not call advanceById or advanceStream
666
- expect(mockThreadsApi.advanceByID).not.toHaveBeenCalled();
667
- expect(advanceStream).not.toHaveBeenCalled();
668
- });
669
606
  it("should call advanceStream when streamResponse=true for placeholder thread", async () => {
670
607
  const mockStreamResponse = {
671
608
  responseMessageDto: {
@@ -722,26 +659,96 @@ describe("TamboThreadProvider", () => {
722
659
  expect(mockThreadsApi.advance).not.toHaveBeenCalled();
723
660
  expect(mockThreadsApi.advanceByID).not.toHaveBeenCalled();
724
661
  });
725
- });
726
- describe("error handling", () => {
727
- it("should set generation stage to ERROR when non-streaming sendThreadMessage fails", async () => {
728
- const testError = new Error("API call failed");
729
- // Mock advanceById to throw an error
730
- jest.mocked(mockThreadsApi.advanceByID).mockRejectedValue(testError);
662
+ it("should handle multiple sequential messages during streaming (server tool scenario)", async () => {
663
+ // This test verifies the fix for the bug where the second message doesn't render
664
+ // during server tool response streaming. The scenario:
665
+ // 1. First message: "I will call the tool..." with statusMessage
666
+ // 2. Second message: The tool result response streaming in
667
+ // First message - tool announcement (server tools don't have componentName set during streaming)
668
+ const mockFirstMessage = {
669
+ responseMessageDto: {
670
+ id: "msg-first",
671
+ content: [{ type: "text", text: "I will search the docs..." }],
672
+ role: "assistant",
673
+ threadId: "test-thread-1",
674
+ component: {
675
+ componentName: "",
676
+ componentState: {},
677
+ message: "",
678
+ props: {},
679
+ statusMessage: "searching the Tambo docs...",
680
+ },
681
+ componentState: {},
682
+ createdAt: new Date().toISOString(),
683
+ },
684
+ generationStage: GenerationStage.STREAMING_RESPONSE,
685
+ mcpAccessToken: "test-mcp-access-token",
686
+ };
687
+ // Second message - tool result (different ID!)
688
+ const mockSecondMessageChunk1 = {
689
+ responseMessageDto: {
690
+ id: "msg-second",
691
+ content: [{ type: "text", text: "Here's what I found..." }],
692
+ role: "assistant",
693
+ threadId: "test-thread-1",
694
+ componentState: {},
695
+ createdAt: new Date().toISOString(),
696
+ },
697
+ generationStage: GenerationStage.STREAMING_RESPONSE,
698
+ mcpAccessToken: "test-mcp-access-token",
699
+ };
700
+ const mockSecondMessageChunk2 = {
701
+ responseMessageDto: {
702
+ id: "msg-second",
703
+ content: [
704
+ {
705
+ type: "text",
706
+ text: "Here's what I found in the documentation about that topic.",
707
+ },
708
+ ],
709
+ role: "assistant",
710
+ threadId: "test-thread-1",
711
+ componentState: {},
712
+ createdAt: new Date().toISOString(),
713
+ },
714
+ generationStage: GenerationStage.COMPLETE,
715
+ mcpAccessToken: "test-mcp-access-token",
716
+ };
717
+ const mockAsyncIterator = {
718
+ [Symbol.asyncIterator]: async function* () {
719
+ yield mockFirstMessage;
720
+ yield mockSecondMessageChunk1;
721
+ yield mockSecondMessageChunk2;
722
+ },
723
+ };
724
+ jest.mocked(advanceStream).mockResolvedValue(mockAsyncIterator);
731
725
  const { result } = renderHook(() => useTamboThread(), {
732
- wrapper: Wrapper,
726
+ wrapper: createWrapper({ streaming: true }),
733
727
  });
734
- // Expect the error to be thrown
735
728
  await act(async () => {
736
- await result.current.switchCurrentThread("test-thread-1");
737
- await expect(result.current.sendThreadMessage("Hello", {
729
+ await result.current.sendThreadMessage("Search the docs", {
738
730
  threadId: "test-thread-1",
739
- streamResponse: false,
740
- })).rejects.toThrow("API call failed");
731
+ streamResponse: true,
732
+ });
741
733
  });
742
- // Verify generation stage is set to ERROR
743
- expect(result.current.generationStage).toBe(GenerationStage.ERROR);
734
+ // Thread should have 3 messages: user message + 2 assistant messages
735
+ expect(result.current.thread.messages).toHaveLength(3);
736
+ // Filter to assistant messages only
737
+ const assistantMessages = result.current.thread.messages.filter((m) => m.role === "assistant");
738
+ expect(assistantMessages).toHaveLength(2);
739
+ // First assistant message should have the tool status
740
+ const firstMsg = result.current.thread.messages.find((m) => m.id === "msg-first");
741
+ expect(firstMsg).toBeDefined();
742
+ expect(firstMsg?.content[0]?.text).toContain("search the docs");
743
+ // Second assistant message should have the final content
744
+ const secondMsg = result.current.thread.messages.find((m) => m.id === "msg-second");
745
+ expect(secondMsg).toBeDefined();
746
+ expect(secondMsg?.content[0]?.text).toContain("what I found in the documentation");
747
+ // Generation should be complete
748
+ expect(result.current.generationStage).toBe(GenerationStage.COMPLETE);
744
749
  });
750
+ });
751
+ describe("error handling", () => {
745
752
  it("should set generation stage to ERROR when streaming sendThreadMessage fails", async () => {
746
753
  const testError = new Error("Streaming API call failed");
747
754
  // Mock advanceStream to throw an error
@@ -760,24 +767,85 @@ describe("TamboThreadProvider", () => {
760
767
  // Verify generation stage is set to ERROR
761
768
  expect(result.current.generationStage).toBe(GenerationStage.ERROR);
762
769
  });
763
- it("should set generation stage to ERROR when advance API call fails for placeholder thread", async () => {
764
- const testError = new Error("Advance API call failed");
765
- // Mock advance to throw an error
766
- jest.mocked(mockThreadsApi.advance).mockRejectedValue(testError);
770
+ it("should rollback optimistic user message when sendThreadMessage fails", async () => {
771
+ const testError = new Error("API call failed");
772
+ jest.mocked(advanceStream).mockRejectedValue(testError);
767
773
  const { result } = renderHook(() => useTamboThread(), {
768
774
  wrapper: Wrapper,
769
775
  });
770
- // Start with placeholder thread (which is the default state)
771
- expect(result.current.thread.id).toBe("placeholder");
772
- // Expect the error to be thrown
776
+ await act(async () => {
777
+ await result.current.switchCurrentThread("test-thread-1");
778
+ });
779
+ const initialMessageCount = result.current.thread.messages.length;
773
780
  await act(async () => {
774
781
  await expect(result.current.sendThreadMessage("Hello", {
775
- threadId: "placeholder",
776
- streamResponse: false,
777
- })).rejects.toThrow("Advance API call failed");
782
+ threadId: "test-thread-1",
783
+ streamResponse: true,
784
+ })).rejects.toThrow("API call failed");
778
785
  });
779
- // Verify generation stage is set to ERROR
780
- expect(result.current.generationStage).toBe(GenerationStage.ERROR);
786
+ // Verify user message was rolled back
787
+ expect(result.current.thread.messages.length).toBe(initialMessageCount);
788
+ });
789
+ it("should rollback optimistic message when addThreadMessage fails", async () => {
790
+ const testError = new Error("Create message failed");
791
+ jest.mocked(mockThreadsApi.messages.create).mockRejectedValue(testError);
792
+ const { result } = renderHook(() => useTamboThread(), {
793
+ wrapper: Wrapper,
794
+ });
795
+ await act(async () => {
796
+ await result.current.switchCurrentThread("test-thread-1");
797
+ });
798
+ const initialMessageCount = result.current.thread.messages.length;
799
+ const newMessage = createMockMessage({ threadId: "test-thread-1" });
800
+ await act(async () => {
801
+ await expect(result.current.addThreadMessage(newMessage, true)).rejects.toThrow("Create message failed");
802
+ });
803
+ // Verify message was rolled back
804
+ expect(result.current.thread.messages.length).toBe(initialMessageCount);
805
+ });
806
+ it("should rollback optimistic update when updateThreadMessage fails", async () => {
807
+ const testError = new Error("Update message failed");
808
+ jest.mocked(mockThreadsApi.messages.create).mockRejectedValue(testError);
809
+ const { result } = renderHook(() => useTamboThread(), {
810
+ wrapper: Wrapper,
811
+ });
812
+ await act(async () => {
813
+ await result.current.switchCurrentThread("test-thread-1");
814
+ });
815
+ const existingMessage = createMockMessage({
816
+ id: "existing-msg",
817
+ threadId: "test-thread-1",
818
+ content: [{ type: "text", text: "Old content" }],
819
+ });
820
+ await act(async () => {
821
+ await result.current.addThreadMessage(existingMessage, false);
822
+ });
823
+ const initialMessageCount = result.current.thread.messages.length;
824
+ await act(async () => {
825
+ await expect(result.current.updateThreadMessage("existing-msg", {
826
+ threadId: "test-thread-1",
827
+ content: [{ type: "text", text: "New content" }],
828
+ role: "assistant",
829
+ }, true)).rejects.toThrow("Update message failed");
830
+ });
831
+ // Verify message was rolled back
832
+ expect(result.current.thread.messages.length).toBe(initialMessageCount - 1);
833
+ });
834
+ it("should rollback optimistic name update when updateThreadName fails", async () => {
835
+ const testError = new Error("Update name failed");
836
+ jest.mocked(mockThreadsApi.update).mockRejectedValue(testError);
837
+ const { result } = renderHook(() => useTamboThread(), {
838
+ wrapper: Wrapper,
839
+ });
840
+ await act(async () => {
841
+ await result.current.switchCurrentThread("test-thread-1");
842
+ });
843
+ const initialName = result.current.thread.name;
844
+ await act(async () => {
845
+ await expect(result.current.updateThreadName("New Name", "test-thread-1")).rejects.toThrow("Update name failed");
846
+ });
847
+ // Verify name was rolled back
848
+ expect(result.current.thread.name).toBe(initialName);
781
849
  });
782
850
  });
783
851
  describe("refetch threads list behavior", () => {
@@ -785,8 +853,8 @@ describe("TamboThreadProvider", () => {
785
853
  const { result } = renderHook(() => useTamboThread(), {
786
854
  wrapper: Wrapper,
787
855
  });
788
- // Mock the advance response to return a new thread ID
789
- const mockAdvanceResponse = {
856
+ // Mock the stream response to return a new thread ID
857
+ const mockStreamResponse = {
790
858
  responseMessageDto: {
791
859
  id: "response-1",
792
860
  content: [{ type: "text", text: "Response" }],
@@ -799,16 +867,19 @@ describe("TamboThreadProvider", () => {
799
867
  generationStage: GenerationStage.COMPLETE,
800
868
  mcpAccessToken: "test-mcp-access-token",
801
869
  };
802
- jest
803
- .mocked(mockThreadsApi.advance)
804
- .mockResolvedValue(mockAdvanceResponse);
870
+ const mockAsyncIterator = {
871
+ [Symbol.asyncIterator]: async function* () {
872
+ yield mockStreamResponse;
873
+ },
874
+ };
875
+ jest.mocked(advanceStream).mockResolvedValue(mockAsyncIterator);
805
876
  // Start with placeholder thread
806
877
  expect(result.current.thread.id).toBe("placeholder");
807
878
  // Send a message which will create a new thread with contextKey
808
879
  await act(async () => {
809
880
  await result.current.sendThreadMessage("Hello", {
810
881
  threadId: "placeholder",
811
- streamResponse: false,
882
+ streamResponse: true,
812
883
  contextKey: "test-context-key",
813
884
  });
814
885
  });
@@ -846,93 +917,6 @@ describe("TamboThreadProvider", () => {
846
917
  });
847
918
  });
848
919
  describe("transformToContent", () => {
849
- it("should use custom transformToContent when provided (non-streaming)", async () => {
850
- const mockTransformToContent = jest.fn().mockReturnValue([
851
- { type: "text", text: "Custom transformed content" },
852
- {
853
- type: "image_url",
854
- image_url: { url: "https://example.com/image.png" },
855
- },
856
- ]);
857
- const customToolRegistry = [
858
- {
859
- name: "TestComponent",
860
- component: () => React.createElement("div", null, "Test"),
861
- description: "Test",
862
- propsSchema: z.object({ test: z.string() }),
863
- associatedTools: [
864
- {
865
- name: "custom-tool",
866
- tool: jest.fn().mockResolvedValue({ data: "tool result" }),
867
- description: "Tool with custom transform",
868
- inputSchema: z.object({ input: z.string() }),
869
- outputSchema: z.object({ data: z.string() }),
870
- transformToContent: mockTransformToContent,
871
- },
872
- ],
873
- },
874
- ];
875
- const mockToolCallResponse = {
876
- responseMessageDto: {
877
- id: "tool-call-1",
878
- content: [{ type: "text", text: "Tool response" }],
879
- role: "tool",
880
- threadId: "test-thread-1",
881
- toolCallRequest: {
882
- toolName: "custom-tool",
883
- parameters: [{ parameterName: "input", parameterValue: "test" }],
884
- },
885
- componentState: {},
886
- createdAt: new Date().toISOString(),
887
- },
888
- generationStage: GenerationStage.COMPLETE,
889
- mcpAccessToken: "test-mcp-access-token",
890
- };
891
- jest
892
- .mocked(mockThreadsApi.advanceByID)
893
- .mockResolvedValueOnce(mockToolCallResponse)
894
- .mockResolvedValueOnce({
895
- responseMessageDto: {
896
- id: "final-response",
897
- content: [{ type: "text", text: "Final response" }],
898
- role: "assistant",
899
- threadId: "test-thread-1",
900
- componentState: {},
901
- createdAt: new Date().toISOString(),
902
- },
903
- generationStage: GenerationStage.COMPLETE,
904
- mcpAccessToken: "test-mcp-access-token",
905
- });
906
- const { result } = renderHook(() => useTamboThread(), {
907
- wrapper: createWrapper({ components: customToolRegistry }),
908
- });
909
- await act(async () => {
910
- await result.current.sendThreadMessage("Use custom tool", {
911
- threadId: "test-thread-1",
912
- streamResponse: false,
913
- });
914
- });
915
- // Verify the tool was called with single object arg (new inputSchema interface)
916
- expect(customToolRegistry[0]?.associatedTools?.[0]?.tool).toHaveBeenCalledWith({ input: "test" });
917
- // Verify transformToContent was called with the tool result
918
- expect(mockTransformToContent).toHaveBeenCalledWith({
919
- data: "tool result",
920
- });
921
- // Verify the second advance call included the transformed content
922
- expect(mockThreadsApi.advanceByID).toHaveBeenCalledTimes(2);
923
- expect(mockThreadsApi.advanceByID).toHaveBeenLastCalledWith("test-thread-1", expect.objectContaining({
924
- messageToAppend: expect.objectContaining({
925
- content: [
926
- { type: "text", text: "Custom transformed content" },
927
- {
928
- type: "image_url",
929
- image_url: { url: "https://example.com/image.png" },
930
- },
931
- ],
932
- role: "tool",
933
- }),
934
- }));
935
- });
936
920
  it("should use custom async transformToContent when provided (streaming)", async () => {
937
921
  const mockTransformToContent = jest
938
922
  .fn()
@@ -1029,169 +1013,6 @@ describe("TamboThreadProvider", () => {
1029
1013
  }),
1030
1014
  }), "test-thread-1");
1031
1015
  });
1032
- it("should fallback to stringified text when transformToContent is not provided", async () => {
1033
- const toolWithoutTransform = [
1034
- {
1035
- name: "TestComponent",
1036
- component: () => React.createElement("div", null, "Test"),
1037
- description: "Test",
1038
- propsSchema: z.object({ test: z.string() }),
1039
- associatedTools: [
1040
- {
1041
- name: "no-transform-tool",
1042
- tool: jest
1043
- .fn()
1044
- .mockResolvedValue({ complex: "data", nested: { value: 42 } }),
1045
- description: "Tool without custom transform",
1046
- inputSchema: z.object({ input: z.string() }),
1047
- outputSchema: z.object({
1048
- complex: z.string(),
1049
- nested: z.object({ value: z.number() }),
1050
- }),
1051
- // No transformToContent provided
1052
- },
1053
- ],
1054
- },
1055
- ];
1056
- const mockToolCallResponse = {
1057
- responseMessageDto: {
1058
- id: "tool-call-1",
1059
- content: [{ type: "text", text: "Tool call" }],
1060
- role: "tool",
1061
- threadId: "test-thread-1",
1062
- toolCallRequest: {
1063
- toolName: "no-transform-tool",
1064
- parameters: [{ parameterName: "input", parameterValue: "test" }],
1065
- },
1066
- componentState: {},
1067
- createdAt: new Date().toISOString(),
1068
- },
1069
- generationStage: GenerationStage.COMPLETE,
1070
- mcpAccessToken: "test-mcp-access-token",
1071
- };
1072
- jest
1073
- .mocked(mockThreadsApi.advanceByID)
1074
- .mockResolvedValueOnce(mockToolCallResponse)
1075
- .mockResolvedValueOnce({
1076
- responseMessageDto: {
1077
- id: "final-response",
1078
- content: [{ type: "text", text: "Final response" }],
1079
- role: "assistant",
1080
- threadId: "test-thread-1",
1081
- componentState: {},
1082
- createdAt: new Date().toISOString(),
1083
- },
1084
- generationStage: GenerationStage.COMPLETE,
1085
- mcpAccessToken: "test-mcp-access-token",
1086
- });
1087
- const { result } = renderHook(() => useTamboThread(), {
1088
- wrapper: createWrapper({ components: toolWithoutTransform }),
1089
- });
1090
- await act(async () => {
1091
- await result.current.sendThreadMessage("Use tool without transform", {
1092
- threadId: "test-thread-1",
1093
- streamResponse: false,
1094
- });
1095
- });
1096
- // Verify the tool was called with single object arg (new inputSchema interface)
1097
- expect(toolWithoutTransform[0]?.associatedTools?.[0]?.tool).toHaveBeenCalledWith({ input: "test" });
1098
- // Verify the second advance call used stringified content
1099
- expect(mockThreadsApi.advanceByID).toHaveBeenLastCalledWith("test-thread-1", expect.objectContaining({
1100
- messageToAppend: expect.objectContaining({
1101
- content: [
1102
- {
1103
- type: "text",
1104
- text: '{"complex":"data","nested":{"value":42}}',
1105
- },
1106
- ],
1107
- role: "tool",
1108
- }),
1109
- }));
1110
- });
1111
- it("should always return text for error responses even with transformToContent", async () => {
1112
- const mockTransformToContent = jest.fn().mockReturnValue([
1113
- {
1114
- type: "image_url",
1115
- image_url: { url: "https://example.com/error.png" },
1116
- },
1117
- ]);
1118
- const toolWithTransform = [
1119
- {
1120
- name: "TestComponent",
1121
- component: () => React.createElement("div", null, "Test"),
1122
- description: "Test",
1123
- propsSchema: z.object({ test: z.string() }),
1124
- associatedTools: [
1125
- {
1126
- name: "error-tool",
1127
- tool: jest
1128
- .fn()
1129
- .mockRejectedValue(new Error("Tool execution failed")),
1130
- description: "Tool that errors",
1131
- inputSchema: z.object({ input: z.string() }),
1132
- outputSchema: z.string(),
1133
- transformToContent: mockTransformToContent,
1134
- },
1135
- ],
1136
- },
1137
- ];
1138
- const mockToolCallResponse = {
1139
- responseMessageDto: {
1140
- id: "tool-call-1",
1141
- content: [{ type: "text", text: "Tool call" }],
1142
- role: "tool",
1143
- threadId: "test-thread-1",
1144
- toolCallRequest: {
1145
- toolName: "error-tool",
1146
- parameters: [{ parameterName: "input", parameterValue: "test" }],
1147
- },
1148
- componentState: {},
1149
- createdAt: new Date().toISOString(),
1150
- },
1151
- generationStage: GenerationStage.COMPLETE,
1152
- mcpAccessToken: "test-mcp-access-token",
1153
- };
1154
- jest
1155
- .mocked(mockThreadsApi.advanceByID)
1156
- .mockResolvedValueOnce(mockToolCallResponse)
1157
- .mockResolvedValueOnce({
1158
- responseMessageDto: {
1159
- id: "final-response",
1160
- content: [{ type: "text", text: "Final response" }],
1161
- role: "assistant",
1162
- threadId: "test-thread-1",
1163
- componentState: {},
1164
- createdAt: new Date().toISOString(),
1165
- },
1166
- generationStage: GenerationStage.COMPLETE,
1167
- mcpAccessToken: "test-mcp-access-token",
1168
- });
1169
- const { result } = renderHook(() => useTamboThread(), {
1170
- wrapper: createWrapper({ components: toolWithTransform }),
1171
- });
1172
- await act(async () => {
1173
- await result.current.sendThreadMessage("Use error tool", {
1174
- threadId: "test-thread-1",
1175
- streamResponse: false,
1176
- });
1177
- });
1178
- // Verify the tool was called with single object arg (new inputSchema interface)
1179
- expect(toolWithTransform[0]?.associatedTools?.[0]?.tool).toHaveBeenCalledWith({ input: "test" });
1180
- // Verify transformToContent was NOT called for error responses
1181
- expect(mockTransformToContent).not.toHaveBeenCalled();
1182
- // Verify the second advance call used text content with the error message
1183
- expect(mockThreadsApi.advanceByID).toHaveBeenLastCalledWith("test-thread-1", expect.objectContaining({
1184
- messageToAppend: expect.objectContaining({
1185
- content: [
1186
- expect.objectContaining({
1187
- type: "text",
1188
- // Error message should be in text format
1189
- }),
1190
- ],
1191
- role: "tool",
1192
- }),
1193
- }));
1194
- });
1195
1016
  });
1196
1017
  describe("tamboStreamableHint streaming behavior", () => {
1197
1018
  it("should call streamable tool during streaming when tamboStreamableHint is true", async () => {
@@ -1524,6 +1345,25 @@ describe("TamboThreadProvider", () => {
1524
1345
  });
1525
1346
  describe("auto-generate thread name", () => {
1526
1347
  it("should auto-generate thread name after reaching threshold", async () => {
1348
+ const mockStreamResponse = {
1349
+ responseMessageDto: {
1350
+ id: "response-1",
1351
+ content: [{ type: "text", text: "Response" }],
1352
+ role: "assistant",
1353
+ threadId: "test-thread-1",
1354
+ component: undefined,
1355
+ componentState: {},
1356
+ createdAt: new Date().toISOString(),
1357
+ },
1358
+ generationStage: GenerationStage.COMPLETE,
1359
+ mcpAccessToken: "test-mcp-access-token",
1360
+ };
1361
+ const mockAsyncIterator = {
1362
+ [Symbol.asyncIterator]: async function* () {
1363
+ yield mockStreamResponse;
1364
+ },
1365
+ };
1366
+ jest.mocked(advanceStream).mockResolvedValue(mockAsyncIterator);
1527
1367
  const { result } = renderHook(() => useTamboThread(), {
1528
1368
  wrapper: createWrapper({ autoGenerateNameThreshold: 2 }),
1529
1369
  });
@@ -1555,13 +1395,34 @@ describe("TamboThreadProvider", () => {
1555
1395
  }), false);
1556
1396
  });
1557
1397
  await act(async () => {
1558
- await result.current.sendThreadMessage("Test message");
1398
+ await result.current.sendThreadMessage("Test message", {
1399
+ streamResponse: true,
1400
+ });
1559
1401
  });
1560
1402
  expect(mockThreadsApi.generateName).toHaveBeenCalledWith("test-thread-1");
1561
1403
  expect(result.current.thread.name).toBe("Generated Thread Name");
1562
1404
  expect(mockQueryClient.setQueryData).toHaveBeenCalledWith(["threads", "test-project-id", undefined], expect.any(Function));
1563
1405
  });
1564
1406
  it("should NOT auto-generate when autoGenerateThreadName is false", async () => {
1407
+ const mockStreamResponse = {
1408
+ responseMessageDto: {
1409
+ id: "response-1",
1410
+ content: [{ type: "text", text: "Response" }],
1411
+ role: "assistant",
1412
+ threadId: "test-thread-1",
1413
+ component: undefined,
1414
+ componentState: {},
1415
+ createdAt: new Date().toISOString(),
1416
+ },
1417
+ generationStage: GenerationStage.COMPLETE,
1418
+ mcpAccessToken: "test-mcp-access-token",
1419
+ };
1420
+ const mockAsyncIterator = {
1421
+ [Symbol.asyncIterator]: async function* () {
1422
+ yield mockStreamResponse;
1423
+ },
1424
+ };
1425
+ jest.mocked(advanceStream).mockResolvedValue(mockAsyncIterator);
1565
1426
  const { result } = renderHook(() => useTamboThread(), {
1566
1427
  wrapper: createWrapper({
1567
1428
  autoGenerateThreadName: false,
@@ -1593,12 +1454,33 @@ describe("TamboThreadProvider", () => {
1593
1454
  }), false);
1594
1455
  });
1595
1456
  await act(async () => {
1596
- await result.current.sendThreadMessage("Test message");
1457
+ await result.current.sendThreadMessage("Test message", {
1458
+ streamResponse: true,
1459
+ });
1597
1460
  });
1598
1461
  // Should NOT generate name because feature is disabled
1599
1462
  expect(mockThreadsApi.generateName).not.toHaveBeenCalled();
1600
1463
  });
1601
1464
  it("should NOT auto-generate when thread already has a name", async () => {
1465
+ const mockStreamResponse = {
1466
+ responseMessageDto: {
1467
+ id: "response-1",
1468
+ content: [{ type: "text", text: "Response" }],
1469
+ role: "assistant",
1470
+ threadId: "test-thread-1",
1471
+ component: undefined,
1472
+ componentState: {},
1473
+ createdAt: new Date().toISOString(),
1474
+ },
1475
+ generationStage: GenerationStage.COMPLETE,
1476
+ mcpAccessToken: "test-mcp-access-token",
1477
+ };
1478
+ const mockAsyncIterator = {
1479
+ [Symbol.asyncIterator]: async function* () {
1480
+ yield mockStreamResponse;
1481
+ },
1482
+ };
1483
+ jest.mocked(advanceStream).mockResolvedValue(mockAsyncIterator);
1602
1484
  const { result } = renderHook(() => useTamboThread(), {
1603
1485
  wrapper: createWrapper({ autoGenerateNameThreshold: 2 }),
1604
1486
  });
@@ -1632,7 +1514,9 @@ describe("TamboThreadProvider", () => {
1632
1514
  expect(result.current.thread.messages).toHaveLength(2);
1633
1515
  // Send another message to reach threshold (3 messages total)
1634
1516
  await act(async () => {
1635
- await result.current.sendThreadMessage("Test message");
1517
+ await result.current.sendThreadMessage("Test message", {
1518
+ streamResponse: true,
1519
+ });
1636
1520
  });
1637
1521
  // Should NOT generate name because thread already has one
1638
1522
  expect(mockThreadsApi.generateName).not.toHaveBeenCalled();