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,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { ModelMessage } from "ai";
|
|
3
|
+
import { endsWithAssistantMessage } from "./msg";
|
|
4
|
+
|
|
5
|
+
describe("endsWithAssistantMessage", () => {
|
|
6
|
+
it("returns false for empty arrays", () => {
|
|
7
|
+
expect(endsWithAssistantMessage([])).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns false when the last message is user", () => {
|
|
11
|
+
const messages: ModelMessage[] = [{ role: "user", content: [{ type: "text", text: "hello" }] }];
|
|
12
|
+
expect(endsWithAssistantMessage(messages)).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns false when the last message is tool", () => {
|
|
16
|
+
const toolMessage = {
|
|
17
|
+
role: "tool",
|
|
18
|
+
content: [{ type: "tool-result", toolCallId: "call_1", toolName: "foo", output: null }],
|
|
19
|
+
} as unknown as ModelMessage;
|
|
20
|
+
const messages: ModelMessage[] = [
|
|
21
|
+
{ role: "assistant", content: [{ type: "text", text: "calling tool" }] },
|
|
22
|
+
toolMessage,
|
|
23
|
+
];
|
|
24
|
+
expect(endsWithAssistantMessage(messages)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns true when the last message is assistant", () => {
|
|
28
|
+
const messages: ModelMessage[] = [
|
|
29
|
+
{ role: "user", content: [{ type: "text", text: "hello" }] },
|
|
30
|
+
{ role: "assistant", content: [{ type: "text", text: "world" }] },
|
|
31
|
+
];
|
|
32
|
+
expect(endsWithAssistantMessage(messages)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/utils/msg.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
classifyRetryErrorDefault,
|
|
4
|
+
extractRetryAfterDelayMs,
|
|
5
|
+
extractRetryErrorSignal,
|
|
6
|
+
extractToolErrorInfo,
|
|
7
|
+
isRetryableDecision,
|
|
8
|
+
isRetryableToolErrorDefault,
|
|
9
|
+
normalizeErrorMessage,
|
|
10
|
+
} from "./retry";
|
|
11
|
+
|
|
12
|
+
describe("classifyRetryErrorDefault", () => {
|
|
13
|
+
it("classifies rate-limited status as retryable", () => {
|
|
14
|
+
const classification = classifyRetryErrorDefault({ statusCode: 429, message: "Too many requests" });
|
|
15
|
+
expect(classification.kind).toBe("rate_limited");
|
|
16
|
+
expect(classification.retryable).toBe(true);
|
|
17
|
+
expect(classification.requiresExplicitHandling).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("classifies provider 5xx as retryable", () => {
|
|
21
|
+
const classification = classifyRetryErrorDefault({ status: 503, message: "Service unavailable" });
|
|
22
|
+
expect(classification.kind).toBe("provider_5xx");
|
|
23
|
+
expect(classification.retryable).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("classifies auth failures as explicit handling", () => {
|
|
27
|
+
const classification = classifyRetryErrorDefault({ statusCode: 401, message: "Unauthorized" });
|
|
28
|
+
expect(classification.kind).toBe("auth");
|
|
29
|
+
expect(classification.retryable).toBe(false);
|
|
30
|
+
expect(classification.requiresExplicitHandling).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("prefers context-overflow provider code over generic 400", () => {
|
|
34
|
+
const classification = classifyRetryErrorDefault({
|
|
35
|
+
statusCode: 400,
|
|
36
|
+
responseBody: JSON.stringify({ error: { code: "context_length_exceeded" } }),
|
|
37
|
+
message: "Bad request",
|
|
38
|
+
});
|
|
39
|
+
expect(classification.kind).toBe("context_window_exceeded");
|
|
40
|
+
expect(classification.retryable).toBe(false);
|
|
41
|
+
expect(classification.requiresExplicitHandling).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("classifies quota provider code as insufficient credits", () => {
|
|
45
|
+
const classification = classifyRetryErrorDefault({
|
|
46
|
+
statusCode: 400,
|
|
47
|
+
data: JSON.stringify({ error: { code: "insufficient_quota" } }),
|
|
48
|
+
message: "quota exceeded",
|
|
49
|
+
});
|
|
50
|
+
expect(classification.kind).toBe("insufficient_credits");
|
|
51
|
+
expect(classification.retryable).toBe(false);
|
|
52
|
+
expect(classification.requiresExplicitHandling).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("classifies network system codes as retryable", () => {
|
|
56
|
+
const classification = classifyRetryErrorDefault({ code: "ECONNRESET", message: "socket hang up" });
|
|
57
|
+
expect(classification.kind).toBe("network");
|
|
58
|
+
expect(classification.retryable).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("classifies connection/stream termination as retryable network errors", () => {
|
|
62
|
+
const classification = classifyRetryErrorDefault("stream terminated by upstream peer");
|
|
63
|
+
expect(classification.kind).toBe("network");
|
|
64
|
+
expect(classification.retryable).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not classify unrelated termination text as retryable network errors", () => {
|
|
68
|
+
const classification = classifyRetryErrorDefault("session terminated by administrator");
|
|
69
|
+
expect(classification.kind).toBe("unknown");
|
|
70
|
+
expect(classification.retryable).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("classifies retryable unknown status as network", () => {
|
|
74
|
+
const classification = classifyRetryErrorDefault({ isRetryable: true, message: "temporary transport issue" });
|
|
75
|
+
expect(classification.kind).toBe("network");
|
|
76
|
+
expect(classification.retryable).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("classifies invalid request status as explicit handling", () => {
|
|
80
|
+
const classification = classifyRetryErrorDefault({ statusCode: 422, message: "Invalid argument" });
|
|
81
|
+
expect(classification.kind).toBe("invalid_request");
|
|
82
|
+
expect(classification.retryable).toBe(false);
|
|
83
|
+
expect(classification.requiresExplicitHandling).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not retry abort errors", () => {
|
|
87
|
+
const classification = classifyRetryErrorDefault({ reason: "abort", message: "request was aborted" });
|
|
88
|
+
expect(classification.kind).toBe("unknown");
|
|
89
|
+
expect(classification.retryable).toBe(false);
|
|
90
|
+
expect(classification.requiresExplicitHandling).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("extractRetryErrorSignal", () => {
|
|
95
|
+
it("extracts nested cause fields and normalizes case", () => {
|
|
96
|
+
const signal = extractRetryErrorSignal({
|
|
97
|
+
name: "ApiCallError",
|
|
98
|
+
cause: {
|
|
99
|
+
code: "econnrefused",
|
|
100
|
+
statusCode: 503,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(signal.name).toBe("apicallerror");
|
|
105
|
+
expect(signal.code).toBe("ECONNREFUSED");
|
|
106
|
+
expect(signal.statusCode).toBe(503);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("extractRetryAfterDelayMs", () => {
|
|
111
|
+
const originalNow = Date.now;
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
Date.now = originalNow;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("uses retry-after-ms header when present", () => {
|
|
118
|
+
const delayMs = extractRetryAfterDelayMs({ responseHeaders: { "retry-after-ms": "150.2" } });
|
|
119
|
+
expect(delayMs).toBe(151);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("parses retry-after seconds", () => {
|
|
123
|
+
const delayMs = extractRetryAfterDelayMs({ responseHeaders: { "retry-after": "2.4" } });
|
|
124
|
+
expect(delayMs).toBe(2400);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("parses retry-after HTTP date", () => {
|
|
128
|
+
Date.now = () => new Date("2026-02-18T00:00:00.000Z").getTime();
|
|
129
|
+
const delayMs = extractRetryAfterDelayMs({
|
|
130
|
+
responseHeaders: { "retry-after": "Wed, 18 Feb 2026 00:00:04 GMT" },
|
|
131
|
+
});
|
|
132
|
+
expect(delayMs).toBe(4000);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("ignores values outside cap", () => {
|
|
136
|
+
const delayMs = extractRetryAfterDelayMs({ responseHeaders: { "retry-after": "120" } });
|
|
137
|
+
expect(delayMs).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("normalizeErrorMessage", () => {
|
|
142
|
+
it("normalizes error and non-error values", () => {
|
|
143
|
+
expect(normalizeErrorMessage(new Error("boom"))).toBe("boom");
|
|
144
|
+
expect(normalizeErrorMessage("oops")).toBe("oops");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("extractToolErrorInfo", () => {
|
|
149
|
+
it("extracts structured fields from root and cause", () => {
|
|
150
|
+
const info = extractToolErrorInfo({
|
|
151
|
+
name: "FetchError",
|
|
152
|
+
message: "request failed",
|
|
153
|
+
cause: {
|
|
154
|
+
code: "etimedout",
|
|
155
|
+
statusCode: 504,
|
|
156
|
+
message: "upstream timed out",
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(info.name).toBe("FetchError");
|
|
161
|
+
expect(info.code).toBe("ETIMEDOUT");
|
|
162
|
+
expect(info.statusCode).toBe(504);
|
|
163
|
+
expect(info.causeMessage).toBe("upstream timed out");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("parses status code from message when missing", () => {
|
|
167
|
+
const info = extractToolErrorInfo(new Error("status code: 503 from provider"));
|
|
168
|
+
expect(info.statusCode).toBe(503);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("isRetryableToolErrorDefault", () => {
|
|
173
|
+
it("retries retryable status codes", () => {
|
|
174
|
+
expect(isRetryableToolErrorDefault({ message: "busy", statusCode: 429 })).toBe(true);
|
|
175
|
+
expect(isRetryableToolErrorDefault({ message: "timeout", statusCode: 408 })).toBe(true);
|
|
176
|
+
expect(isRetryableToolErrorDefault({ message: "server", statusCode: 503 })).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("does not retry non-retryable status codes", () => {
|
|
180
|
+
expect(isRetryableToolErrorDefault({ message: "unauthorized", statusCode: 401 })).toBe(false);
|
|
181
|
+
expect(isRetryableToolErrorDefault({ message: "validation", statusCode: 422 })).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("retries network/system error codes", () => {
|
|
185
|
+
expect(isRetryableToolErrorDefault({ message: "socket error", code: "ECONNRESET" })).toBe(true);
|
|
186
|
+
expect(isRetryableToolErrorDefault({ message: "dns", code: "ENOTFOUND" })).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("retries known transient messages", () => {
|
|
190
|
+
expect(isRetryableToolErrorDefault("fetch failed due to upstream connect error")).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("does not treat non-http 'status: 5' text as retryable", () => {
|
|
194
|
+
expect(isRetryableToolErrorDefault("processing status: 5 items remaining")).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("does not retry unknown deterministic errors", () => {
|
|
198
|
+
expect(isRetryableToolErrorDefault("invalid input: account id is required")).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("isRetryableDecision", () => {
|
|
203
|
+
it("accepts supported retry decision shapes", () => {
|
|
204
|
+
expect(isRetryableDecision(true)).toBe(true);
|
|
205
|
+
expect(isRetryableDecision({ retryable: true })).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("rejects unsupported decision values", () => {
|
|
209
|
+
expect(isRetryableDecision(false)).toBe(false);
|
|
210
|
+
expect(isRetryableDecision({ retryable: false })).toBe(false);
|
|
211
|
+
expect(isRetryableDecision({})).toBe(false);
|
|
212
|
+
expect(isRetryableDecision(null)).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
});
|