@codespar/sdk 0.10.11 → 0.12.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 (90) hide show
  1. package/README.md +72 -1
  2. package/dist/__tests__/abort.test.d.ts +2 -0
  3. package/dist/__tests__/abort.test.d.ts.map +1 -0
  4. package/dist/__tests__/abort.test.js +70 -0
  5. package/dist/__tests__/abort.test.js.map +1 -0
  6. package/dist/__tests__/api-client.test.d.ts +7 -0
  7. package/dist/__tests__/api-client.test.d.ts.map +1 -0
  8. package/dist/__tests__/api-client.test.js +286 -0
  9. package/dist/__tests__/api-client.test.js.map +1 -0
  10. package/dist/__tests__/close-timeout.test.d.ts +2 -0
  11. package/dist/__tests__/close-timeout.test.d.ts.map +1 -0
  12. package/dist/__tests__/close-timeout.test.js +57 -0
  13. package/dist/__tests__/close-timeout.test.js.map +1 -0
  14. package/dist/__tests__/codespar.test.js +32 -0
  15. package/dist/__tests__/codespar.test.js.map +1 -1
  16. package/dist/__tests__/connections-timeout.test.d.ts +2 -0
  17. package/dist/__tests__/connections-timeout.test.d.ts.map +1 -0
  18. package/dist/__tests__/connections-timeout.test.js +58 -0
  19. package/dist/__tests__/connections-timeout.test.js.map +1 -0
  20. package/dist/__tests__/fetch-timeout.test.d.ts +2 -0
  21. package/dist/__tests__/fetch-timeout.test.d.ts.map +1 -0
  22. package/dist/__tests__/fetch-timeout.test.js +137 -0
  23. package/dist/__tests__/fetch-timeout.test.js.map +1 -0
  24. package/dist/__tests__/loop.test.d.ts +2 -0
  25. package/dist/__tests__/loop.test.d.ts.map +1 -0
  26. package/dist/__tests__/loop.test.js +237 -0
  27. package/dist/__tests__/loop.test.js.map +1 -0
  28. package/dist/__tests__/mandate.test.d.ts +2 -0
  29. package/dist/__tests__/mandate.test.d.ts.map +1 -0
  30. package/dist/__tests__/mandate.test.js +141 -0
  31. package/dist/__tests__/mandate.test.js.map +1 -0
  32. package/dist/__tests__/openapi-snapshot.test.d.ts +13 -0
  33. package/dist/__tests__/openapi-snapshot.test.d.ts.map +1 -0
  34. package/dist/__tests__/openapi-snapshot.test.js +79 -0
  35. package/dist/__tests__/openapi-snapshot.test.js.map +1 -0
  36. package/dist/__tests__/shop.test.js +1 -1
  37. package/dist/__tests__/shop.test.js.map +1 -1
  38. package/dist/__tests__/stream-timeout.test.d.ts +2 -0
  39. package/dist/__tests__/stream-timeout.test.d.ts.map +1 -0
  40. package/dist/__tests__/stream-timeout.test.js +335 -0
  41. package/dist/__tests__/stream-timeout.test.js.map +1 -0
  42. package/dist/__tests__/wait-for-connections.test.d.ts +2 -0
  43. package/dist/__tests__/wait-for-connections.test.d.ts.map +1 -0
  44. package/dist/__tests__/wait-for-connections.test.js +94 -0
  45. package/dist/__tests__/wait-for-connections.test.js.map +1 -0
  46. package/dist/api/client.d.ts +56 -0
  47. package/dist/api/client.d.ts.map +1 -0
  48. package/dist/api/client.js +221 -0
  49. package/dist/api/client.js.map +1 -0
  50. package/dist/api/types.d.ts +110 -0
  51. package/dist/api/types.d.ts.map +1 -0
  52. package/dist/api/types.js +10 -0
  53. package/dist/api/types.js.map +1 -0
  54. package/dist/errors.d.ts +5 -0
  55. package/dist/errors.d.ts.map +1 -1
  56. package/dist/errors.js +9 -0
  57. package/dist/errors.js.map +1 -1
  58. package/dist/generated/openapi.d.ts +22004 -0
  59. package/dist/generated/openapi.d.ts.map +1 -0
  60. package/dist/generated/openapi.js +2 -0
  61. package/dist/generated/openapi.js.map +1 -0
  62. package/dist/generated/operations.d.ts +1084 -0
  63. package/dist/generated/operations.d.ts.map +1 -0
  64. package/dist/generated/operations.js +225 -0
  65. package/dist/generated/operations.js.map +1 -0
  66. package/dist/index.d.ts +24 -4
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +46 -4
  69. package/dist/index.js.map +1 -1
  70. package/dist/internal/abort.d.ts +18 -0
  71. package/dist/internal/abort.d.ts.map +1 -0
  72. package/dist/internal/abort.js +42 -0
  73. package/dist/internal/abort.js.map +1 -0
  74. package/dist/internal/fetch.d.ts +23 -0
  75. package/dist/internal/fetch.d.ts.map +1 -0
  76. package/dist/internal/fetch.js +57 -0
  77. package/dist/internal/fetch.js.map +1 -0
  78. package/dist/mandate/index.d.ts +111 -0
  79. package/dist/mandate/index.d.ts.map +1 -0
  80. package/dist/mandate/index.js +217 -0
  81. package/dist/mandate/index.js.map +1 -0
  82. package/dist/session.d.ts +3 -2
  83. package/dist/session.d.ts.map +1 -1
  84. package/dist/session.js +343 -162
  85. package/dist/session.js.map +1 -1
  86. package/dist/types.d.ts +6 -0
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/types.js +4 -1
  89. package/dist/types.js.map +1 -1
  90. package/package.json +11 -3
package/dist/session.js CHANGED
@@ -1,23 +1,35 @@
1
1
  /**
2
2
  * Session implementation for the CodeSpar managed runtime.
3
3
  */
4
- import { networkErrorToApiError, throwFromResponse } from "./errors.js";
5
- // Wraps `fetch` so network rejections become CodesparApiError
6
- // (status: 0) at the very edge of the SDK, rather than bubbling
7
- // raw TypeError / DOMException up to the caller. Every transport
8
- // site in this file goes through here.
9
- async function safeFetch(input, init, what) {
4
+ import { CodesparApiError, networkErrorToApiError, throwFromResponse, TimeoutError } from "./errors.js";
5
+ import { fetchWithTimeout, validateTimeout } from "./internal/fetch.js";
6
+ import { mergeSignals } from "./internal/abort.js";
7
+ // Every unary transport site funnels through here. The `consume`
8
+ // callback reads the body inside the timeout/abort budget (via
9
+ // fetchWithTimeout), so a backend that stalls the body still hits the
10
+ // deadline. Network rejections become CodesparApiError (status: 0);
11
+ // typed timeouts and caller aborts propagate verbatim; structured API
12
+ // errors raised inside `consume` (throwFromResponse) pass through
13
+ // unwrapped. Streaming sites use raw fetch (SSE idle-timeout lives at
14
+ // the stream layer), not this helper.
15
+ async function safeFetch(input, init, what, timeoutOpts, consume) {
10
16
  try {
11
- return await fetch(input, init);
17
+ return await fetchWithTimeout(input, init, timeoutOpts, consume);
12
18
  }
13
19
  catch (cause) {
20
+ if (cause instanceof TimeoutError)
21
+ throw cause;
22
+ if (cause instanceof CodesparApiError)
23
+ throw cause;
24
+ if (timeoutOpts.signal?.aborted)
25
+ throw cause;
14
26
  throw networkErrorToApiError(cause, what);
15
27
  }
16
28
  }
17
29
  // The concrete session object satisfies the Session interface and carries extra
18
30
  // internal methods (tools, findTools) that the free functions in tools.ts access
19
31
  // via duck-typing. They are intentionally not declared on the Session interface.
20
- export async function createSession(userId, config, deps) {
32
+ export async function createSession(userId, config, deps, createOpts) {
21
33
  const { baseUrl, apiKey } = deps;
22
34
  const projectId = config.projectId ?? deps.projectId;
23
35
  const headers = {
@@ -31,6 +43,12 @@ export async function createSession(userId, config, deps) {
31
43
  metadata: config.metadata,
32
44
  projectId: config.projectId ?? deps.projectId,
33
45
  };
46
+ // Resolve per-call timeout/abort against the client default. Kept local
47
+ // so every method funnels through one place if CallOptions grows.
48
+ const callOpts = (o) => ({
49
+ timeout: o?.timeout ?? deps.timeout,
50
+ signal: o?.signal,
51
+ });
34
52
  // Conditional spread keeps the wire body byte-identical to the
35
53
  // pre-PRD shape when the caller omits mocks (R18 wire-neutrality).
36
54
  // The field is forwarded verbatim — no canonical-name rewriting on
@@ -42,15 +60,15 @@ export async function createSession(userId, config, deps) {
42
60
  user_id: userId,
43
61
  ...(config.mocks !== undefined ? { mocks: config.mocks } : {}),
44
62
  };
45
- const res = await safeFetch(`${baseUrl}/v1/sessions`, {
63
+ const data = await safeFetch(`${baseUrl}/v1/sessions`, {
46
64
  method: "POST",
47
65
  headers,
48
66
  body: JSON.stringify(wireBody),
49
- }, "createSession");
50
- if (!res.ok) {
51
- await throwFromResponse(res, "createSession");
52
- }
53
- const data = (await res.json());
67
+ }, "createSession", callOpts(createOpts), async (res) => {
68
+ if (!res.ok)
69
+ await throwFromResponse(res, "createSession");
70
+ return (await res.json());
71
+ });
54
72
  let cachedTools = null;
55
73
  let cachedConnections = null;
56
74
  const session = {
@@ -80,29 +98,29 @@ export async function createSession(userId, config, deps) {
80
98
  return all.filter((t) => t.name.toLowerCase().includes(q) ||
81
99
  t.description.toLowerCase().includes(q));
82
100
  },
83
- async execute(toolName, params) {
101
+ async execute(toolName, params, opts) {
84
102
  const start = Date.now();
85
- const r = await safeFetch(`${baseUrl}/v1/sessions/${data.id}/execute`, {
103
+ return await safeFetch(`${baseUrl}/v1/sessions/${data.id}/execute`, {
86
104
  method: "POST",
87
105
  headers,
88
106
  body: JSON.stringify({ tool: toolName, input: params }),
89
- }, "execute");
90
- if (!r.ok) {
91
- const body = await r.text();
92
- return {
93
- success: false,
94
- data: null,
95
- error: `${r.status}: ${body}`,
96
- duration: Date.now() - start,
97
- server: "",
98
- tool: toolName,
99
- };
100
- }
101
- const result = (await r.json());
102
- return result;
107
+ }, "execute", callOpts(opts), async (r) => {
108
+ if (!r.ok) {
109
+ const body = await r.text();
110
+ return {
111
+ success: false,
112
+ data: null,
113
+ error: `${r.status}: ${body}`,
114
+ duration: Date.now() - start,
115
+ server: "",
116
+ tool: toolName,
117
+ };
118
+ }
119
+ return (await r.json());
120
+ });
103
121
  },
104
- async proxyExecute(request) {
105
- const r = await safeFetch(`${baseUrl}/v1/sessions/${data.id}/proxy_execute`, {
122
+ async proxyExecute(request, opts) {
123
+ return await safeFetch(`${baseUrl}/v1/sessions/${data.id}/proxy_execute`, {
106
124
  method: "POST",
107
125
  headers,
108
126
  body: JSON.stringify({
@@ -113,45 +131,72 @@ export async function createSession(userId, config, deps) {
113
131
  params: request.params,
114
132
  headers: request.headers,
115
133
  }),
116
- }, "proxyExecute");
117
- if (!r.ok) {
118
- await throwFromResponse(r, "proxyExecute");
119
- }
120
- return (await r.json());
134
+ }, "proxyExecute", callOpts(opts), async (r) => {
135
+ if (!r.ok)
136
+ await throwFromResponse(r, "proxyExecute");
137
+ return (await r.json());
138
+ });
121
139
  },
122
- async send(message) {
123
- const r = await safeFetch(`${baseUrl}/v1/sessions/${data.id}/send`, {
140
+ async send(message, opts) {
141
+ return await safeFetch(`${baseUrl}/v1/sessions/${data.id}/send`, {
124
142
  method: "POST",
125
143
  headers: { ...headers, Accept: "application/json" },
126
144
  body: JSON.stringify({ message }),
127
- }, "send");
128
- if (!r.ok) {
129
- await throwFromResponse(r, "send");
130
- }
131
- return (await r.json());
145
+ }, "send", callOpts(opts), async (r) => {
146
+ if (!r.ok)
147
+ await throwFromResponse(r, "send");
148
+ return (await r.json());
149
+ });
132
150
  },
133
- async *sendStream(message) {
134
- const r = await safeFetch(`${baseUrl}/v1/sessions/${data.id}/send`, {
135
- method: "POST",
136
- headers: { ...headers, Accept: "text/event-stream" },
137
- body: JSON.stringify({ message }),
138
- }, "sendStream");
139
- if (!r.ok || !r.body) {
140
- await throwFromResponse(r, "sendStream");
151
+ async *sendStream(message, opts) {
152
+ const ms = opts?.timeout ?? deps.timeout;
153
+ validateTimeout(ms);
154
+ const idleAc = new AbortController();
155
+ const merged = mergeSignals([idleAc.signal, opts?.signal]);
156
+ const idle = makeIdle(idleAc, ms);
157
+ try {
158
+ // Arm the timeout before the fetch so the connect/headers phase
159
+ // is covered too — not just post-body idle. Otherwise a backend
160
+ // that accepts the socket but never sends headers hangs forever.
161
+ idle.reset();
162
+ const r = await fetch(`${baseUrl}/v1/sessions/${data.id}/send`, {
163
+ method: "POST",
164
+ headers: { ...headers, Accept: "text/event-stream" },
165
+ body: JSON.stringify({ message }),
166
+ signal: merged.signal,
167
+ });
168
+ if (!r.ok || !r.body) {
169
+ await throwFromResponse(r, "sendStream");
170
+ }
171
+ // r.body is non-null here — throwFromResponse always throws.
172
+ yield* parseSseStream(r.body, idle, merged.signal);
173
+ }
174
+ catch (err) {
175
+ if (opts?.signal?.aborted)
176
+ throw opts.signal.reason;
177
+ if (idleAc.signal.aborted)
178
+ throw new TimeoutError(ms);
179
+ // Structured API errors propagate; only raw transport rejections
180
+ // collapse to CodesparApiError(status: 0), matching safeFetch.
181
+ if (err instanceof CodesparApiError)
182
+ throw err;
183
+ throw networkErrorToApiError(err, "sendStream");
184
+ }
185
+ finally {
186
+ idle.pause();
187
+ merged.cleanup();
141
188
  }
142
- // Once we're past the !r.ok branch, r.body is guaranteed non-null.
143
- yield* parseSseStream(r.body);
144
189
  },
145
190
  /**
146
191
  * codespar_discover wrapper. Same wire as
147
192
  * `execute("codespar_discover", {...})` but returns a typed
148
193
  * DiscoverResult so the caller doesn't have to cast.
149
194
  */
150
- async discover(useCase, options) {
195
+ async discover(useCase, options, opts) {
151
196
  const result = await session.execute("codespar_discover", {
152
197
  use_case: useCase,
153
198
  ...(options ?? {}),
154
- });
199
+ }, opts);
155
200
  if (!result.success) {
156
201
  throw new Error(`discover failed: ${result.error ?? "unknown"}`);
157
202
  }
@@ -165,8 +210,8 @@ export async function createSession(userId, config, deps) {
165
210
  * when no server_id is given, "status" otherwise (matching the
166
211
  * backend default).
167
212
  */
168
- async connectionWizard(options) {
169
- const result = await session.execute("codespar_manage_connections", options);
213
+ async connectionWizard(options, opts) {
214
+ const result = await session.execute("codespar_manage_connections", options, opts);
170
215
  if (!result.success) {
171
216
  throw new Error(`connectionWizard failed: ${result.error ?? "unknown"}`);
172
217
  }
@@ -179,8 +224,8 @@ export async function createSession(userId, config, deps) {
179
224
  * ToolResult.data. Distinct from the legacy `codespar_pay` rail
180
225
  * (which routes to outbound transfers/payouts).
181
226
  */
182
- async charge(args) {
183
- const result = await session.execute("codespar_charge", args);
227
+ async charge(args, opts) {
228
+ const result = await session.execute("codespar_charge", args, opts);
184
229
  if (!result.success) {
185
230
  throw new Error(`charge failed: ${result.error ?? "unknown"}`);
186
231
  }
@@ -193,8 +238,8 @@ export async function createSession(userId, config, deps) {
193
238
  * ShipResult so the caller doesn't have to cast through
194
239
  * ToolResult.data.
195
240
  */
196
- async ship(args) {
197
- const result = await session.execute("codespar_ship", args);
241
+ async ship(args, opts) {
242
+ const result = await session.execute("codespar_ship", args, opts);
198
243
  if (!result.success) {
199
244
  throw new Error(`ship failed: ${result.error ?? "unknown"}`);
200
245
  }
@@ -208,8 +253,8 @@ export async function createSession(userId, config, deps) {
208
253
  * LedgerResult so the caller doesn't have to cast through
209
254
  * ToolResult.data.
210
255
  */
211
- async ledger(args) {
212
- const result = await session.execute("codespar_ledger", args);
256
+ async ledger(args, opts) {
257
+ const result = await session.execute("codespar_ledger", args, opts);
213
258
  if (!result.success) {
214
259
  throw new Error(`ledger failed: ${result.error ?? "unknown"}`);
215
260
  }
@@ -221,8 +266,8 @@ export async function createSession(userId, config, deps) {
221
266
  * Pomelo card-issuing program. Same wire as
222
267
  * `execute("codespar_issue", {...})` but returns a typed IssueResult.
223
268
  */
224
- async issue(args) {
225
- const result = await session.execute("codespar_issue", args);
269
+ async issue(args, opts) {
270
+ const result = await session.execute("codespar_issue", args, opts);
226
271
  if (!result.success) {
227
272
  throw new Error(`issue failed: ${result.error ?? "unknown"}`);
228
273
  }
@@ -241,81 +286,123 @@ export async function createSession(userId, config, deps) {
241
286
  * (a registered implementation behind the contract); a self-hosted
242
287
  * OSS runtime with none returns "Tool not registered".
243
288
  */
244
- async shop(args) {
245
- const result = await session.execute("codespar_shop", args);
289
+ async shop(args, opts) {
290
+ const result = await session.execute("codespar_shop", args, opts);
246
291
  if (!result.success) {
247
292
  throw new Error(`shop failed: ${result.error ?? "unknown"}`);
248
293
  }
249
294
  return result.data;
250
295
  },
251
- async paymentStatus(toolCallId) {
252
- const r = await safeFetch(`${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/payment-status`, { headers }, "paymentStatus");
253
- if (!r.ok) {
254
- await throwFromResponse(r, "paymentStatus");
255
- }
256
- return (await r.json());
296
+ async paymentStatus(toolCallId, opts) {
297
+ return await safeFetch(`${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/payment-status`, { headers }, "paymentStatus", callOpts(opts), async (r) => {
298
+ if (!r.ok)
299
+ await throwFromResponse(r, "paymentStatus");
300
+ return (await r.json());
301
+ });
257
302
  },
258
- async verificationStatus(toolCallId) {
259
- const r = await safeFetch(`${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/verification-status`, { headers }, "verificationStatus");
260
- if (!r.ok) {
261
- await throwFromResponse(r, "verificationStatus");
262
- }
263
- return (await r.json());
303
+ async verificationStatus(toolCallId, opts) {
304
+ return await safeFetch(`${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/verification-status`, { headers }, "verificationStatus", callOpts(opts), async (r) => {
305
+ if (!r.ok)
306
+ await throwFromResponse(r, "verificationStatus");
307
+ return (await r.json());
308
+ });
264
309
  },
265
310
  async paymentStatusStream(toolCallId, options = {}) {
266
- const url = `${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/payment-status/stream`;
267
- const r = await safeFetch(url, {
268
- headers: { ...headers, Accept: "text/event-stream" },
269
- signal: options.signal,
270
- }, "paymentStatusStream");
271
- if (!r.ok || !r.body) {
272
- await throwFromResponse(r, "paymentStatusStream");
273
- // unreachable — throwFromResponse always throws
274
- throw new Error("unreachable");
275
- }
276
- let last = null;
277
- for await (const frame of parseStatusSseStream(r.body)) {
278
- if (frame.event === "snapshot" || frame.event === "update") {
279
- last = frame.data;
280
- options.onUpdate?.(last);
311
+ const ms = options.timeout ?? deps.timeout;
312
+ validateTimeout(ms);
313
+ const idleAc = new AbortController();
314
+ const merged = mergeSignals([idleAc.signal, options.signal]);
315
+ const idle = makeIdle(idleAc, ms);
316
+ try {
317
+ // Arm the timeout before the fetch so connect/headers are covered.
318
+ idle.reset();
319
+ const url = `${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/payment-status/stream`;
320
+ const r = await fetch(url, {
321
+ headers: { ...headers, Accept: "text/event-stream" },
322
+ signal: merged.signal,
323
+ });
324
+ if (!r.ok || !r.body) {
325
+ await throwFromResponse(r, "paymentStatusStream");
326
+ }
327
+ let last = null;
328
+ // r.body is non-null here — throwFromResponse always throws.
329
+ for await (const frame of parseStatusSseStream(r.body, idle, merged.signal)) {
330
+ if (frame.event === "snapshot" || frame.event === "update") {
331
+ last = frame.data;
332
+ options.onUpdate?.(last);
333
+ }
334
+ else if (frame.event === "done") {
335
+ break;
336
+ }
281
337
  }
282
- else if (frame.event === "done") {
283
- break;
338
+ if (!last) {
339
+ throw new Error("paymentStatusStream: stream closed before snapshot");
284
340
  }
341
+ return last;
285
342
  }
286
- if (!last) {
287
- throw new Error("paymentStatusStream: stream closed before snapshot");
343
+ catch (err) {
344
+ if (options.signal?.aborted)
345
+ throw options.signal.reason;
346
+ if (idleAc.signal.aborted)
347
+ throw new TimeoutError(ms);
348
+ if (err instanceof CodesparApiError)
349
+ throw err;
350
+ throw networkErrorToApiError(err, "paymentStatusStream");
351
+ }
352
+ finally {
353
+ idle.pause();
354
+ merged.cleanup();
288
355
  }
289
- return last;
290
356
  },
291
357
  async verificationStatusStream(toolCallId, options = {}) {
292
- const url = `${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/verification-status/stream`;
293
- const r = await safeFetch(url, {
294
- headers: { ...headers, Accept: "text/event-stream" },
295
- signal: options.signal,
296
- }, "verificationStatusStream");
297
- if (!r.ok || !r.body) {
298
- await throwFromResponse(r, "verificationStatusStream");
299
- // unreachable — throwFromResponse always throws
300
- throw new Error("unreachable");
301
- }
302
- let last = null;
303
- for await (const frame of parseStatusSseStream(r.body)) {
304
- if (frame.event === "snapshot" || frame.event === "update") {
305
- last = frame.data;
306
- options.onUpdate?.(last);
358
+ const ms = options.timeout ?? deps.timeout;
359
+ validateTimeout(ms);
360
+ const idleAc = new AbortController();
361
+ const merged = mergeSignals([idleAc.signal, options.signal]);
362
+ const idle = makeIdle(idleAc, ms);
363
+ try {
364
+ // Arm the timeout before the fetch so connect/headers are covered.
365
+ idle.reset();
366
+ const url = `${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/verification-status/stream`;
367
+ const r = await fetch(url, {
368
+ headers: { ...headers, Accept: "text/event-stream" },
369
+ signal: merged.signal,
370
+ });
371
+ if (!r.ok || !r.body) {
372
+ await throwFromResponse(r, "verificationStatusStream");
373
+ }
374
+ let last = null;
375
+ // r.body is non-null here — throwFromResponse always throws.
376
+ for await (const frame of parseStatusSseStream(r.body, idle, merged.signal)) {
377
+ if (frame.event === "snapshot" || frame.event === "update") {
378
+ last = frame.data;
379
+ options.onUpdate?.(last);
380
+ }
381
+ else if (frame.event === "done") {
382
+ break;
383
+ }
307
384
  }
308
- else if (frame.event === "done") {
309
- break;
385
+ if (!last) {
386
+ throw new Error("verificationStatusStream: stream closed before snapshot");
310
387
  }
388
+ return last;
311
389
  }
312
- if (!last) {
313
- throw new Error("verificationStatusStream: stream closed before snapshot");
390
+ catch (err) {
391
+ if (options.signal?.aborted)
392
+ throw options.signal.reason;
393
+ if (idleAc.signal.aborted)
394
+ throw new TimeoutError(ms);
395
+ if (err instanceof CodesparApiError)
396
+ throw err;
397
+ throw networkErrorToApiError(err, "verificationStatusStream");
398
+ }
399
+ finally {
400
+ idle.pause();
401
+ merged.cleanup();
314
402
  }
315
- return last;
316
403
  },
317
- async authorize(serverId, config) {
318
- const r = await safeFetch(`${baseUrl}/v1/connect/start`, {
404
+ async authorize(serverId, config, opts) {
405
+ const payload = await safeFetch(`${baseUrl}/v1/connect/start`, {
319
406
  method: "POST",
320
407
  headers,
321
408
  body: JSON.stringify({
@@ -324,43 +411,59 @@ export async function createSession(userId, config, deps) {
324
411
  redirect_uri: config.redirectUri,
325
412
  scopes: config.scopes,
326
413
  }),
327
- }, "authorize");
328
- if (!r.ok) {
329
- await throwFromResponse(r, "authorize");
330
- }
331
- const payload = (await r.json());
414
+ }, "authorize", callOpts(opts), async (r) => {
415
+ if (!r.ok)
416
+ await throwFromResponse(r, "authorize");
417
+ return (await r.json());
418
+ });
332
419
  return {
333
420
  linkToken: payload.link_token,
334
421
  authorizeUrl: payload.authorize_url,
335
422
  expiresAt: payload.expires_at,
336
423
  };
337
424
  },
338
- async connections() {
425
+ async connections(opts) {
426
+ // Caller misconfiguration is NOT best-effort — validate the
427
+ // effective timeout before the swallow (parity with close()), so
428
+ // an invalid value surfaces instead of silently returning the
429
+ // cached list.
430
+ const resolved = callOpts(opts);
431
+ validateTimeout(resolved.timeout);
339
432
  // Best-effort — both transport failures (CodesparApiError from
340
433
  // safeFetch) and non-2xx responses fall back to the cached
341
434
  // payload so a transient blip doesn't crater the session.
342
435
  try {
343
- const r = await safeFetch(`${baseUrl}/v1/sessions/${data.id}/connections`, { headers }, "connections");
344
- if (!r.ok)
345
- return cachedConnections ?? [];
346
- const payload = (await r.json());
347
- cachedConnections = payload.servers;
348
- cachedTools = payload.tools;
349
- return payload.servers;
436
+ return await safeFetch(`${baseUrl}/v1/sessions/${data.id}/connections`, { headers }, "connections", resolved, async (r) => {
437
+ if (!r.ok)
438
+ return cachedConnections ?? [];
439
+ const payload = (await r.json());
440
+ cachedConnections = payload.servers;
441
+ cachedTools = payload.tools;
442
+ return payload.servers;
443
+ });
350
444
  }
351
445
  catch {
352
446
  return cachedConnections ?? [];
353
447
  }
354
448
  },
355
- async close() {
356
- // Best-effort — the backend reaps stale sessions on a timer,
357
- // so a network failure here shouldn't surface to the caller.
449
+ async close(opts) {
450
+ // Caller misconfiguration is NOT best-effort — validate the
451
+ // effective timeout before the swallow, so an invalid value
452
+ // surfaces (parity with Python's ConfigError) instead of
453
+ // silently skipping the DELETE.
454
+ const { timeout } = callOpts(opts);
455
+ validateTimeout(timeout);
456
+ // Best-effort: the DELETE is bounded by the timeout/abort budget
457
+ // so close() can't hang, but its outcome is swallowed so a slow
458
+ // or failing backend cleanup never throws from a caller's
459
+ // teardown/finally. Parity with the Python client (suppresses
460
+ // ApiError + TimeoutError) and the managed-agents adapter (close
461
+ // never throws). The backend reaps stale sessions on a timer.
358
462
  try {
359
- await safeFetch(`${baseUrl}/v1/sessions/${data.id}`, { method: "DELETE", headers }, "close");
463
+ await safeFetch(`${baseUrl}/v1/sessions/${data.id}`, { method: "DELETE", headers }, "close", callOpts(opts), async () => undefined);
360
464
  }
361
465
  catch {
362
- // Intentional swallow — matches the previous fire-and-forget
363
- // contract; close() never threw.
466
+ // intentionally ignored — see above
364
467
  }
365
468
  },
366
469
  };
@@ -369,31 +472,53 @@ export async function createSession(userId, config, deps) {
369
472
  const start = Date.now();
370
473
  while (Date.now() - start < timeout) {
371
474
  const conns = await session.connections();
372
- if (conns.every((c) => c.connected))
475
+ // `connections()` returns [] on a failed/empty response; an empty
476
+ // list must NOT count as "all connected" — [].every() is vacuously
477
+ // true and would resolve the gate with zero servers connected.
478
+ if (conns.length > 0 && conns.every((c) => c.connected))
373
479
  break;
374
480
  await new Promise((resolve) => setTimeout(resolve, 1000));
375
481
  }
376
482
  }
377
483
  return session;
378
484
  }
379
- /**
380
- * Parse a Server-Sent Events stream into typed StreamEvent objects.
381
- *
382
- * The backend emits events shaped like:
383
- * event: assistant_text
384
- * data: {"content":"...","iteration":1}
385
- *
386
- * which we map to discriminated-union StreamEvent values that the SDK
387
- * exports. We intentionally keep parsing tiny: split on double newlines,
388
- * pick out `event:` and `data:` lines, JSON-parse the data.
389
- */
390
- async function* parseSseStream(body) {
485
+ function makeIdle(idleAc, ms) {
486
+ let timer;
487
+ return {
488
+ reset() {
489
+ if (timer)
490
+ clearTimeout(timer);
491
+ timer = setTimeout(() => idleAc.abort(), ms);
492
+ },
493
+ pause() {
494
+ if (timer) {
495
+ clearTimeout(timer);
496
+ timer = undefined;
497
+ }
498
+ },
499
+ };
500
+ }
501
+ async function* parseSseStream(body, idle, signal) {
391
502
  const reader = body.getReader();
392
503
  const decoder = new TextDecoder();
393
504
  let buffer = "";
505
+ // Single abort promise + single listener per parser call — avoids
506
+ // per-iteration listener accumulation and MaxListeners warnings.
507
+ let abortReject;
508
+ const abortPromise = new Promise((_resolve, reject) => {
509
+ abortReject = reject;
510
+ });
511
+ const onAbort = () => abortReject(signal.reason);
512
+ if (signal.aborted) {
513
+ queueMicrotask(() => abortReject(signal.reason));
514
+ }
515
+ else {
516
+ signal.addEventListener("abort", onAbort, { once: true });
517
+ }
394
518
  try {
519
+ idle.reset();
395
520
  while (true) {
396
- const { done, value } = await reader.read();
521
+ const { done, value } = await Promise.race([reader.read(), abortPromise]);
397
522
  if (done)
398
523
  break;
399
524
  buffer += decoder.decode(value, { stream: true });
@@ -401,18 +526,42 @@ async function* parseSseStream(body) {
401
526
  while ((sep = buffer.indexOf("\n\n")) !== -1) {
402
527
  const chunk = buffer.slice(0, sep);
403
528
  buffer = buffer.slice(sep + 2);
529
+ // A complete SSE comment frame (":..." heartbeat) carries no
530
+ // data but IS protocol liveness — reset idle. Only an
531
+ // incomplete byte trickle (no "\n\n" yet) must still time out.
532
+ if (chunk.startsWith(":")) {
533
+ idle.reset();
534
+ continue;
535
+ }
404
536
  const event = parseSseChunk(chunk);
405
- if (event)
537
+ if (event) {
538
+ // Pause while the consumer processes the event so its
539
+ // processing time is not counted as transport idle; re-arm
540
+ // once control returns, before the next read.
541
+ idle.pause();
406
542
  yield event;
543
+ idle.reset();
544
+ }
407
545
  }
408
546
  }
409
547
  if (buffer.trim()) {
410
548
  const event = parseSseChunk(buffer);
411
- if (event)
549
+ if (event) {
550
+ // Same invariant as the inner loop: pause the idle timer
551
+ // while the consumer holds control. The stream has ended, so
552
+ // there is no read to re-arm for afterwards.
553
+ idle.pause();
412
554
  yield event;
555
+ }
413
556
  }
414
557
  }
415
558
  finally {
559
+ idle.pause();
560
+ signal.removeEventListener("abort", onAbort);
561
+ // Best-effort, NON-blocking: a degraded ReadableStream may return a
562
+ // cancel() promise that never settles; awaiting it here would delay
563
+ // delivery of the very TimeoutError/abort the caller is waiting for.
564
+ void Promise.resolve(reader.cancel()).catch(() => { });
416
565
  reader.releaseLock();
417
566
  }
418
567
  }
@@ -488,13 +637,27 @@ const PRESET_SERVERS = {
488
637
  * just need raw `{event, data}` pairs the caller's typed wrapper
489
638
  * casts via the route-level envelope shape.
490
639
  */
491
- async function* parseStatusSseStream(body) {
640
+ async function* parseStatusSseStream(body, idle, signal) {
492
641
  const reader = body.getReader();
493
642
  const decoder = new TextDecoder();
494
643
  let buffer = "";
644
+ // Single abort promise + single listener per parser call — avoids
645
+ // per-iteration listener accumulation and MaxListeners warnings.
646
+ let abortReject;
647
+ const abortPromise = new Promise((_resolve, reject) => {
648
+ abortReject = reject;
649
+ });
650
+ const onAbort = () => abortReject(signal.reason);
651
+ if (signal.aborted) {
652
+ queueMicrotask(() => abortReject(signal.reason));
653
+ }
654
+ else {
655
+ signal.addEventListener("abort", onAbort, { once: true });
656
+ }
495
657
  try {
658
+ idle.reset();
496
659
  while (true) {
497
- const { done, value } = await reader.read();
660
+ const { done, value } = await Promise.race([reader.read(), abortPromise]);
498
661
  if (done)
499
662
  break;
500
663
  buffer += decoder.decode(value, { stream: true });
@@ -502,9 +665,16 @@ async function* parseStatusSseStream(body) {
502
665
  while ((sep = buffer.indexOf("\n\n")) !== -1) {
503
666
  const chunk = buffer.slice(0, sep);
504
667
  buffer = buffer.slice(sep + 2);
505
- // Comment frames (heartbeats) start with ":" and carry no data.
506
- if (chunk.startsWith(":"))
668
+ // Comment frames (heartbeats) start with ":" and carry no
669
+ // data, but a complete heartbeat frame IS protocol liveness —
670
+ // reset idle so healthy long-pending streams don't time out
671
+ // (parity with Python httpx, whose read timeout any byte
672
+ // resets). An incomplete byte trickle still times out because
673
+ // it never forms a "\n\n" frame.
674
+ if (chunk.startsWith(":")) {
675
+ idle.reset();
507
676
  continue;
677
+ }
508
678
  let eventName = "message";
509
679
  let dataLine = "";
510
680
  for (const line of chunk.split("\n")) {
@@ -515,16 +685,27 @@ async function* parseStatusSseStream(body) {
515
685
  }
516
686
  if (!dataLine)
517
687
  continue;
688
+ let parsed;
518
689
  try {
519
- yield { event: eventName, data: JSON.parse(dataLine) };
690
+ parsed = JSON.parse(dataLine);
520
691
  }
521
692
  catch {
522
693
  continue;
523
694
  }
695
+ // Pause while the consumer processes the frame; re-arm after.
696
+ idle.pause();
697
+ yield { event: eventName, data: parsed };
698
+ idle.reset();
524
699
  }
525
700
  }
526
701
  }
527
702
  finally {
703
+ idle.pause();
704
+ signal.removeEventListener("abort", onAbort);
705
+ // Best-effort, NON-blocking: a degraded ReadableStream may return a
706
+ // cancel() promise that never settles; awaiting it here would delay
707
+ // delivery of the very TimeoutError/abort the caller is waiting for.
708
+ void Promise.resolve(reader.cancel()).catch(() => { });
528
709
  reader.releaseLock();
529
710
  }
530
711
  }