convex-durable-agents 0.2.4 → 0.2.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.
- package/README.md +1 -0
- package/dist/client/handler.d.ts +4 -0
- package/dist/client/handler.d.ts.map +1 -1
- package/dist/client/handler.js +25 -3
- package/dist/client/handler.js.map +1 -1
- package/dist/client/streamer.d.ts +3 -1
- package/dist/client/streamer.d.ts.map +1 -1
- package/dist/client/streamer.js +9 -3
- package/dist/client/streamer.js.map +1 -1
- package/dist/component/tool_calls.d.ts +1 -0
- package/dist/component/tool_calls.d.ts.map +1 -1
- package/dist/component/tool_calls.js +7 -0
- package/dist/component/tool_calls.js.map +1 -1
- package/dist/react/test/happy-dom-setup.d.ts +2 -0
- package/dist/react/test/happy-dom-setup.d.ts.map +1 -0
- package/dist/react/test/happy-dom-setup.js +28 -0
- package/dist/react/test/happy-dom-setup.js.map +1 -0
- package/dist/utils/msg.d.ts +3 -0
- package/dist/utils/msg.d.ts.map +1 -0
- package/dist/utils/msg.js +7 -0
- package/dist/utils/msg.js.map +1 -0
- package/package.json +24 -21
- package/src/client/handler.ts +33 -2
- package/src/client/streamer.test.ts +187 -0
- package/src/client/streamer.ts +10 -3
- package/src/client/tools.test.ts +48 -0
- package/src/component/messages.test.ts +40 -0
- package/src/component/streams.test.ts +118 -0
- package/src/component/threads.test.ts +48 -0
- package/src/component/tool_calls.ts +9 -0
- package/src/react/__fixtures__/01-early-streaming-start.json +35 -0
- package/src/react/__fixtures__/02-reasoning-complete-tool-call.json +85 -0
- package/src/react/__fixtures__/03-new-round-seq2.json +89 -0
- package/src/react/__fixtures__/04-tool-call-error-seq3.json +145 -0
- package/src/react/__fixtures__/05-later-round-seq5.json +117 -0
- package/src/react/__fixtures__/06-text-streaming-seq6.json +162 -0
- package/src/react/__fixtures__/07-text-streaming-more-seq6.json +212 -0
- package/src/react/__fixtures__/08-fully-committed-seq6.json +188 -0
- package/src/react/__snapshots__/apply-streaming-updates.test.ts.snap +1357 -0
- package/src/react/__snapshots__/use-thread-messages.test.tsx.snap +1429 -0
- package/src/react/agent-chat.test.tsx +155 -0
- package/src/react/apply-streaming-updates.test.ts +28 -0
- package/src/react/test/happy-dom-setup.ts +31 -0
- package/src/react/use-thread-messages.test.tsx +702 -0
- package/src/utils/msg.test.ts +34 -0
- package/src/utils/msg.ts +8 -0
- package/src/utils/retry.test.ts +214 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import "./test/happy-dom-setup";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
6
|
+
import type { MessageDoc, StreamingMessageUpdates, ThreadDoc } from "./types";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Sentinel query references – opaque objects the hook receives as props.
|
|
10
|
+
// The mock useQuery inspects these to dispatch to the right mock function.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const messagesQueryRef = { __brand: "messagesQuery" } as any;
|
|
13
|
+
const threadQueryRef = { __brand: "threadQuery" } as any;
|
|
14
|
+
const streamingQueryRef = { __brand: "streamingQuery" } as any;
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Per-call mock return values (set in each test / beforeEach)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
let messagesReturn: MessageDoc[] | undefined;
|
|
20
|
+
let threadReturn: ThreadDoc | null | undefined;
|
|
21
|
+
let streamingReturn: StreamingMessageUpdates | undefined;
|
|
22
|
+
|
|
23
|
+
/** Track all useQuery calls so we can assert on `"skip"` args */
|
|
24
|
+
let useQueryCalls: Array<[ref: unknown, args: unknown]> = [];
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Mock convex/react before importing the hook
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
vi.doMock("convex/react", () => ({
|
|
30
|
+
useQuery: (ref: unknown, args: unknown) => {
|
|
31
|
+
useQueryCalls.push([ref, args]);
|
|
32
|
+
if (args === "skip") return undefined;
|
|
33
|
+
if (ref === messagesQueryRef) return messagesReturn;
|
|
34
|
+
if (ref === threadQueryRef) return threadReturn;
|
|
35
|
+
if (ref === streamingQueryRef) return streamingReturn;
|
|
36
|
+
return undefined;
|
|
37
|
+
},
|
|
38
|
+
useMutation: () => {
|
|
39
|
+
throw new Error("useMutation is not mocked in use-thread-messages tests");
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// Import AFTER mocking
|
|
44
|
+
const { useThreadMessages, useMessages } = await import("./use-thread-messages");
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Fixtures
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const FIXTURES_DIR = join(import.meta.dirname!, "__fixtures__");
|
|
51
|
+
|
|
52
|
+
type FixtureMessage = {
|
|
53
|
+
id: string;
|
|
54
|
+
role: string;
|
|
55
|
+
parts: any[];
|
|
56
|
+
metadata?: {
|
|
57
|
+
key?: string;
|
|
58
|
+
status?: string;
|
|
59
|
+
_creationTime?: number;
|
|
60
|
+
committedSeq?: number;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type Fixture = {
|
|
65
|
+
description: string;
|
|
66
|
+
logLine: number;
|
|
67
|
+
messages: FixtureMessage[];
|
|
68
|
+
streamingUpdates: StreamingMessageUpdates;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const fixtureFiles = readdirSync(FIXTURES_DIR)
|
|
72
|
+
.filter((f) => f.endsWith(".json"))
|
|
73
|
+
.sort();
|
|
74
|
+
|
|
75
|
+
/** Convert a fixture message (UIMessageWithConvexMetadata shape) back to a MessageDoc */
|
|
76
|
+
function fixtureMessageToDoc(msg: FixtureMessage, threadId: string): MessageDoc {
|
|
77
|
+
return {
|
|
78
|
+
_id: `doc-${msg.id}`,
|
|
79
|
+
_creationTime: msg.metadata?._creationTime ?? 0,
|
|
80
|
+
threadId,
|
|
81
|
+
id: msg.id,
|
|
82
|
+
role: msg.role as MessageDoc["role"],
|
|
83
|
+
parts: msg.parts,
|
|
84
|
+
committedSeq: msg.metadata?.committedSeq,
|
|
85
|
+
metadata: msg.metadata,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Helpers
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
function makeUserMessage(overrides: Partial<MessageDoc> = {}): MessageDoc {
|
|
94
|
+
return {
|
|
95
|
+
_id: "doc1",
|
|
96
|
+
_creationTime: 1000,
|
|
97
|
+
threadId: "thread-1",
|
|
98
|
+
id: "msg-user-1",
|
|
99
|
+
role: "user",
|
|
100
|
+
parts: [{ type: "text" as const, text: "hello" }],
|
|
101
|
+
...overrides,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function makeAssistantMessage(overrides: Partial<MessageDoc> = {}): MessageDoc {
|
|
106
|
+
return {
|
|
107
|
+
_id: "doc2",
|
|
108
|
+
_creationTime: 2000,
|
|
109
|
+
threadId: "thread-1",
|
|
110
|
+
id: "msg-asst-1",
|
|
111
|
+
role: "assistant",
|
|
112
|
+
parts: [{ type: "text" as const, text: "hi there" }],
|
|
113
|
+
committedSeq: 1,
|
|
114
|
+
...overrides,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function makeThread(overrides: Partial<ThreadDoc> = {}): ThreadDoc {
|
|
119
|
+
return {
|
|
120
|
+
_id: "thread-doc-1",
|
|
121
|
+
_creationTime: 500,
|
|
122
|
+
status: "completed",
|
|
123
|
+
stopSignal: false,
|
|
124
|
+
...overrides,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Reset state between tests
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
messagesReturn = undefined;
|
|
133
|
+
threadReturn = undefined;
|
|
134
|
+
streamingReturn = undefined;
|
|
135
|
+
useQueryCalls = [];
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ===================================================================
|
|
139
|
+
// Tests
|
|
140
|
+
// ===================================================================
|
|
141
|
+
|
|
142
|
+
describe("useThreadMessages", () => {
|
|
143
|
+
// -----------------------------------------------------------------
|
|
144
|
+
// Loading state
|
|
145
|
+
// -----------------------------------------------------------------
|
|
146
|
+
describe("loading state", () => {
|
|
147
|
+
it("returns isLoading: true and empty messages when queries return undefined", () => {
|
|
148
|
+
// messagesReturn is undefined (default)
|
|
149
|
+
const { result } = renderHook(() =>
|
|
150
|
+
useThreadMessages({
|
|
151
|
+
messagesQuery: messagesQueryRef,
|
|
152
|
+
threadQuery: threadQueryRef,
|
|
153
|
+
streamingMessageUpdatesQuery: streamingQueryRef,
|
|
154
|
+
threadId: "thread-1",
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
expect(result.current.isLoading).toBe(true);
|
|
159
|
+
expect(result.current.messages).toEqual([]);
|
|
160
|
+
expect(result.current.thread).toBeUndefined();
|
|
161
|
+
expect(result.current.status).toBeUndefined();
|
|
162
|
+
expect(result.current.isRunning).toBe(false);
|
|
163
|
+
expect(result.current.isComplete).toBe(false);
|
|
164
|
+
expect(result.current.isFailed).toBe(false);
|
|
165
|
+
expect(result.current.isStopped).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// -----------------------------------------------------------------
|
|
170
|
+
// Messages-only path (no streaming query)
|
|
171
|
+
// -----------------------------------------------------------------
|
|
172
|
+
describe("messages without streaming", () => {
|
|
173
|
+
it("returns persisted messages with addConvexMetadata when streamingMessageUpdatesQuery is null", () => {
|
|
174
|
+
const userMsg = makeUserMessage();
|
|
175
|
+
messagesReturn = [userMsg];
|
|
176
|
+
threadReturn = makeThread({ status: "completed" });
|
|
177
|
+
|
|
178
|
+
const { result } = renderHook(() =>
|
|
179
|
+
useThreadMessages({
|
|
180
|
+
messagesQuery: messagesQueryRef,
|
|
181
|
+
threadQuery: threadQueryRef,
|
|
182
|
+
streamingMessageUpdatesQuery: null as any,
|
|
183
|
+
threadId: "thread-1",
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(result.current.isLoading).toBe(false);
|
|
188
|
+
expect(result.current.messages).toHaveLength(1);
|
|
189
|
+
|
|
190
|
+
const msg = result.current.messages[0]!;
|
|
191
|
+
expect(msg.id).toBe("msg-user-1");
|
|
192
|
+
expect(msg.role).toBe("user");
|
|
193
|
+
expect(msg.parts).toEqual([{ type: "text", text: "hello" }]);
|
|
194
|
+
// addConvexMetadata should have applied
|
|
195
|
+
expect((msg as any).metadata?.key).toBe("thread-1-msg-user-1");
|
|
196
|
+
expect((msg as any).metadata?.status).toBe("success");
|
|
197
|
+
expect((msg as any).metadata?._creationTime).toBe(1000);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns multiple messages in order", () => {
|
|
201
|
+
messagesReturn = [makeUserMessage(), makeAssistantMessage()];
|
|
202
|
+
threadReturn = makeThread();
|
|
203
|
+
|
|
204
|
+
const { result } = renderHook(() =>
|
|
205
|
+
useThreadMessages({
|
|
206
|
+
messagesQuery: messagesQueryRef,
|
|
207
|
+
threadQuery: threadQueryRef,
|
|
208
|
+
streamingMessageUpdatesQuery: null as any,
|
|
209
|
+
threadId: "thread-1",
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(result.current.messages).toHaveLength(2);
|
|
214
|
+
expect(result.current.messages[0]!.role).toBe("user");
|
|
215
|
+
expect(result.current.messages[1]!.role).toBe("assistant");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// -----------------------------------------------------------------
|
|
220
|
+
// Streaming merge
|
|
221
|
+
// -----------------------------------------------------------------
|
|
222
|
+
describe("streaming updates merge", () => {
|
|
223
|
+
it("merges streaming text deltas into persisted messages", async () => {
|
|
224
|
+
const userMsg = makeUserMessage();
|
|
225
|
+
const assistantMsg = makeAssistantMessage({
|
|
226
|
+
id: "msg-asst-1",
|
|
227
|
+
parts: [{ type: "text" as const, text: "partial" }],
|
|
228
|
+
committedSeq: 1,
|
|
229
|
+
});
|
|
230
|
+
messagesReturn = [userMsg, assistantMsg];
|
|
231
|
+
threadReturn = makeThread({ status: "streaming" });
|
|
232
|
+
streamingReturn = {
|
|
233
|
+
messages: [
|
|
234
|
+
{
|
|
235
|
+
msgId: "msg-asst-1",
|
|
236
|
+
parts: [
|
|
237
|
+
{ messageId: "msg-asst-1", seq: 2, type: "start" },
|
|
238
|
+
{ seq: 2, type: "start-step" },
|
|
239
|
+
{ id: "0", seq: 2, type: "text-start" },
|
|
240
|
+
{ delta: " more text here", id: "0", seq: 2, type: "text-delta" },
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const { result } = renderHook(() =>
|
|
247
|
+
useThreadMessages({
|
|
248
|
+
messagesQuery: messagesQueryRef,
|
|
249
|
+
threadQuery: threadQueryRef,
|
|
250
|
+
streamingMessageUpdatesQuery: streamingQueryRef,
|
|
251
|
+
threadId: "thread-1",
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Wait for the async applyStreamingUpdates to resolve
|
|
256
|
+
await waitFor(() => {
|
|
257
|
+
// The streaming merge should produce a message with extra parts from the stream
|
|
258
|
+
const assistantMessages = result.current.messages.filter((m) => m.role === "assistant");
|
|
259
|
+
expect(assistantMessages.length).toBeGreaterThanOrEqual(1);
|
|
260
|
+
// The merged message should contain the streamed text
|
|
261
|
+
const allText = assistantMessages
|
|
262
|
+
.flatMap((m) => m.parts)
|
|
263
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
264
|
+
.map((p) => p.text)
|
|
265
|
+
.join("");
|
|
266
|
+
expect(allText).toContain("more text here");
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("creates a new assistant message if msgId is not in persisted messages", async () => {
|
|
271
|
+
const userMsg = makeUserMessage();
|
|
272
|
+
messagesReturn = [userMsg];
|
|
273
|
+
threadReturn = makeThread({ status: "streaming" });
|
|
274
|
+
streamingReturn = {
|
|
275
|
+
messages: [
|
|
276
|
+
{
|
|
277
|
+
msgId: "new-assistant-msg",
|
|
278
|
+
parts: [
|
|
279
|
+
{ messageId: "new-assistant-msg", seq: 1, type: "start" },
|
|
280
|
+
{ seq: 1, type: "start-step" },
|
|
281
|
+
{ id: "0", seq: 1, type: "text-start" },
|
|
282
|
+
{ delta: "hello from streaming", id: "0", seq: 1, type: "text-delta" },
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const { result } = renderHook(() =>
|
|
289
|
+
useThreadMessages({
|
|
290
|
+
messagesQuery: messagesQueryRef,
|
|
291
|
+
threadQuery: threadQueryRef,
|
|
292
|
+
streamingMessageUpdatesQuery: streamingQueryRef,
|
|
293
|
+
threadId: "thread-1",
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
await waitFor(() => {
|
|
298
|
+
expect(result.current.messages.length).toBeGreaterThan(1);
|
|
299
|
+
const newMsg = result.current.messages.find((m) => m.id === "new-assistant-msg");
|
|
300
|
+
expect(newMsg).toBeDefined();
|
|
301
|
+
expect(newMsg!.role).toBe("assistant");
|
|
302
|
+
const texts = newMsg!.parts
|
|
303
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
304
|
+
.map((p) => p.text)
|
|
305
|
+
.join("");
|
|
306
|
+
expect(texts).toContain("hello from streaming");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("skips streaming parts whose seq <= committedSeq", async () => {
|
|
311
|
+
const assistantMsg = makeAssistantMessage({
|
|
312
|
+
id: "msg-asst-1",
|
|
313
|
+
parts: [{ type: "text" as const, text: "committed text" }],
|
|
314
|
+
committedSeq: 5,
|
|
315
|
+
});
|
|
316
|
+
messagesReturn = [makeUserMessage(), assistantMsg];
|
|
317
|
+
threadReturn = makeThread({ status: "streaming" });
|
|
318
|
+
streamingReturn = {
|
|
319
|
+
messages: [
|
|
320
|
+
{
|
|
321
|
+
msgId: "msg-asst-1",
|
|
322
|
+
parts: [
|
|
323
|
+
// seq 4 and 5 should be filtered out (already committed)
|
|
324
|
+
{ messageId: "msg-asst-1", seq: 4, type: "start" },
|
|
325
|
+
{ id: "0", seq: 5, type: "text-start" },
|
|
326
|
+
{ delta: "should be filtered", id: "0", seq: 5, type: "text-delta" },
|
|
327
|
+
// seq 6 should pass through
|
|
328
|
+
{ messageId: "msg-asst-1", seq: 6, type: "start" },
|
|
329
|
+
{ seq: 6, type: "start-step" },
|
|
330
|
+
{ id: "1", seq: 6, type: "text-start" },
|
|
331
|
+
{ delta: "fresh content", id: "1", seq: 6, type: "text-delta" },
|
|
332
|
+
],
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const { result } = renderHook(() =>
|
|
338
|
+
useThreadMessages({
|
|
339
|
+
messagesQuery: messagesQueryRef,
|
|
340
|
+
threadQuery: threadQueryRef,
|
|
341
|
+
streamingMessageUpdatesQuery: streamingQueryRef,
|
|
342
|
+
threadId: "thread-1",
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
await waitFor(() => {
|
|
347
|
+
const asst = result.current.messages.find((m) => m.id === "msg-asst-1");
|
|
348
|
+
expect(asst).toBeDefined();
|
|
349
|
+
const allText = asst!.parts
|
|
350
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
351
|
+
.map((p) => p.text)
|
|
352
|
+
.join("");
|
|
353
|
+
expect(allText).toContain("fresh content");
|
|
354
|
+
// "should be filtered" should not appear as a new text part —
|
|
355
|
+
// the committed text remains from the base, but the streaming delta is skipped
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("returns persisted messages when streaming updates have empty messages array", () => {
|
|
360
|
+
messagesReturn = [makeUserMessage()];
|
|
361
|
+
threadReturn = makeThread({ status: "completed" });
|
|
362
|
+
streamingReturn = { messages: [] };
|
|
363
|
+
|
|
364
|
+
const { result } = renderHook(() =>
|
|
365
|
+
useThreadMessages({
|
|
366
|
+
messagesQuery: messagesQueryRef,
|
|
367
|
+
threadQuery: threadQueryRef,
|
|
368
|
+
streamingMessageUpdatesQuery: streamingQueryRef,
|
|
369
|
+
threadId: "thread-1",
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
// No async merge needed — empty streaming updates fall through to persisted
|
|
374
|
+
expect(result.current.messages).toHaveLength(1);
|
|
375
|
+
expect(result.current.messages[0]!.id).toBe("msg-user-1");
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// -----------------------------------------------------------------
|
|
380
|
+
// Status flags
|
|
381
|
+
// -----------------------------------------------------------------
|
|
382
|
+
describe("status flags", () => {
|
|
383
|
+
it.each([
|
|
384
|
+
{ status: "streaming" as const, isRunning: true, isComplete: false, isFailed: false, isStopped: false },
|
|
385
|
+
{
|
|
386
|
+
status: "awaiting_tool_results" as const,
|
|
387
|
+
isRunning: true,
|
|
388
|
+
isComplete: false,
|
|
389
|
+
isFailed: false,
|
|
390
|
+
isStopped: false,
|
|
391
|
+
},
|
|
392
|
+
{ status: "completed" as const, isRunning: false, isComplete: true, isFailed: false, isStopped: false },
|
|
393
|
+
{ status: "failed" as const, isRunning: false, isComplete: false, isFailed: true, isStopped: false },
|
|
394
|
+
{ status: "stopped" as const, isRunning: false, isComplete: false, isFailed: false, isStopped: true },
|
|
395
|
+
])('derives correct flags for thread status "$status"', ({
|
|
396
|
+
status,
|
|
397
|
+
isRunning,
|
|
398
|
+
isComplete,
|
|
399
|
+
isFailed,
|
|
400
|
+
isStopped,
|
|
401
|
+
}) => {
|
|
402
|
+
messagesReturn = [makeUserMessage()];
|
|
403
|
+
threadReturn = makeThread({ status });
|
|
404
|
+
|
|
405
|
+
const { result } = renderHook(() =>
|
|
406
|
+
useThreadMessages({
|
|
407
|
+
messagesQuery: messagesQueryRef,
|
|
408
|
+
threadQuery: threadQueryRef,
|
|
409
|
+
streamingMessageUpdatesQuery: null as any,
|
|
410
|
+
threadId: "thread-1",
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
expect(result.current.status).toBe(status);
|
|
415
|
+
expect(result.current.isRunning).toBe(isRunning);
|
|
416
|
+
expect(result.current.isComplete).toBe(isComplete);
|
|
417
|
+
expect(result.current.isFailed).toBe(isFailed);
|
|
418
|
+
expect(result.current.isStopped).toBe(isStopped);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("returns undefined status when thread is null", () => {
|
|
422
|
+
messagesReturn = [makeUserMessage()];
|
|
423
|
+
threadReturn = null;
|
|
424
|
+
|
|
425
|
+
const { result } = renderHook(() =>
|
|
426
|
+
useThreadMessages({
|
|
427
|
+
messagesQuery: messagesQueryRef,
|
|
428
|
+
threadQuery: threadQueryRef,
|
|
429
|
+
streamingMessageUpdatesQuery: null as any,
|
|
430
|
+
threadId: "thread-1",
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
expect(result.current.status).toBeUndefined();
|
|
435
|
+
expect(result.current.isRunning).toBe(false);
|
|
436
|
+
expect(result.current.isComplete).toBe(false);
|
|
437
|
+
expect(result.current.isFailed).toBe(false);
|
|
438
|
+
expect(result.current.isStopped).toBe(false);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("returns undefined status when thread is undefined (loading)", () => {
|
|
442
|
+
messagesReturn = [];
|
|
443
|
+
threadReturn = undefined;
|
|
444
|
+
|
|
445
|
+
const { result } = renderHook(() =>
|
|
446
|
+
useThreadMessages({
|
|
447
|
+
messagesQuery: messagesQueryRef,
|
|
448
|
+
threadQuery: threadQueryRef,
|
|
449
|
+
streamingMessageUpdatesQuery: null as any,
|
|
450
|
+
threadId: "thread-1",
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
expect(result.current.status).toBeUndefined();
|
|
455
|
+
expect(result.current.thread).toBeUndefined();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// -----------------------------------------------------------------
|
|
460
|
+
// Skip behavior
|
|
461
|
+
// -----------------------------------------------------------------
|
|
462
|
+
describe("skip behavior", () => {
|
|
463
|
+
it('passes "skip" to useQuery when skip=true', () => {
|
|
464
|
+
const { result } = renderHook(() =>
|
|
465
|
+
useThreadMessages({
|
|
466
|
+
messagesQuery: messagesQueryRef,
|
|
467
|
+
threadQuery: threadQueryRef,
|
|
468
|
+
streamingMessageUpdatesQuery: streamingQueryRef,
|
|
469
|
+
threadId: "thread-1",
|
|
470
|
+
skip: true,
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Both messages and thread queries should receive "skip"
|
|
475
|
+
const messagesQueryCalls = useQueryCalls.filter(([ref]) => ref === messagesQueryRef);
|
|
476
|
+
const threadQueryCalls = useQueryCalls.filter(([ref]) => ref === threadQueryRef);
|
|
477
|
+
|
|
478
|
+
expect(messagesQueryCalls.length).toBeGreaterThanOrEqual(1);
|
|
479
|
+
expect(messagesQueryCalls[0]![1]).toBe("skip");
|
|
480
|
+
|
|
481
|
+
expect(threadQueryCalls.length).toBeGreaterThanOrEqual(1);
|
|
482
|
+
expect(threadQueryCalls[0]![1]).toBe("skip");
|
|
483
|
+
|
|
484
|
+
// isLoading should be true since queries returned undefined
|
|
485
|
+
expect(result.current.isLoading).toBe(true);
|
|
486
|
+
expect(result.current.messages).toEqual([]);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("passes threadId args to useQuery when skip=false", () => {
|
|
490
|
+
messagesReturn = [];
|
|
491
|
+
threadReturn = makeThread();
|
|
492
|
+
|
|
493
|
+
renderHook(() =>
|
|
494
|
+
useThreadMessages({
|
|
495
|
+
messagesQuery: messagesQueryRef,
|
|
496
|
+
threadQuery: threadQueryRef,
|
|
497
|
+
streamingMessageUpdatesQuery: streamingQueryRef,
|
|
498
|
+
threadId: "thread-1",
|
|
499
|
+
skip: false,
|
|
500
|
+
}),
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const messagesQueryCalls = useQueryCalls.filter(([ref]) => ref === messagesQueryRef);
|
|
504
|
+
const threadQueryCalls = useQueryCalls.filter(([ref]) => ref === threadQueryRef);
|
|
505
|
+
|
|
506
|
+
expect(messagesQueryCalls[0]![1]).toEqual({ threadId: "thread-1" });
|
|
507
|
+
expect(threadQueryCalls[0]![1]).toEqual({ threadId: "thread-1" });
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// -----------------------------------------------------------------
|
|
512
|
+
// fromSeq derivation
|
|
513
|
+
// -----------------------------------------------------------------
|
|
514
|
+
describe("fromSeq derivation", () => {
|
|
515
|
+
it("skips streaming query initially (fromSeq is null on first render)", () => {
|
|
516
|
+
messagesReturn = undefined; // still loading
|
|
517
|
+
threadReturn = undefined;
|
|
518
|
+
|
|
519
|
+
renderHook(() =>
|
|
520
|
+
useThreadMessages({
|
|
521
|
+
messagesQuery: messagesQueryRef,
|
|
522
|
+
threadQuery: threadQueryRef,
|
|
523
|
+
streamingMessageUpdatesQuery: streamingQueryRef,
|
|
524
|
+
threadId: "thread-1",
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
// The streaming query should be called with "skip" since fromSeq is null
|
|
529
|
+
const streamingCalls = useQueryCalls.filter(([ref]) => ref === streamingQueryRef);
|
|
530
|
+
expect(streamingCalls.length).toBeGreaterThanOrEqual(1);
|
|
531
|
+
expect(streamingCalls[0]![1]).toBe("skip");
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("derives fromSeq from max committedSeq + 1 after messages load", async () => {
|
|
535
|
+
const msg1 = makeAssistantMessage({ id: "a1", committedSeq: 3 });
|
|
536
|
+
const msg2 = makeAssistantMessage({ id: "a2", _id: "doc3", committedSeq: 7 });
|
|
537
|
+
messagesReturn = [makeUserMessage(), msg1, msg2];
|
|
538
|
+
threadReturn = makeThread({ status: "streaming" });
|
|
539
|
+
streamingReturn = { messages: [] };
|
|
540
|
+
|
|
541
|
+
const { rerender } = renderHook(() =>
|
|
542
|
+
useThreadMessages({
|
|
543
|
+
messagesQuery: messagesQueryRef,
|
|
544
|
+
threadQuery: threadQueryRef,
|
|
545
|
+
streamingMessageUpdatesQuery: streamingQueryRef,
|
|
546
|
+
threadId: "thread-1",
|
|
547
|
+
}),
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
// After the useEffect runs, fromSeq should be max(3,7) + 1 = 8
|
|
551
|
+
// We need to rerender to see the effect of the state update
|
|
552
|
+
await waitFor(() => {
|
|
553
|
+
rerender();
|
|
554
|
+
const streamingCalls = useQueryCalls.filter(([ref]) => ref === streamingQueryRef);
|
|
555
|
+
const lastCall = streamingCalls[streamingCalls.length - 1];
|
|
556
|
+
// Eventually useStreamingUpdates should compute fromSeq = 8
|
|
557
|
+
if (lastCall && lastCall[1] !== "skip") {
|
|
558
|
+
expect(lastCall[1]).toEqual({ threadId: "thread-1", fromSeq: 8 });
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("uses fromSeq=0 when no messages have committedSeq", async () => {
|
|
564
|
+
messagesReturn = [makeUserMessage({ committedSeq: undefined })];
|
|
565
|
+
threadReturn = makeThread({ status: "streaming" });
|
|
566
|
+
streamingReturn = { messages: [] };
|
|
567
|
+
|
|
568
|
+
const { rerender } = renderHook(() =>
|
|
569
|
+
useThreadMessages({
|
|
570
|
+
messagesQuery: messagesQueryRef,
|
|
571
|
+
threadQuery: threadQueryRef,
|
|
572
|
+
streamingMessageUpdatesQuery: streamingQueryRef,
|
|
573
|
+
threadId: "thread-1",
|
|
574
|
+
}),
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
await waitFor(() => {
|
|
578
|
+
rerender();
|
|
579
|
+
const streamingCalls = useQueryCalls.filter(([ref]) => ref === streamingQueryRef);
|
|
580
|
+
const lastCall = streamingCalls[streamingCalls.length - 1];
|
|
581
|
+
if (lastCall && lastCall[1] !== "skip") {
|
|
582
|
+
// max(-1) is -1 which is not finite via Math.max(...[-1])
|
|
583
|
+
// Actually Math.max(-1) = -1 which IS finite, so fromSeq = -1 + 1 = 0
|
|
584
|
+
expect(lastCall[1]).toEqual({ threadId: "thread-1", fromSeq: 0 });
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// ===================================================================
|
|
592
|
+
// useMessages (exported helper)
|
|
593
|
+
// ===================================================================
|
|
594
|
+
|
|
595
|
+
describe("useMessages", () => {
|
|
596
|
+
it("applies addConvexMetadata to each message", () => {
|
|
597
|
+
const rawMsg: MessageDoc = {
|
|
598
|
+
_id: "doc-abc",
|
|
599
|
+
_creationTime: 9999,
|
|
600
|
+
threadId: "t-1",
|
|
601
|
+
id: "msg-1",
|
|
602
|
+
role: "user",
|
|
603
|
+
parts: [{ type: "text", text: "test" }],
|
|
604
|
+
committedSeq: 42,
|
|
605
|
+
};
|
|
606
|
+
messagesReturn = [rawMsg];
|
|
607
|
+
threadReturn = makeThread();
|
|
608
|
+
|
|
609
|
+
const { result } = renderHook(() => useMessages(messagesQueryRef, threadQueryRef, { threadId: "t-1" }));
|
|
610
|
+
|
|
611
|
+
expect(result.current.isLoading).toBe(false);
|
|
612
|
+
const msg = result.current.messages[0]!;
|
|
613
|
+
expect(msg.metadata).toEqual({
|
|
614
|
+
key: "t-1-msg-1",
|
|
615
|
+
status: "success",
|
|
616
|
+
_creationTime: 9999,
|
|
617
|
+
committedSeq: 42,
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("returns isLoading: true when rawMessages is undefined", () => {
|
|
622
|
+
messagesReturn = undefined;
|
|
623
|
+
threadReturn = undefined;
|
|
624
|
+
|
|
625
|
+
const { result } = renderHook(() => useMessages(messagesQueryRef, threadQueryRef, { threadId: "t-1" }));
|
|
626
|
+
|
|
627
|
+
expect(result.current.isLoading).toBe(true);
|
|
628
|
+
expect(result.current.messages).toEqual([]);
|
|
629
|
+
expect(result.current.thread).toBeUndefined();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('passes "skip" to useQuery when args is "skip"', () => {
|
|
633
|
+
renderHook(() => useMessages(messagesQueryRef, threadQueryRef, "skip"));
|
|
634
|
+
|
|
635
|
+
const messagesQueryCalls = useQueryCalls.filter(([ref]) => ref === messagesQueryRef);
|
|
636
|
+
const threadQueryCalls = useQueryCalls.filter(([ref]) => ref === threadQueryRef);
|
|
637
|
+
|
|
638
|
+
expect(messagesQueryCalls[0]![1]).toBe("skip");
|
|
639
|
+
expect(threadQueryCalls[0]![1]).toBe("skip");
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// ===================================================================
|
|
644
|
+
// Fixture-driven snapshot tests (end-to-end hook with real data)
|
|
645
|
+
// ===================================================================
|
|
646
|
+
|
|
647
|
+
describe("useThreadMessages with fixtures", () => {
|
|
648
|
+
const THREAD_ID = "fixture-thread";
|
|
649
|
+
|
|
650
|
+
for (const file of fixtureFiles) {
|
|
651
|
+
const fixture: Fixture = JSON.parse(readFileSync(join(FIXTURES_DIR, file), "utf-8"));
|
|
652
|
+
|
|
653
|
+
it(`${fixture.description} (log line ${fixture.logLine})`, async () => {
|
|
654
|
+
// Set up mock return values from the fixture data
|
|
655
|
+
messagesReturn = fixture.messages.map((m) => fixtureMessageToDoc(m, THREAD_ID));
|
|
656
|
+
threadReturn = makeThread({ status: "streaming" });
|
|
657
|
+
streamingReturn = fixture.streamingUpdates;
|
|
658
|
+
|
|
659
|
+
const { result } = renderHook(() =>
|
|
660
|
+
useThreadMessages({
|
|
661
|
+
messagesQuery: messagesQueryRef,
|
|
662
|
+
threadQuery: threadQueryRef,
|
|
663
|
+
streamingMessageUpdatesQuery: streamingQueryRef,
|
|
664
|
+
threadId: THREAD_ID,
|
|
665
|
+
}),
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
if (fixture.streamingUpdates.messages.length > 0) {
|
|
669
|
+
// Wait for the async applyStreamingUpdates to resolve and React to re-render.
|
|
670
|
+
// The useEffect fires synchronously after render, calls applyStreamingUpdates
|
|
671
|
+
// which creates a ReadableStream and resolves almost immediately. We flush
|
|
672
|
+
// microtasks via act() and then waitFor the state update to propagate.
|
|
673
|
+
await act(async () => {
|
|
674
|
+
// Flush pending promises (applyStreamingUpdates resolves in one microtask tick)
|
|
675
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// Now wait for React to apply the state update
|
|
679
|
+
await waitFor(
|
|
680
|
+
() => {
|
|
681
|
+
const msgIds = new Set(result.current.messages.map((m) => m.id));
|
|
682
|
+
for (const update of fixture.streamingUpdates.messages) {
|
|
683
|
+
expect(msgIds.has(update.msgId)).toBe(true);
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
{ timeout: 3000, interval: 50 },
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Snapshot the full hook result
|
|
691
|
+
expect({
|
|
692
|
+
messages: result.current.messages,
|
|
693
|
+
status: result.current.status,
|
|
694
|
+
isLoading: result.current.isLoading,
|
|
695
|
+
isRunning: result.current.isRunning,
|
|
696
|
+
isComplete: result.current.isComplete,
|
|
697
|
+
isFailed: result.current.isFailed,
|
|
698
|
+
isStopped: result.current.isStopped,
|
|
699
|
+
}).toMatchSnapshot();
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
});
|