firecrawl 4.38.0 → 4.39.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 (32) hide show
  1. package/README.md +7 -2
  2. package/dist/{chunk-KSTNOGOD.js → chunk-4DPCCKWG.js} +1 -1
  3. package/dist/index.cjs +39 -9
  4. package/dist/index.d.cts +152 -4
  5. package/dist/index.d.ts +152 -4
  6. package/dist/index.js +40 -10
  7. package/dist/{package-OS7RONXY.js → package-D2QZMRW7.js} +1 -1
  8. package/package.json +1 -1
  9. package/pnpm-workspace.yaml +1 -0
  10. package/src/__tests__/e2e/v2/batch.test.ts +13 -11
  11. package/src/__tests__/e2e/v2/crawl.test.ts +24 -20
  12. package/src/__tests__/e2e/v2/extract.test.ts +32 -7
  13. package/src/__tests__/e2e/v2/map.test.ts +4 -3
  14. package/src/__tests__/e2e/v2/parse.test.ts +4 -3
  15. package/src/__tests__/e2e/v2/scrape.test.ts +10 -9
  16. package/src/__tests__/e2e/v2/search.test.ts +9 -8
  17. package/src/__tests__/e2e/v2/usage.test.ts +6 -5
  18. package/src/__tests__/e2e/v2/utils/rateLimit.ts +257 -0
  19. package/src/__tests__/e2e/v2/watcher.test.ts +4 -3
  20. package/src/__tests__/unit/v2/agent-effort-trace.unit.test.ts +1 -1
  21. package/src/__tests__/unit/v2/agent-thread.unit.test.ts +164 -0
  22. package/src/__tests__/unit/v2/e2eRateLimitRetry.test.ts +501 -0
  23. package/src/__tests__/unit/v2/extract.unit.test.ts +1 -1
  24. package/src/__tests__/unit/v2/scrapeAutoResume.test.ts +1 -1
  25. package/src/__tests__/unit/v2/search-country.unit.test.ts +32 -0
  26. package/src/__tests__/unit/v2/search-data-property.test.ts +1 -0
  27. package/src/__tests__/unit/v2/v1-aliases.test.ts +1 -0
  28. package/src/v2/client.ts +13 -1
  29. package/src/v2/methods/agent.ts +23 -1
  30. package/src/v2/methods/research.ts +14 -8
  31. package/src/v2/methods/search.ts +1 -0
  32. package/src/v2/types.ts +151 -34
@@ -5,6 +5,7 @@ import Firecrawl from "../../../index";
5
5
  import type { Document, SearchResultWeb, SearchResultNews, SearchResultImages } from "../../../index";
6
6
  import { config } from "dotenv";
7
7
  import { getIdentity, getApiUrl } from "./utils/idmux";
8
+ import { testTimeoutMs, withRateLimitRetry } from "./utils/rateLimit";
8
9
  import { describe, test, expect, beforeAll } from "@jest/globals";
9
10
 
10
11
  config();
@@ -14,7 +15,7 @@ let client: Firecrawl;
14
15
 
15
16
  beforeAll(async () => {
16
17
  const { apiKey } = await getIdentity({ name: "js-e2e-search" });
17
- client = new Firecrawl({ apiKey, apiUrl: API_URL });
18
+ client = withRateLimitRetry(new Firecrawl({ apiKey, apiUrl: API_URL }));
18
19
  });
19
20
 
20
21
  function collectTexts(entries: any[] | undefined): string[] {
@@ -72,7 +73,7 @@ describe("v2.search e2e", () => {
72
73
 
73
74
  expect(results.news == null).toBe(true);
74
75
  expect(results.images == null).toBe(true);
75
- }, 90_000);
76
+ }, testTimeoutMs(90_000));
76
77
 
77
78
  test("with sources web+news and limit", async () => {
78
79
  if (!client) throw new Error();
@@ -93,7 +94,7 @@ describe("v2.search e2e", () => {
93
94
  .map(r => (r.description || "").toString().toLowerCase());
94
95
  const allWebText = (webTitles.concat(webDescriptions)).join(" ");
95
96
  expect(allWebText.includes("firecrawl")).toBe(true);
96
- }, 90_000);
97
+ }, testTimeoutMs(90_000));
97
98
 
98
99
  test("result structure", async () => {
99
100
  if (!client) throw new Error();
@@ -108,7 +109,7 @@ describe("v2.search e2e", () => {
108
109
  expect(typeof result.description === "string" || result.description == null).toBe(true);
109
110
  expect(result.url.startsWith("http")).toBe(true);
110
111
  }
111
- }, 90_000);
112
+ }, testTimeoutMs(90_000));
112
113
 
113
114
  test("all parameters (comprehensive)", async () => {
114
115
  if (!client) throw new Error();
@@ -196,7 +197,7 @@ describe("v2.search e2e", () => {
196
197
  expect(result.url?.startsWith("http")).toBe(true);
197
198
  }
198
199
  }
199
- }, 120_000);
200
+ }, testTimeoutMs(120_000));
200
201
 
201
202
  test("formats flexibility: list vs object", async () => {
202
203
  if (!client) throw new Error();
@@ -212,7 +213,7 @@ describe("v2.search e2e", () => {
212
213
  expect(results2).toBeTruthy();
213
214
  expect(results1.web).toBeTruthy();
214
215
  expect(results2.web).toBeTruthy();
215
- }, 90_000);
216
+ }, testTimeoutMs(151_000));
216
217
 
217
218
  test("with json format object", async () => {
218
219
  if (!client) throw new Error();
@@ -229,7 +230,7 @@ describe("v2.search e2e", () => {
229
230
  });
230
231
  expect(results).toBeTruthy();
231
232
  expect(Array.isArray(results.web) || results.web == null).toBe(true);
232
- }, 90_000);
233
+ }, testTimeoutMs(90_000));
233
234
 
234
235
  test("with summary format, documents include summary when present", async () => {
235
236
  if (!client) throw new Error();
@@ -242,6 +243,6 @@ describe("v2.search e2e", () => {
242
243
  expect(typeof docs[0].summary).toBe("string");
243
244
  expect((docs[0].summary || "").length).toBeGreaterThan(5);
244
245
  }
245
- }, 90_000);
246
+ }, testTimeoutMs(90_000));
246
247
  });
247
248
 
@@ -4,6 +4,7 @@
4
4
  import Firecrawl from "../../../index";
5
5
  import { config } from "dotenv";
6
6
  import { getIdentity, getApiUrl } from "./utils/idmux";
7
+ import { testTimeoutMs, withRateLimitRetry } from "./utils/rateLimit";
7
8
  import { describe, test, expect, beforeAll } from "@jest/globals";
8
9
 
9
10
  config();
@@ -13,7 +14,7 @@ let client: Firecrawl;
13
14
 
14
15
  beforeAll(async () => {
15
16
  const { apiKey } = await getIdentity({ name: "js-e2e-usage" });
16
- client = new Firecrawl({ apiKey, apiUrl: API_URL });
17
+ client = withRateLimitRetry(new Firecrawl({ apiKey, apiUrl: API_URL }));
17
18
  });
18
19
 
19
20
  describe("v2.usage e2e", () => {
@@ -21,17 +22,17 @@ describe("v2.usage e2e", () => {
21
22
  const resp = await client.getConcurrency();
22
23
  expect(typeof resp.concurrency).toBe("number");
23
24
  expect(typeof resp.maxConcurrency).toBe("number");
24
- }, 60_000);
25
+ }, testTimeoutMs(60_000));
25
26
 
26
27
  test("get_credit_usage", async () => {
27
28
  const resp = await client.getCreditUsage();
28
29
  expect(typeof resp.remainingCredits).toBe("number");
29
- }, 60_000);
30
+ }, testTimeoutMs(60_000));
30
31
 
31
32
  test("get_token_usage", async () => {
32
33
  const resp = await client.getTokenUsage();
33
34
  expect(typeof resp.remainingTokens).toBe("number");
34
- }, 60_000);
35
+ }, testTimeoutMs(60_000));
35
36
 
36
37
  test("get_queue_status", async () => {
37
38
  const resp = await client.getQueueStatus();
@@ -39,6 +40,6 @@ describe("v2.usage e2e", () => {
39
40
  expect(typeof resp.activeJobsInQueue).toBe("number");
40
41
  expect(typeof resp.waitingJobsInQueue).toBe("number");
41
42
  expect(typeof resp.maxConcurrency).toBe("number");
42
- }, 60_000);
43
+ }, testTimeoutMs(60_000));
43
44
  });
44
45
 
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Rate-limit retry for the v2 e2e suites.
3
+ *
4
+ * The e2e suites run against a live API. Each suite mints a CI identity through
5
+ * idmux, and that identity gets the base per-minute limits: 2/min for crawl and
6
+ * extract, 10/min for scrape, search and map, 500/min for job status. Several
7
+ * suites send more calls than that in one minute, so the API answers 429 and the
8
+ * suite fails.
9
+ *
10
+ * This helper wraps a client so every call waits for the limit window to reset
11
+ * and then runs again. It is test-only code. The shipped SDK keeps its own retry
12
+ * behaviour.
13
+ *
14
+ * Batch scrape creation counts against the scrape limit, not a limit of its
15
+ * own. Job status reads use the 500/min limit, so they almost never wait.
16
+ *
17
+ * The wrapper only runs a call again when the refused request created nothing.
18
+ * A single request is safe: the API rejected it, so no job exists. A composite
19
+ * method is not safe. It starts a job, then polls that job. The poll can hit
20
+ * the limit after the job exists, and a second run of the method would start a
21
+ * second job. The wrapper passes these methods straight through, so they get no
22
+ * retry. See COMPOSITE_METHODS.
23
+ *
24
+ * A suite that needs a job therefore does the two steps itself: it calls the
25
+ * start method, then waitForJob. Both calls go through the wrapper, so both
26
+ * retry, and the job starts once.
27
+ */
28
+
29
+ /** Attempts per call, including the first one. */
30
+ const MAX_ATTEMPTS = 3;
31
+
32
+ /** Wait used when the error carries no reset time. */
33
+ const FALLBACK_WAIT_MS = 30_000;
34
+
35
+ /** Upper bound for one wait. The API window is 60 seconds. */
36
+ const MAX_WAIT_MS = 75_000;
37
+
38
+ /** Added to the reset time so the retry lands after the window, not on it. */
39
+ const WAIT_BUFFER_MS = 1_000;
40
+
41
+ /**
42
+ * Worst-case time the retry adds to one call.
43
+ *
44
+ * The first attempt does not wait. Every later attempt waits first, and one
45
+ * wait is never longer than MAX_WAIT_MS. The value comes from the two bounds
46
+ * above, so it cannot drift from them.
47
+ */
48
+ export const RETRY_BUDGET_MS = (MAX_ATTEMPTS - 1) * MAX_WAIT_MS;
49
+
50
+ /**
51
+ * Time waitForJob allows when the caller passes no timeout.
52
+ *
53
+ * Every caller now passes its own timeout, sized to the work it waits for.
54
+ * This value is only a backstop for a new caller that forgets one. Without a
55
+ * bound the poll loop runs until the jest timeout, which hides the reason.
56
+ *
57
+ * The value stays short because it cannot know the work. It fits the smallest
58
+ * base any suite passes to testTimeoutMs, which is 60_000 ms:
59
+ * 45_000 + 2_000 + RETRY_BUDGET_MS = 197_000 ms, inside the 210_000 ms of
60
+ * testTimeoutMs(60_000). So a forgotten timeout still reports "job did not
61
+ * finish" in every test the suites have today.
62
+ *
63
+ * A caller that waits for real work must pass a timeout. Size it to the work,
64
+ * not to the budget, then set the budget from the sum of the calls the test
65
+ * makes. The unit test "every wrapped call in a test fits its budget" holds
66
+ * that rule: it counts this bound, one poll interval, and the retry budget of
67
+ * every wrapped call in the test, and requires the sum to fit.
68
+ */
69
+ export const DEFAULT_JOB_TIMEOUT_MS = 45_000;
70
+
71
+ /**
72
+ * Jest timeout for a test that calls a wrapped client.
73
+ *
74
+ * Pass the base: the time the test needs when the API answers at once. The
75
+ * result adds one retry budget on top, so a test whose single call hits the
76
+ * limit still finishes inside its own timeout instead of failing on it.
77
+ *
78
+ * One retry budget covers one call. A test that makes several wrapped calls
79
+ * can spend the budget once per call, so the base must carry the rest. Do not
80
+ * pick the base by hand: take the number the unit test "every wrapped call in
81
+ * a test fits its budget" computes, which sums every call in the test.
82
+ */
83
+ export function testTimeoutMs(baseMs: number): number {
84
+ return baseMs + RETRY_BUDGET_MS;
85
+ }
86
+
87
+ /**
88
+ * Client methods that start a job and then poll it.
89
+ *
90
+ * These never run again after a rate-limit error. A retry would call the whole
91
+ * method, which starts a second job. The single-request methods that the job
92
+ * waiters use inside, such as startCrawl and getCrawlStatus, keep their retry
93
+ * when a test calls them directly.
94
+ */
95
+ export const COMPOSITE_METHODS: ReadonlySet<string> = new Set([
96
+ "crawl",
97
+ "crawlUrl",
98
+ "batchScrape",
99
+ "batchScrapeUrls",
100
+ "extract",
101
+ "agent",
102
+ ]);
103
+
104
+ function sleep(ms: number): Promise<void> {
105
+ return new Promise(resolve => setTimeout(resolve, ms));
106
+ }
107
+
108
+ /** States a job stops in. Matches the waiters in src/v2/methods. */
109
+ const TERMINAL_STATES = ["completed", "failed", "cancelled"];
110
+
111
+ /** The part of a job snapshot this module reads. */
112
+ interface JobSnapshot {
113
+ status?: string;
114
+ }
115
+
116
+ /**
117
+ * Polls a job that a test already started.
118
+ *
119
+ * The composite client methods, such as crawl, start a job and then poll it in
120
+ * one call, so the wrapper cannot run them again. A test uses this instead: it
121
+ * starts the job with the start method, then polls with the status method. Both
122
+ * calls go through the wrapper, so both keep their retry, and the job starts
123
+ * once. Compare waitForCrawlCompletion in src/v2/methods/crawl.ts, which this
124
+ * mirrors.
125
+ *
126
+ * @param getStatus Reads the job state. Call the wrapped client here.
127
+ * @param opts.pollInterval Seconds between reads. Least value is 1.
128
+ * @param opts.timeout Seconds to allow. Throws when the job runs longer.
129
+ * Defaults to DEFAULT_JOB_TIMEOUT_MS, so the loop always has a bound.
130
+ */
131
+ export async function waitForJob<T extends JobSnapshot>(
132
+ getStatus: () => Promise<T>,
133
+ opts: { pollInterval?: number; timeout?: number } = {},
134
+ ): Promise<T> {
135
+ const pollIntervalMs = Math.max(1000, (opts.pollInterval ?? 2) * 1000);
136
+ const timeoutMs =
137
+ opts.timeout != null ? opts.timeout * 1000 : DEFAULT_JOB_TIMEOUT_MS;
138
+ const startedAt = Date.now();
139
+
140
+ let snapshot = await getStatus();
141
+ while (!TERMINAL_STATES.includes(snapshot.status ?? "")) {
142
+ if (Date.now() - startedAt > timeoutMs) {
143
+ throw new Error(
144
+ `job did not finish in ${timeoutMs / 1000}s. Last status: ${
145
+ snapshot.status
146
+ }`,
147
+ );
148
+ }
149
+ await sleep(pollIntervalMs);
150
+ snapshot = await getStatus();
151
+ }
152
+ return snapshot;
153
+ }
154
+
155
+ function isPromiseLike(value: unknown): value is Promise<unknown> {
156
+ return (
157
+ typeof value === "object" &&
158
+ value !== null &&
159
+ typeof (value as { then?: unknown }).then === "function"
160
+ );
161
+ }
162
+
163
+ /** True when the API refused the call because the per-minute limit is spent. */
164
+ export function isRateLimitError(err: unknown): boolean {
165
+ if (typeof err !== "object" || err === null) return false;
166
+ const candidate = err as { status?: unknown; message?: unknown };
167
+ if (candidate.status === 429) return true;
168
+ return (
169
+ typeof candidate.message === "string" &&
170
+ /rate limit exceeded/i.test(candidate.message)
171
+ );
172
+ }
173
+
174
+ /** Milliseconds to wait before the next attempt. */
175
+ export function rateLimitWaitMs(err: unknown): number {
176
+ let seconds: number | undefined;
177
+
178
+ const details = (err as { details?: Record<string, unknown> })?.details;
179
+ for (const key of ["retryAfter", "retryAfterSeconds"]) {
180
+ const value = details?.[key];
181
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
182
+ seconds = value;
183
+ break;
184
+ }
185
+ }
186
+
187
+ if (seconds === undefined) {
188
+ const message = (err as { message?: unknown })?.message;
189
+ const match =
190
+ typeof message === "string"
191
+ ? message.match(/retry after (\d+)\s*s/i)
192
+ : null;
193
+ if (match) seconds = Number(match[1]);
194
+ }
195
+
196
+ const waitMs =
197
+ seconds !== undefined && seconds > 0
198
+ ? seconds * 1000 + WAIT_BUFFER_MS
199
+ : FALLBACK_WAIT_MS;
200
+ return Math.min(waitMs, MAX_WAIT_MS);
201
+ }
202
+
203
+ async function runWithRetry<T>(
204
+ first: Promise<T>,
205
+ again: () => Promise<T>,
206
+ label: string,
207
+ ): Promise<T> {
208
+ let pending = first;
209
+
210
+ for (let attempt = 1; ; attempt++) {
211
+ try {
212
+ return await pending;
213
+ } catch (err) {
214
+ if (attempt >= MAX_ATTEMPTS || !isRateLimitError(err)) throw err;
215
+ const waitMs = rateLimitWaitMs(err);
216
+ console.warn(
217
+ `[e2e] ${label} hit the API rate limit. Waiting ${Math.round(
218
+ waitMs / 1000,
219
+ )}s, then attempt ${attempt + 1} of ${MAX_ATTEMPTS}.`,
220
+ );
221
+ await sleep(waitMs);
222
+ pending = again();
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Wraps a client so each method call retries after a rate-limit error.
229
+ *
230
+ * The wrapper only covers the calls the test makes. Methods that return a value
231
+ * other than a promise pass through unchanged, and so do the composite methods
232
+ * listed in COMPOSITE_METHODS.
233
+ */
234
+ export function withRateLimitRetry<T extends object>(client: T): T {
235
+ return new Proxy(client, {
236
+ get(target, prop) {
237
+ const value = (target as Record<string | symbol, unknown>)[prop];
238
+ if (typeof value !== "function") return value;
239
+
240
+ const name = String(prop);
241
+
242
+ return (...args: unknown[]) => {
243
+ const call = () =>
244
+ (value as (...a: unknown[]) => unknown).apply(target, args);
245
+ const result = call();
246
+ // A composite method already created a job, so never run it again.
247
+ if (COMPOSITE_METHODS.has(name)) return result;
248
+ if (!isPromiseLike(result)) return result;
249
+ return runWithRetry(
250
+ result,
251
+ call as () => Promise<unknown>,
252
+ name,
253
+ );
254
+ };
255
+ },
256
+ });
257
+ }
@@ -2,6 +2,7 @@ import Firecrawl from "../../../index";
2
2
  import { config } from "dotenv";
3
3
  import { describe, test, expect, beforeAll } from "@jest/globals";
4
4
  import { getIdentity } from "./utils/idmux";
5
+ import { testTimeoutMs, withRateLimitRetry } from "./utils/rateLimit";
5
6
 
6
7
  config();
7
8
 
@@ -10,7 +11,7 @@ let client: Firecrawl;
10
11
 
11
12
  beforeAll(async () => {
12
13
  const { apiKey } = await getIdentity({ name: "js-e2e-watcher" });
13
- client = new Firecrawl({ apiKey, apiUrl: API_URL });
14
+ client = withRateLimitRetry(new Firecrawl({ apiKey, apiUrl: API_URL }));
14
15
  });
15
16
 
16
17
  describe("v2.watcher e2e", () => {
@@ -52,7 +53,7 @@ describe("v2.watcher e2e", () => {
52
53
  expect(snapshots).toBeGreaterThanOrEqual(1);
53
54
  expect(documents).toBeGreaterThanOrEqual(0);
54
55
  watcher.close();
55
- }, 240_000);
56
+ }, testTimeoutMs(240_000));
56
57
 
57
58
  test("batch watcher with options (kind, pollInterval, timeout)", async () => {
58
59
  // client is initialized in beforeAll
@@ -91,6 +92,6 @@ describe("v2.watcher e2e", () => {
91
92
  expect(snapshots).toBeGreaterThanOrEqual(1);
92
93
  expect(gotCompleted || final.status !== "completed").toBe(true);
93
94
  watcher.close();
94
- }, 300_000);
95
+ }, testTimeoutMs(300_000));
95
96
  });
96
97
 
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "@jest/globals";
1
+ import { describe, expect, jest, test } from "@jest/globals";
2
2
  import {
3
3
  startAgent,
4
4
  getAgentTrace,
@@ -0,0 +1,164 @@
1
+ import { describe, expect, jest, test } from "@jest/globals";
2
+ import {
3
+ getAgentStatus,
4
+ getAgentThread,
5
+ startAgent,
6
+ } from "../../../v2/methods/agent";
7
+
8
+ const okPost = () =>
9
+ jest.fn().mockResolvedValue({
10
+ status: 200,
11
+ data: { success: true, id: "agent-job", threadId: "thread-1", threadTurn: 2 },
12
+ });
13
+
14
+ describe("v2.agent threads unit", () => {
15
+ test("startAgent forwards the thread fields when set", async () => {
16
+ const post = okPost();
17
+
18
+ await startAgent({ post } as any, {
19
+ prompt: "Which tier has SSO?",
20
+ threadId: "thread-1",
21
+ mode: "chat",
22
+ exchange: { enabled: true, toolkits: ["a", "b"] },
23
+ });
24
+
25
+ expect(post).toHaveBeenCalledWith("/v2/agent", {
26
+ prompt: "Which tier has SSO?",
27
+ threadId: "thread-1",
28
+ mode: "chat",
29
+ exchange: { enabled: true, toolkits: ["a", "b"] },
30
+ });
31
+ });
32
+
33
+ test("startAgent omits the thread fields when unset", async () => {
34
+ const post = okPost();
35
+
36
+ await startAgent({ post } as any, { prompt: "List the pricing tiers" });
37
+
38
+ expect(post).toHaveBeenCalledWith("/v2/agent", {
39
+ prompt: "List the pricing tiers",
40
+ });
41
+ });
42
+
43
+ test("startAgent returns the thread the run belongs to", async () => {
44
+ const started = await startAgent({ post: okPost() } as any, {
45
+ prompt: "Which tier has SSO?",
46
+ threadId: "thread-1",
47
+ });
48
+
49
+ expect(started.threadId).toBe("thread-1");
50
+ expect(started.threadTurn).toBe(2);
51
+ });
52
+
53
+ test("getAgentStatus parses a chat-mode status payload", async () => {
54
+ const get = jest.fn().mockResolvedValue({
55
+ status: 200,
56
+ data: {
57
+ success: true,
58
+ status: "completed",
59
+ data: null,
60
+ model: "spark-2",
61
+ expiresAt: "2026-09-02T00:00:00.000Z",
62
+ threadId: "thread-1",
63
+ threadTurn: 2,
64
+ mode: "chat",
65
+ message: "Only the Enterprise tier lists SSO.",
66
+ suggestions: [{ label: "Seat caps?", prompt: "Does Team cap seats?" }],
67
+ pendingApproval: {
68
+ id: "approval-1",
69
+ reason: "One paid call answers this.",
70
+ calls: [
71
+ {
72
+ id: "call-1",
73
+ provider: "provider-1",
74
+ capability: "capability-1",
75
+ input: { query: "sso" },
76
+ creditsEstimate: 5,
77
+ },
78
+ ],
79
+ resolution: null,
80
+ },
81
+ exchange: { enabled: true, paidCalls: 0, creditsUsed: null },
82
+ },
83
+ });
84
+
85
+ const status = await getAgentStatus({ get } as any, "agent-job");
86
+
87
+ expect(get).toHaveBeenCalledWith("/v2/agent/agent-job");
88
+ expect(status.data).toBeNull();
89
+ expect(status.message).toBe("Only the Enterprise tier lists SSO.");
90
+ expect(status.mode).toBe("chat");
91
+ expect(status.threadTurn).toBe(2);
92
+ expect(status.suggestions).toHaveLength(1);
93
+ expect(status.pendingApproval!.calls[0].creditsEstimate).toBe(5);
94
+ expect(status.exchange!.paidCalls).toBe(0);
95
+ });
96
+
97
+ test("getAgentStatus parses a status payload without the thread fields", async () => {
98
+ const get = jest.fn().mockResolvedValue({
99
+ status: 200,
100
+ data: {
101
+ success: true,
102
+ status: "completed",
103
+ data: { price: 42 },
104
+ model: "spark-2",
105
+ expiresAt: "2026-09-02T00:00:00.000Z",
106
+ creditsUsed: 12,
107
+ },
108
+ });
109
+
110
+ const status = await getAgentStatus({ get } as any, "agent-job");
111
+
112
+ expect(status.data).toEqual({ price: 42 });
113
+ expect(status.threadId).toBeUndefined();
114
+ expect(status.message).toBeUndefined();
115
+ expect(status.pendingApproval).toBeUndefined();
116
+ });
117
+
118
+ test("getAgentThread hits the thread endpoint", async () => {
119
+ const get = jest.fn().mockResolvedValue({
120
+ status: 200,
121
+ data: {
122
+ success: true,
123
+ thread: {
124
+ id: "thread-1",
125
+ createdAt: "2026-09-01T00:00:00.000Z",
126
+ updatedAt: "2026-09-01T00:01:00.000Z",
127
+ status: "idle",
128
+ runs: [
129
+ {
130
+ id: "agent-job",
131
+ turn: 1,
132
+ mode: "chat",
133
+ prompt: "List the pricing tiers",
134
+ status: "succeeded",
135
+ createdAt: "2026-09-01T00:00:00.000Z",
136
+ finishedAt: "2026-09-01T00:00:30.000Z",
137
+ creditsUsed: 212,
138
+ message: null,
139
+ },
140
+ ],
141
+ },
142
+ },
143
+ });
144
+
145
+ const thread = await getAgentThread({ get } as any, "thread-1");
146
+
147
+ expect(get).toHaveBeenCalledWith("/v2/agent/threads/thread-1");
148
+ expect(thread.thread!.runs).toHaveLength(1);
149
+ expect(thread.thread!.runs[0].turn).toBe(1);
150
+ });
151
+
152
+ test("getAgentThread appends includeData when requested", async () => {
153
+ const get = jest.fn().mockResolvedValue({
154
+ status: 200,
155
+ data: { success: true, thread: { id: "thread-1", runs: [] } },
156
+ });
157
+
158
+ await getAgentThread({ get } as any, "thread-1", { includeData: true });
159
+
160
+ expect(get).toHaveBeenCalledWith(
161
+ "/v2/agent/threads/thread-1?includeData=true",
162
+ );
163
+ });
164
+ });