@carlonicora/nextjs-jsonapi 1.77.2 → 1.78.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 (103) hide show
  1. package/dist/AssistantInterface-BYgI5z1-.d.mts +12 -0
  2. package/dist/AssistantInterface-DfDcz0gJ.d.ts +12 -0
  3. package/dist/AssistantMessageInterface-D0Kwf8CR.d.mts +36 -0
  4. package/dist/AssistantMessageInterface-DS_tyJTV.d.ts +36 -0
  5. package/dist/{BlockNoteEditor-ILXF7KHN.js → BlockNoteEditor-2G5UYALC.js} +14 -14
  6. package/dist/{BlockNoteEditor-ILXF7KHN.js.map → BlockNoteEditor-2G5UYALC.js.map} +1 -1
  7. package/dist/{BlockNoteEditor-ALVN35PS.mjs → BlockNoteEditor-JXK3JGKJ.mjs} +4 -4
  8. package/dist/billing/index.js +346 -346
  9. package/dist/billing/index.mjs +3 -3
  10. package/dist/{chunk-FKLP4NED.js → chunk-FDJQRIMY.js} +320 -18
  11. package/dist/chunk-FDJQRIMY.js.map +1 -0
  12. package/dist/{chunk-XI35ALWY.mjs → chunk-I65SSQ5Z.mjs} +303 -1
  13. package/dist/chunk-I65SSQ5Z.mjs.map +1 -0
  14. package/dist/{chunk-ICD6MZ43.mjs → chunk-NB6TIKHK.mjs} +2090 -1463
  15. package/dist/chunk-NB6TIKHK.mjs.map +1 -0
  16. package/dist/{chunk-JOJZRGZL.mjs → chunk-NZOUEN67.mjs} +2 -2
  17. package/dist/{chunk-OTZEXASK.js → chunk-X4YDETTD.js} +11 -11
  18. package/dist/{chunk-OTZEXASK.js.map → chunk-X4YDETTD.js.map} +1 -1
  19. package/dist/{chunk-VSWQ7WIV.js → chunk-ZEDB6JVB.js} +1359 -732
  20. package/dist/chunk-ZEDB6JVB.js.map +1 -0
  21. package/dist/client/index.js +4 -4
  22. package/dist/client/index.mjs +3 -3
  23. package/dist/components/index.d.mts +21 -2
  24. package/dist/components/index.d.ts +21 -2
  25. package/dist/components/index.js +10 -4
  26. package/dist/components/index.js.map +1 -1
  27. package/dist/components/index.mjs +9 -3
  28. package/dist/contexts/index.d.mts +26 -2
  29. package/dist/contexts/index.d.ts +26 -2
  30. package/dist/contexts/index.js +8 -4
  31. package/dist/contexts/index.js.map +1 -1
  32. package/dist/contexts/index.mjs +7 -3
  33. package/dist/core/index.d.mts +110 -3
  34. package/dist/core/index.d.ts +110 -3
  35. package/dist/core/index.js +14 -2
  36. package/dist/core/index.js.map +1 -1
  37. package/dist/core/index.mjs +13 -1
  38. package/dist/index.d.mts +3 -2
  39. package/dist/index.d.ts +3 -2
  40. package/dist/index.js +15 -3
  41. package/dist/index.js.map +1 -1
  42. package/dist/index.mjs +14 -2
  43. package/dist/server/index.js +3 -3
  44. package/dist/server/index.mjs +1 -1
  45. package/package.json +1 -1
  46. package/src/components/index.ts +2 -0
  47. package/src/contexts/index.ts +1 -0
  48. package/src/core/index.ts +4 -0
  49. package/src/core/registry/ModuleRegistry.ts +9 -0
  50. package/src/features/assistant/AssistantModule.ts +19 -0
  51. package/src/features/assistant/components/containers/AssistantContainer.tsx +56 -0
  52. package/src/features/assistant/components/containers/__tests__/AssistantContainer.spec.tsx +101 -0
  53. package/src/features/assistant/components/index.ts +1 -0
  54. package/src/features/assistant/components/parts/AssistantComposer.tsx +56 -0
  55. package/src/features/assistant/components/parts/AssistantEmptyState.tsx +47 -0
  56. package/src/features/assistant/components/parts/AssistantSidebar.tsx +64 -0
  57. package/src/features/assistant/components/parts/AssistantStatusLine.tsx +19 -0
  58. package/src/features/assistant/components/parts/AssistantThread.tsx +36 -0
  59. package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +91 -0
  60. package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +32 -0
  61. package/src/features/assistant/components/parts/__tests__/AssistantEmptyState.spec.tsx +27 -0
  62. package/src/features/assistant/components/parts/__tests__/AssistantSidebar.spec.tsx +58 -0
  63. package/src/features/assistant/components/parts/__tests__/AssistantStatusLine.spec.tsx +19 -0
  64. package/src/features/assistant/components/parts/__tests__/AssistantThread.spec.tsx +39 -0
  65. package/src/features/assistant/components/parts/__tests__/AssistantThreadHeader.spec.tsx +67 -0
  66. package/src/features/assistant/contexts/AssistantContext.tsx +255 -0
  67. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +375 -0
  68. package/src/features/assistant/data/Assistant.ts +37 -0
  69. package/src/features/assistant/data/AssistantInterface.ts +11 -0
  70. package/src/features/assistant/data/AssistantService.ts +79 -0
  71. package/src/features/assistant/data/index.ts +3 -0
  72. package/src/features/assistant/index.ts +2 -0
  73. package/src/features/assistant/utils/__tests__/groupThreadsByBucket.spec.ts +24 -0
  74. package/src/features/assistant/utils/__tests__/resolveReferenceableModules.spec.ts +92 -0
  75. package/src/features/assistant/utils/groupThreadsByBucket.ts +26 -0
  76. package/src/features/assistant/utils/resolveReferenceableModules.ts +14 -0
  77. package/src/features/assistant-message/AssistantMessageModule.ts +28 -0
  78. package/src/features/assistant-message/components/MessageItem.tsx +60 -0
  79. package/src/features/assistant-message/components/MessageList.tsx +38 -0
  80. package/src/features/assistant-message/components/__tests__/MessageItem.spec.tsx +108 -0
  81. package/src/features/assistant-message/components/index.ts +2 -0
  82. package/src/features/assistant-message/components/parts/ReferenceBadges.tsx +46 -0
  83. package/src/features/assistant-message/components/parts/SuggestedFollowUps.tsx +52 -0
  84. package/src/features/assistant-message/components/parts/__tests__/ReferenceBadges.spec.tsx +59 -0
  85. package/src/features/assistant-message/components/parts/__tests__/SuggestedFollowUps.spec.tsx +29 -0
  86. package/src/features/assistant-message/data/AssistantMessage.ts +95 -0
  87. package/src/features/assistant-message/data/AssistantMessageInterface.ts +21 -0
  88. package/src/features/assistant-message/data/AssistantMessageService.ts +40 -0
  89. package/src/features/assistant-message/data/__tests__/AssistantMessage.spec.ts +158 -0
  90. package/src/features/assistant-message/data/index.ts +3 -0
  91. package/src/features/assistant-message/index.ts +2 -0
  92. package/src/features/user/contexts/CurrentUserContext.tsx +5 -13
  93. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +141 -0
  94. package/src/hooks/usePushNotifications.ts +3 -0
  95. package/src/index.ts +4 -0
  96. package/dist/HowToInterface-BKhnkzBp.d.ts +0 -17
  97. package/dist/HowToInterface-Cj8OuQFf.d.mts +0 -17
  98. package/dist/chunk-FKLP4NED.js.map +0 -1
  99. package/dist/chunk-ICD6MZ43.mjs.map +0 -1
  100. package/dist/chunk-VSWQ7WIV.js.map +0 -1
  101. package/dist/chunk-XI35ALWY.mjs.map +0 -1
  102. /package/dist/{BlockNoteEditor-ALVN35PS.mjs.map → BlockNoteEditor-JXK3JGKJ.mjs.map} +0 -0
  103. /package/dist/{chunk-JOJZRGZL.mjs.map → chunk-NZOUEN67.mjs.map} +0 -0
@@ -0,0 +1,375 @@
1
+ import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
2
+ import { renderHook, act, waitFor } from "@testing-library/react";
3
+ import { AssistantProvider, useAssistantContext } from "../AssistantContext";
4
+ import { AssistantService } from "../../data/AssistantService";
5
+ import { AssistantMessageService } from "../../../assistant-message/data/AssistantMessageService";
6
+ import { useSocketContext } from "../../../../contexts/SocketContext";
7
+ import type { JsonApiHydratedDataInterface } from "../../../../core";
8
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
9
+ import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry";
10
+ import { AssistantMessage } from "../../../assistant-message/data/AssistantMessage";
11
+ import { Assistant } from "../../data/Assistant";
12
+
13
+ vi.mock("../../../../contexts/SocketContext", () => ({
14
+ useSocketContext: vi.fn(() => ({ socket: null, isConnected: false })),
15
+ }));
16
+
17
+ function wrapper({ children }: { children: React.ReactNode }) {
18
+ return <AssistantProvider>{children}</AssistantProvider>;
19
+ }
20
+
21
+ function buildAssistantStub({ id, title = "Stub" }: { id: string; title?: string }) {
22
+ return { id, title, messageCount: 0, type: "assistants", createdAt: new Date(), updatedAt: new Date() } as any;
23
+ }
24
+
25
+ function buildAssistantDehydrated({
26
+ id,
27
+ title = "Stub",
28
+ }: {
29
+ id: string;
30
+ title?: string;
31
+ }): JsonApiHydratedDataInterface {
32
+ return {
33
+ jsonApi: {
34
+ type: "assistants",
35
+ id,
36
+ attributes: { title, messageCount: 0 },
37
+ },
38
+ included: [],
39
+ };
40
+ }
41
+
42
+ function buildMessageStub({ role, content = "hi" }: { role: "user" | "assistant"; content?: string }) {
43
+ return { role, content, position: 0, type: "assistant-messages", id: Math.random().toString(36).slice(2) } as any;
44
+ }
45
+
46
+ describe("AssistantContext", () => {
47
+ beforeAll(() => {
48
+ const assistantMessageModule = { name: "assistant-messages", model: AssistantMessage } as any;
49
+ const assistantModule = { name: "assistants", model: Assistant } as any;
50
+ ModuleRegistry.register("AssistantMessage", assistantMessageModule);
51
+ ModuleRegistry.register("Assistant", assistantModule);
52
+ DataClassRegistry.registerObjectClass(assistantMessageModule, AssistantMessage);
53
+ DataClassRegistry.registerObjectClass(assistantModule, Assistant);
54
+ });
55
+
56
+ beforeEach(() => {
57
+ vi.mocked(useSocketContext).mockReturnValue({ socket: null, isConnected: false } as any);
58
+ AssistantService.findMany = vi.fn().mockResolvedValue([]);
59
+ });
60
+
61
+ it("initial state: no assistant, empty messages, not sending", () => {
62
+ const { result } = renderHook(() => useAssistantContext(), { wrapper });
63
+ expect(result.current.assistant).toBeUndefined();
64
+ expect(result.current.messages).toEqual([]);
65
+ expect(result.current.sending).toBe(false);
66
+ });
67
+
68
+ it("sendMessage with no assistant: creates one and swaps URL via replaceState", async () => {
69
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
70
+ const created = buildAssistantStub({ id: "a-1", title: "Test" });
71
+ const userMsg = buildMessageStub({ role: "user" });
72
+ const assistantMsg = buildMessageStub({ role: "assistant" });
73
+ AssistantService.create = vi.fn().mockResolvedValue(created);
74
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([userMsg, assistantMsg]);
75
+
76
+ const { result } = renderHook(() => useAssistantContext(), {
77
+ wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
78
+ });
79
+ await act(async () => {
80
+ await result.current.sendMessage("first question");
81
+ });
82
+
83
+ expect(AssistantService.create).toHaveBeenCalledWith({ firstMessage: "first question" });
84
+ expect(AssistantMessageService.findByAssistant).toHaveBeenCalledWith({ assistantId: "a-1" });
85
+ expect(result.current.assistant?.id).toBe("a-1");
86
+ expect(result.current.messages).toHaveLength(2);
87
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants/a-1");
88
+ });
89
+
90
+ it("sendMessage with existing assistant: appends [user, assistant]", async () => {
91
+ const existing = buildAssistantDehydrated({ id: "a-2", title: "Existing" });
92
+ AssistantService.appendMessage = vi
93
+ .fn()
94
+ .mockResolvedValue([
95
+ buildMessageStub({ role: "user", content: "follow-up" }),
96
+ buildMessageStub({ role: "assistant", content: "reply" }),
97
+ ]);
98
+ const { result } = renderHook(() => useAssistantContext(), {
99
+ wrapper: ({ children }) => <AssistantProvider dehydratedAssistant={existing}>{children}</AssistantProvider>,
100
+ });
101
+ await act(async () => {
102
+ await result.current.sendMessage("follow-up");
103
+ });
104
+ expect(AssistantService.appendMessage).toHaveBeenCalledWith({ assistantId: "a-2", content: "follow-up" });
105
+ expect(result.current.messages.map((m) => m.content)).toEqual(["follow-up", "reply"]);
106
+ });
107
+
108
+ it("selectThread loads messages and replaces URL", async () => {
109
+ const target = buildAssistantStub({ id: "a-3", title: "Target" });
110
+ AssistantService.findOne = vi.fn().mockResolvedValue(target);
111
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([buildMessageStub({ role: "user" })]);
112
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
113
+ const { result } = renderHook(() => useAssistantContext(), {
114
+ wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
115
+ });
116
+ await act(async () => {
117
+ await result.current.selectThread("a-3");
118
+ });
119
+ expect(AssistantService.findOne).toHaveBeenCalledWith({ id: "a-3" });
120
+ expect(AssistantMessageService.findByAssistant).toHaveBeenCalledWith({ assistantId: "a-3" });
121
+ expect(result.current.assistant?.id).toBe("a-3");
122
+ expect(result.current.messages).toHaveLength(1);
123
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants/a-3");
124
+ });
125
+
126
+ it("renameThread calls the service + updates active assistant title", async () => {
127
+ AssistantService.rename = vi.fn().mockResolvedValue(undefined);
128
+ const active = buildAssistantDehydrated({ id: "a-1", title: "Old" });
129
+ const { result } = renderHook(() => useAssistantContext(), {
130
+ wrapper: ({ children }) => <AssistantProvider dehydratedAssistant={active}>{children}</AssistantProvider>,
131
+ });
132
+ await act(async () => {
133
+ await result.current.renameThread("a-1", "New");
134
+ });
135
+ expect(AssistantService.rename).toHaveBeenCalledWith({ id: "a-1", title: "New" });
136
+ expect(result.current.assistant?.title).toBe("New");
137
+ });
138
+
139
+ it("deleteThread calls the service + clears active if deleted was active", async () => {
140
+ AssistantService.delete = vi.fn().mockResolvedValue(undefined);
141
+ const active = buildAssistantDehydrated({ id: "a-1", title: "A" });
142
+ const { result } = renderHook(() => useAssistantContext(), {
143
+ wrapper: ({ children }) => <AssistantProvider dehydratedAssistant={active}>{children}</AssistantProvider>,
144
+ });
145
+ await act(async () => {
146
+ await result.current.deleteThread("a-1");
147
+ });
148
+ expect(AssistantService.delete).toHaveBeenCalledWith({ id: "a-1" });
149
+ expect(result.current.assistant).toBeUndefined();
150
+ expect(result.current.messages).toEqual([]);
151
+ });
152
+
153
+ it("startNew clears the active assistant, messages, failed ids, and resets URL to /assistants", async () => {
154
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
155
+ const existing = buildAssistantDehydrated({ id: "a-9", title: "Hydrated" });
156
+ const msg = buildMessageStub({ role: "user", content: "hi" });
157
+ const { result } = renderHook(() => useAssistantContext(), {
158
+ wrapper: ({ children }) => (
159
+ <AssistantProvider
160
+ dehydratedAssistant={existing}
161
+ dehydratedMessages={[
162
+ {
163
+ jsonApi: {
164
+ type: "assistant-messages",
165
+ id: msg.id,
166
+ attributes: { role: "user", content: "hi", position: 0 },
167
+ },
168
+ included: [],
169
+ },
170
+ ]}
171
+ >
172
+ {children}
173
+ </AssistantProvider>
174
+ ),
175
+ });
176
+
177
+ // Seed a failed message id by forcing an append failure.
178
+ AssistantService.appendMessage = vi.fn().mockRejectedValue(new Error("boom"));
179
+ await act(async () => {
180
+ await result.current.sendMessage("will fail").catch(() => {});
181
+ });
182
+ expect(result.current.failedMessageIds.size).toBeGreaterThan(0);
183
+ expect(result.current.assistant?.id).toBe("a-9");
184
+ expect(result.current.messages.length).toBeGreaterThan(0);
185
+
186
+ act(() => {
187
+ result.current.startNew();
188
+ });
189
+
190
+ expect(result.current.assistant).toBeUndefined();
191
+ expect(result.current.messages).toEqual([]);
192
+ expect(result.current.failedMessageIds.size).toBe(0);
193
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants");
194
+ });
195
+
196
+ it("loads threads on mount", async () => {
197
+ const t1 = buildAssistantStub({ id: "t1", title: "T1" });
198
+ AssistantService.findMany = vi.fn().mockResolvedValue([t1]);
199
+ const { result } = renderHook(() => useAssistantContext(), {
200
+ wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
201
+ });
202
+ await waitFor(() => expect(result.current.threads).toEqual([t1]));
203
+ });
204
+
205
+ it("subscribes to assistant:status while sending and unsubscribes after", async () => {
206
+ const handlers: Record<string, (payload: any) => void> = {};
207
+ const socket = {
208
+ on: vi.fn((evt: string, h: any) => {
209
+ handlers[evt] = h;
210
+ }),
211
+ off: vi.fn((evt: string) => {
212
+ delete handlers[evt];
213
+ }),
214
+ };
215
+ vi.mocked(useSocketContext).mockReturnValue({ socket, isConnected: true } as any);
216
+
217
+ const created = buildAssistantStub({ id: "a-4", title: "Stub" });
218
+ AssistantService.create = vi
219
+ .fn()
220
+ .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(created), 20)));
221
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]);
222
+
223
+ const { result } = renderHook(() => useAssistantContext(), {
224
+ wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
225
+ });
226
+ let sendPromise: Promise<void>;
227
+ await act(async () => {
228
+ sendPromise = result.current.sendMessage("go");
229
+ // allow subscription to register before the create resolves
230
+ await Promise.resolve();
231
+ });
232
+ await waitFor(() => expect(socket.on).toHaveBeenCalledWith("assistant:status", expect.any(Function)));
233
+ handlers["assistant:status"]?.({ assistantId: "a-4", status: "Searching accounts", at: new Date().toISOString() });
234
+ await act(async () => {
235
+ await sendPromise!;
236
+ });
237
+ expect(socket.off).toHaveBeenCalledWith("assistant:status", expect.any(Function));
238
+ });
239
+
240
+ it("exposes an empty failedMessageIds set and a retrySend callback", () => {
241
+ const { result } = renderHook(() => useAssistantContext(), { wrapper });
242
+ expect(result.current.failedMessageIds).toBeInstanceOf(Set);
243
+ expect(result.current.failedMessageIds.size).toBe(0);
244
+ expect(typeof result.current.retrySend).toBe("function");
245
+ });
246
+
247
+ it("sendMessage (existing assistant): shows the user bubble synchronously before the server responds", async () => {
248
+ const existing = buildAssistantDehydrated({ id: "a-2", title: "Existing" });
249
+ let resolveAppend!: (value: any) => void;
250
+ AssistantService.appendMessage = vi.fn().mockImplementation(
251
+ () =>
252
+ new Promise((resolve) => {
253
+ resolveAppend = resolve;
254
+ }),
255
+ );
256
+ const { result } = renderHook(() => useAssistantContext(), {
257
+ wrapper: ({ children }) => <AssistantProvider dehydratedAssistant={existing}>{children}</AssistantProvider>,
258
+ });
259
+
260
+ let sendPromise: Promise<void>;
261
+ act(() => {
262
+ sendPromise = result.current.sendMessage("follow-up");
263
+ });
264
+
265
+ // Before the server responds, the optimistic user bubble must be visible.
266
+ expect(result.current.messages.map((m) => m.content)).toContain("follow-up");
267
+ expect(result.current.messages.some((m) => m.id.startsWith("tmp-") && m.role === "user")).toBe(true);
268
+ expect(result.current.sending).toBe(true);
269
+
270
+ await act(async () => {
271
+ resolveAppend([
272
+ buildMessageStub({ role: "user", content: "follow-up" }),
273
+ buildMessageStub({ role: "assistant", content: "reply" }),
274
+ ]);
275
+ await sendPromise!;
276
+ });
277
+
278
+ // After reconciliation, no tmp-* remains, and server messages are appended.
279
+ expect(result.current.messages.some((m) => m.id.startsWith("tmp-"))).toBe(false);
280
+ expect(result.current.messages.map((m) => m.content)).toEqual(["follow-up", "reply"]);
281
+ expect(result.current.sending).toBe(false);
282
+ });
283
+
284
+ it("sendMessage (no assistant): shows the user bubble synchronously before create resolves", async () => {
285
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
286
+ const created = buildAssistantStub({ id: "a-1", title: "Test" });
287
+ const userMsg = buildMessageStub({ role: "user", content: "first question" });
288
+ const assistantMsg = buildMessageStub({ role: "assistant", content: "answer" });
289
+
290
+ let resolveCreate!: (value: any) => void;
291
+ AssistantService.create = vi.fn().mockImplementation(
292
+ () =>
293
+ new Promise((resolve) => {
294
+ resolveCreate = resolve;
295
+ }),
296
+ );
297
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([userMsg, assistantMsg]);
298
+
299
+ const { result } = renderHook(() => useAssistantContext(), {
300
+ wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
301
+ });
302
+
303
+ let sendPromise: Promise<void>;
304
+ act(() => {
305
+ sendPromise = result.current.sendMessage("first question");
306
+ });
307
+
308
+ // Before the server responds: thread has exactly the optimistic user bubble.
309
+ expect(result.current.messages).toHaveLength(1);
310
+ expect(result.current.messages[0].id.startsWith("tmp-")).toBe(true);
311
+ expect(result.current.messages[0].content).toBe("first question");
312
+ expect(result.current.assistant).toBeUndefined();
313
+ expect(result.current.sending).toBe(true);
314
+
315
+ await act(async () => {
316
+ resolveCreate(created);
317
+ await sendPromise!;
318
+ });
319
+
320
+ // After reconciliation: assistant set, URL replaced, server messages only.
321
+ expect(result.current.assistant?.id).toBe("a-1");
322
+ expect(result.current.messages.some((m) => m.id.startsWith("tmp-"))).toBe(false);
323
+ expect(result.current.messages).toHaveLength(2);
324
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants/a-1");
325
+ });
326
+
327
+ it("sendMessage failure: optimistic message stays and its id lands in failedMessageIds", async () => {
328
+ const existing = buildAssistantDehydrated({ id: "a-x", title: "Ex" });
329
+ AssistantService.appendMessage = vi.fn().mockRejectedValue(new Error("boom"));
330
+ const { result } = renderHook(() => useAssistantContext(), {
331
+ wrapper: ({ children }) => <AssistantProvider dehydratedAssistant={existing}>{children}</AssistantProvider>,
332
+ });
333
+
334
+ await act(async () => {
335
+ await result.current.sendMessage("oops").catch(() => {});
336
+ });
337
+
338
+ const optimistic = result.current.messages.find((m) => m.id.startsWith("tmp-"));
339
+ expect(optimistic).toBeDefined();
340
+ expect(optimistic!.content).toBe("oops");
341
+ expect(result.current.failedMessageIds.has(optimistic!.id)).toBe(true);
342
+ expect(result.current.sending).toBe(false);
343
+ });
344
+
345
+ it("retrySend: clears the failed id, removes the old tmp message, and resends the content", async () => {
346
+ const existing = buildAssistantDehydrated({ id: "a-y", title: "Ey" });
347
+ const appendMock = vi
348
+ .fn()
349
+ .mockRejectedValueOnce(new Error("fail-1"))
350
+ .mockResolvedValueOnce([
351
+ buildMessageStub({ role: "user", content: "retry-me" }),
352
+ buildMessageStub({ role: "assistant", content: "ok" }),
353
+ ]);
354
+ AssistantService.appendMessage = appendMock;
355
+
356
+ const { result } = renderHook(() => useAssistantContext(), {
357
+ wrapper: ({ children }) => <AssistantProvider dehydratedAssistant={existing}>{children}</AssistantProvider>,
358
+ });
359
+
360
+ await act(async () => {
361
+ await result.current.sendMessage("retry-me").catch(() => {});
362
+ });
363
+ const failedId = [...result.current.failedMessageIds][0];
364
+ expect(failedId).toBeDefined();
365
+
366
+ await act(async () => {
367
+ await result.current.retrySend(failedId!);
368
+ });
369
+
370
+ expect(result.current.failedMessageIds.has(failedId!)).toBe(false);
371
+ expect(result.current.messages.some((m) => m.id.startsWith("tmp-"))).toBe(false);
372
+ expect(result.current.messages.map((m) => m.content)).toEqual(["retry-me", "ok"]);
373
+ expect(appendMock).toHaveBeenCalledTimes(2);
374
+ });
375
+ });
@@ -0,0 +1,37 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { AssistantInput, AssistantInterface } from "./AssistantInterface";
3
+
4
+ export class Assistant extends AbstractApiData implements AssistantInterface {
5
+ private _title?: string;
6
+ private _messageCount?: number;
7
+
8
+ get title(): string {
9
+ return this._title ?? "";
10
+ }
11
+
12
+ get messageCount(): number {
13
+ return this._messageCount ?? 0;
14
+ }
15
+
16
+ rehydrate(data: JsonApiHydratedDataInterface): this {
17
+ super.rehydrate(data);
18
+ this._title = data.jsonApi.attributes?.title;
19
+ const fromMeta = data.jsonApi.meta?.messageCount;
20
+ const fromAttrs = data.jsonApi.attributes?.messageCount;
21
+ this._messageCount = typeof fromMeta === "number" ? fromMeta : typeof fromAttrs === "number" ? fromAttrs : 0;
22
+ return this;
23
+ }
24
+
25
+ createJsonApi(data: AssistantInput) {
26
+ return {
27
+ data: {
28
+ type: Modules.Assistant.name,
29
+ attributes: {
30
+ content: data.firstMessage,
31
+ ...(data.title !== undefined ? { title: data.title } : {}),
32
+ },
33
+ },
34
+ included: [],
35
+ };
36
+ }
37
+ }
@@ -0,0 +1,11 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+
3
+ export type AssistantInput = {
4
+ firstMessage: string;
5
+ title?: string;
6
+ };
7
+
8
+ export interface AssistantInterface extends ApiDataInterface {
9
+ get title(): string;
10
+ get messageCount(): number;
11
+ }
@@ -0,0 +1,79 @@
1
+ import { AbstractService, EndpointCreator, HttpMethod, Modules } from "../../../core";
2
+ import { AssistantInput, AssistantInterface } from "./AssistantInterface";
3
+ import { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
4
+
5
+ export class AssistantService extends AbstractService {
6
+ static async findOne(params: { id: string }): Promise<AssistantInterface> {
7
+ return this.callApi<AssistantInterface>({
8
+ type: Modules.Assistant,
9
+ method: HttpMethod.GET,
10
+ endpoint: new EndpointCreator({ endpoint: Modules.Assistant, id: params.id }).generate(),
11
+ });
12
+ }
13
+
14
+ static async findMany(params: { fetchAll?: boolean } = {}): Promise<AssistantInterface[]> {
15
+ const endpoint = new EndpointCreator({ endpoint: Modules.Assistant });
16
+ if (params.fetchAll) endpoint.addAdditionalParam("fetchAll", "true");
17
+ return this.callApi({
18
+ type: Modules.Assistant,
19
+ method: HttpMethod.GET,
20
+ endpoint: endpoint.generate(),
21
+ });
22
+ }
23
+
24
+ static async create(params: AssistantInput): Promise<AssistantInterface> {
25
+ return this.callApi({
26
+ type: Modules.Assistant,
27
+ method: HttpMethod.POST,
28
+ endpoint: new EndpointCreator({ endpoint: Modules.Assistant }).generate(),
29
+ input: params,
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Sends a new user message to an existing assistant thread. The agent turn
35
+ * runs synchronously; the response is a two-element list: [user, assistant].
36
+ */
37
+ static async appendMessage(params: { assistantId: string; content: string }): Promise<AssistantMessageInterface[]> {
38
+ return this.callApi<AssistantMessageInterface[]>({
39
+ type: Modules.AssistantMessage,
40
+ method: HttpMethod.POST,
41
+ endpoint: new EndpointCreator({
42
+ endpoint: Modules.Assistant,
43
+ id: params.assistantId,
44
+ childEndpoint: Modules.AssistantMessage,
45
+ }).generate(),
46
+ input: {
47
+ data: {
48
+ type: Modules.AssistantMessage.name,
49
+ attributes: { content: params.content },
50
+ },
51
+ },
52
+ overridesJsonApiCreation: true,
53
+ });
54
+ }
55
+
56
+ static async rename(params: { id: string; title: string }): Promise<void> {
57
+ await this.callApi({
58
+ type: Modules.Assistant,
59
+ method: HttpMethod.PATCH,
60
+ endpoint: new EndpointCreator({ endpoint: Modules.Assistant, id: params.id }).generate(),
61
+ input: {
62
+ data: {
63
+ type: Modules.Assistant.name,
64
+ id: params.id,
65
+ attributes: { title: params.title },
66
+ },
67
+ },
68
+ overridesJsonApiCreation: true,
69
+ });
70
+ }
71
+
72
+ static async delete(params: { id: string }): Promise<void> {
73
+ await this.callApi({
74
+ type: Modules.Assistant,
75
+ method: HttpMethod.DELETE,
76
+ endpoint: new EndpointCreator({ endpoint: Modules.Assistant, id: params.id }).generate(),
77
+ });
78
+ }
79
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./Assistant";
2
+ export * from "./AssistantInterface";
3
+ export * from "./AssistantService";
@@ -0,0 +1,2 @@
1
+ export * from "./AssistantModule";
2
+ export * from "./data";
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2
+ import { groupThreadsByBucket } from "../groupThreadsByBucket";
3
+
4
+ describe("groupThreadsByBucket", () => {
5
+ beforeEach(() => vi.useFakeTimers().setSystemTime(new Date("2026-04-22T12:00:00Z")));
6
+ afterEach(() => vi.useRealTimers());
7
+
8
+ it("buckets today, thisWeek, earlier by updatedAt", () => {
9
+ const today = { id: "1", updatedAt: new Date("2026-04-22T09:00:00Z") } as any;
10
+ const weekAgo2 = { id: "2", updatedAt: new Date("2026-04-20T09:00:00Z") } as any;
11
+ const earlier = { id: "3", updatedAt: new Date("2026-03-01T09:00:00Z") } as any;
12
+ const grouped = groupThreadsByBucket([today, weekAgo2, earlier]);
13
+ expect(grouped.today).toEqual([today]);
14
+ expect(grouped.thisWeek).toEqual([weekAgo2]);
15
+ expect(grouped.earlier).toEqual([earlier]);
16
+ });
17
+
18
+ it("sorts each bucket by updatedAt desc", () => {
19
+ const a = { id: "a", updatedAt: new Date("2026-04-22T09:00:00Z") } as any;
20
+ const b = { id: "b", updatedAt: new Date("2026-04-22T10:00:00Z") } as any;
21
+ const grouped = groupThreadsByBucket([a, b]);
22
+ expect(grouped.today).toEqual([b, a]);
23
+ });
24
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { AbstractApiData } from "../../../../core/abstracts/AbstractApiData";
3
+ import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry";
4
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
5
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
6
+ import { JsonApiHydratedDataInterface } from "../../../../core/interfaces/JsonApiHydratedDataInterface";
7
+ import { resolveReferenceableModules } from "../resolveReferenceableModules";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Test-only model WITH identifierFields (default: ["name"]) — should be included
11
+ // ---------------------------------------------------------------------------
12
+ class WithIdentifier extends AbstractApiData {
13
+ static identifierFields: string[] = ["name"];
14
+
15
+ rehydrate(data: JsonApiHydratedDataInterface): this {
16
+ super.rehydrate(data);
17
+ return this;
18
+ }
19
+
20
+ createJsonApi(_data?: any): any {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Test-only model WITHOUT identifierFields (forced to []) — should be excluded
27
+ // ---------------------------------------------------------------------------
28
+ class WithoutIdentifier extends AbstractApiData {
29
+ static identifierFields: string[] = [];
30
+
31
+ rehydrate(data: JsonApiHydratedDataInterface): this {
32
+ super.rehydrate(data);
33
+ return this;
34
+ }
35
+
36
+ createJsonApi(_data?: any): any {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ const withIdentifierModule: ApiRequestDataTypeInterface = {
42
+ name: "test-with-identifier",
43
+ model: WithIdentifier,
44
+ } as any;
45
+
46
+ const withoutIdentifierModule: ApiRequestDataTypeInterface = {
47
+ name: "test-without-identifier",
48
+ model: WithoutIdentifier,
49
+ } as any;
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Setup / teardown
53
+ // ---------------------------------------------------------------------------
54
+ beforeAll(() => {
55
+ DataClassRegistry.clear();
56
+ DataClassRegistry.registerObjectClass(withIdentifierModule, WithIdentifier);
57
+ DataClassRegistry.registerObjectClass(withoutIdentifierModule, WithoutIdentifier);
58
+ ModuleRegistry.register("TestWithIdentifier", withIdentifierModule);
59
+ ModuleRegistry.register("TestWithoutIdentifier", withoutIdentifierModule);
60
+ });
61
+
62
+ afterAll(() => {
63
+ DataClassRegistry.clear();
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Tests
68
+ // ---------------------------------------------------------------------------
69
+ describe("resolveReferenceableModules", () => {
70
+ it("returns an array of ApiRequestDataTypeInterface-shaped entries (has .name: string)", () => {
71
+ const result = resolveReferenceableModules();
72
+
73
+ expect(Array.isArray(result)).toBe(true);
74
+ for (const entry of result) {
75
+ expect(typeof entry.name).toBe("string");
76
+ }
77
+ });
78
+
79
+ it("includes modules whose model class has non-empty identifierFields", () => {
80
+ const result = resolveReferenceableModules();
81
+
82
+ const names = result.map((m) => m.name);
83
+ expect(names).toContain("test-with-identifier");
84
+ });
85
+
86
+ it("excludes modules whose model class has empty identifierFields", () => {
87
+ const result = resolveReferenceableModules();
88
+
89
+ const names = result.map((m) => m.name);
90
+ expect(names).not.toContain("test-without-identifier");
91
+ });
92
+ });
@@ -0,0 +1,26 @@
1
+ import type { AssistantInterface } from "../data/AssistantInterface";
2
+
3
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
4
+
5
+ export function groupThreadsByBucket(threads: AssistantInterface[]): {
6
+ today: AssistantInterface[];
7
+ thisWeek: AssistantInterface[];
8
+ earlier: AssistantInterface[];
9
+ } {
10
+ const now = new Date();
11
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
12
+ const startOfWeek = startOfToday - 7 * MS_PER_DAY;
13
+
14
+ const sorted = [...threads].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
15
+
16
+ const today: AssistantInterface[] = [];
17
+ const thisWeek: AssistantInterface[] = [];
18
+ const earlier: AssistantInterface[] = [];
19
+ for (const t of sorted) {
20
+ const ts = t.updatedAt.getTime();
21
+ if (ts >= startOfToday) today.push(t);
22
+ else if (ts >= startOfWeek) thisWeek.push(t);
23
+ else earlier.push(t);
24
+ }
25
+ return { today, thisWeek, earlier };
26
+ }
@@ -0,0 +1,14 @@
1
+ import type { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
2
+ import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
3
+
4
+ /**
5
+ * Returns every registered Module whose model class has a non-empty `identifierFields`
6
+ * (so `entity.identifier` produces a meaningful string). Lazy — reads at call time so
7
+ * modules registered after import-time are picked up.
8
+ */
9
+ export function resolveReferenceableModules(): ApiRequestDataTypeInterface[] {
10
+ return ModuleRegistry.getAll().filter((m) => {
11
+ const cls = m.model as any;
12
+ return cls && Array.isArray(cls.identifierFields) && cls.identifierFields.length > 0;
13
+ });
14
+ }