@recursiv/sdk 0.7.50 → 0.7.51

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/dist/realtime.js CHANGED
@@ -16,11 +16,62 @@ async function loadIo() {
16
16
  }
17
17
  return cachedIo;
18
18
  }
19
+ /**
20
+ * Wider and more jittered than socket.io's own 1s/5s: when an API restart
21
+ * drops every client at once they must not all re-fetch `/realtime/ws-token`
22
+ * in the same second, which rate-limits it and keeps them all offline.
23
+ */
24
+ const DEFAULT_BACKOFF = {
25
+ reconnectionDelay: 2_000,
26
+ reconnectionDelayMax: 30_000,
27
+ randomizationFactor: 0.5,
28
+ };
29
+ /** The token source refused the credential itself; retrying cannot succeed. */
30
+ function isCredentialRefusal(err) {
31
+ const status = err?.status;
32
+ return status === 401 || status === 403;
33
+ }
34
+ /**
35
+ * WebSocket client for chat and presence.
36
+ *
37
+ * One socket at a time, and it survives the ways a socket dies:
38
+ * - a transport drop (network blip, sleep, backgrounding, API restart) is
39
+ * retried by socket.io with jittered backoff;
40
+ * - the server ending the connection because its token expired, or refusing
41
+ * a reconnect handshake (spent or expired token, rate limit), is retried
42
+ * here with the same backoff, since socket.io does not retry either;
43
+ * - every attempt authenticates with its own token (the server accepts each
44
+ * token once), and a failed token fetch sends none rather than a spent one;
45
+ * - listeners registered with `on()`/`onMessage()`… and rooms joined with
46
+ * `joinConversation()` are re-applied to every connection, including a
47
+ * socket rebuilt by a later `connect()`.
48
+ *
49
+ * Reconnecting stops only on `disconnect()` or when the token source refuses
50
+ * the credential (401/403).
51
+ */
19
52
  export class RealtimeClient {
20
53
  socket = null;
21
54
  client;
22
55
  connectionPromise = null;
23
56
  tokenProvider = null;
57
+ backoff = { ...DEFAULT_BACKOFF };
58
+ /** Every subscription, attached to each socket this client builds. */
59
+ listeners = new Map();
60
+ /** Conversation rooms to join on every connect. */
61
+ rooms = new Set();
62
+ /** connect() callers waiting on a socket that is reconnecting. */
63
+ waiters = [];
64
+ recoveryTimer = null;
65
+ recoveryAttempts = 0;
66
+ credentialRefused = false;
67
+ /** Bumped by disconnect(), so a connect() still in flight does not revive a socket. */
68
+ generation = 0;
69
+ /**
70
+ * The socket whose first handshake a connect() is awaiting. Closing a socket
71
+ * that never connected makes socket.io emit nothing, so discardSocket()
72
+ * settles that connect() through this.
73
+ */
74
+ pendingOpen = null;
24
75
  constructor(client) {
25
76
  this.client = client;
26
77
  }
@@ -32,107 +83,324 @@ export class RealtimeClient {
32
83
  const response = await this.client.get('/realtime/ws-token');
33
84
  return response.data?.token || undefined;
34
85
  }
35
- catch {
86
+ catch (err) {
87
+ if (isCredentialRefusal(err))
88
+ this.credentialRefused = true;
89
+ return undefined;
90
+ }
91
+ }
92
+ /** A token for one reconnect attempt. Never throws: no token means the attempt is refused and retried. */
93
+ async mintToken() {
94
+ try {
95
+ const token = this.tokenProvider ? await this.tokenProvider() : await this.fetchTokenWithClientKey();
96
+ if (token)
97
+ this.credentialRefused = false;
98
+ return token || undefined;
99
+ }
100
+ catch (err) {
101
+ if (isCredentialRefusal(err))
102
+ this.credentialRefused = true;
36
103
  return undefined;
37
104
  }
38
105
  }
39
106
  /**
40
107
  * Connects to the WebSocket server using a temporary WebSocket token.
108
+ * Resolves with the connected socket.
41
109
  *
42
- * Reconnect-safe by design:
43
- * - the connection promise is cleared after settling, so a later call
44
- * builds a fresh socket instead of returning a dead one;
45
- * - `connect_error` only tears down the INITIAL attempt — after the first
46
- * successful connect, socket.io's auto-reconnection owns recovery and
47
- * transient errors never disable it;
48
- * - auth uses socket.io's function form, so every (re)connection attempt
49
- * fetches a fresh token via `options.getToken` or the client's key.
110
+ * Safe to call as often as you like: a connected socket is returned as is,
111
+ * a socket that is reconnecting is waited on (the promise rejects if that
112
+ * attempt fails, while the client keeps retrying), and only a socket that
113
+ * has stopped for good is replaced.
50
114
  */
51
115
  async connect(options) {
52
116
  if (options?.getToken) {
53
117
  this.tokenProvider = options.getToken;
54
118
  }
55
- if (this.socket?.connected) {
56
- return this.socket;
57
- }
58
- // A socket that is neither connected nor actively reconnecting is dead —
59
- // socket.io has given up on it. Drop it so we build a fresh one.
60
- if (this.socket && !this.socket.connected && !this.socket.active) {
61
- this.socket.disconnect();
62
- this.socket = null;
63
- this.connectionPromise = null;
119
+ if (options)
120
+ this.applyBackoff(options);
121
+ const current = this.socket;
122
+ if (current?.connected) {
123
+ return current;
64
124
  }
65
125
  if (this.connectionPromise) {
66
126
  return this.connectionPromise;
67
127
  }
68
- this.connectionPromise = (async () => {
69
- const io = await loadIo();
70
- const provider = this.tokenProvider ?? (() => this.fetchTokenWithClientKey());
71
- const token = await provider();
72
- if (!token) {
73
- throw new Error('Failed to fetch WebSocket token');
74
- }
75
- const socket = io(this.client.baseOrigin, {
76
- path: '/socket.io',
77
- transports: ['websocket', 'polling'],
78
- // Function-form auth: socket.io invokes this on EVERY connection
79
- // attempt, so reconnects always carry a fresh short-lived token.
80
- auth: (cb) => {
81
- provider()
82
- .then((fresh) => cb({ token: fresh ?? token }))
83
- .catch(() => cb({ token }));
84
- },
85
- autoConnect: true,
86
- reconnection: true,
87
- });
88
- this.socket = socket;
89
- return await new Promise((resolve, reject) => {
90
- const onConnect = () => {
91
- socket.off('connect', onConnect);
92
- socket.off('connect_error', onInitialError);
93
- resolve(socket);
94
- };
95
- const onInitialError = (err) => {
96
- // Initial handshake failed — tear down and surface the error.
97
- // This handler is removed on first successful connect, so later
98
- // reconnect errors never disable the socket.
99
- socket.off('connect', onConnect);
100
- socket.off('connect_error', onInitialError);
101
- socket.disconnect();
102
- if (this.socket === socket) {
103
- this.socket = null;
104
- }
105
- reject(err);
106
- };
107
- socket.on('connect', onConnect);
108
- socket.on('connect_error', onInitialError);
109
- });
110
- })();
128
+ // socket.io is retrying the transport, or a retry of a refused handshake
129
+ // is scheduled. Building a second socket here would leave the first one
130
+ // reconnecting in the background with its own listeners: duplicate events
131
+ // and an extra server connection per call.
132
+ if (current && (current.active || this.recoveryTimer)) {
133
+ return this.waitForConnect();
134
+ }
135
+ // Neither connected nor reconnecting: socket.io has given up on it.
136
+ if (current) {
137
+ this.discardSocket(current, new Error('Realtime connection replaced'));
138
+ }
139
+ const opening = this.openSocket();
140
+ this.connectionPromise = opening;
111
141
  try {
112
- return await this.connectionPromise;
142
+ return await opening;
113
143
  }
114
144
  finally {
115
145
  // Clear after settling (success OR failure): the live socket is held in
116
- // this.socket; a dead one must not be served from a stale promise.
117
- this.connectionPromise = null;
146
+ // this.socket; a dead one must not be served from a stale promise. Only
147
+ // clear our own: after disconnect() a later connect() may already own
148
+ // the slot, and forgetting it would let the next call open a second
149
+ // socket beside it.
150
+ if (this.connectionPromise === opening)
151
+ this.connectionPromise = null;
152
+ }
153
+ }
154
+ applyBackoff(options) {
155
+ const next = { ...this.backoff };
156
+ if (typeof options.reconnectionDelay === 'number' && options.reconnectionDelay >= 0) {
157
+ next.reconnectionDelay = options.reconnectionDelay;
158
+ }
159
+ if (typeof options.reconnectionDelayMax === 'number' && options.reconnectionDelayMax >= 0) {
160
+ next.reconnectionDelayMax = options.reconnectionDelayMax;
118
161
  }
162
+ if (typeof options.randomizationFactor === 'number') {
163
+ next.randomizationFactor = Math.min(1, Math.max(0, options.randomizationFactor));
164
+ }
165
+ this.backoff = next;
119
166
  }
167
+ async openSocket() {
168
+ const generation = this.generation;
169
+ const io = await loadIo();
170
+ const token = this.tokenProvider ? await this.tokenProvider() : await this.fetchTokenWithClientKey();
171
+ if (!token) {
172
+ throw new Error('Failed to fetch WebSocket token');
173
+ }
174
+ if (generation !== this.generation) {
175
+ throw new Error('Realtime disconnected before the connection opened');
176
+ }
177
+ this.credentialRefused = false;
178
+ // The server accepts each token once. The first handshake spends the one
179
+ // fetched above; every later attempt (socket.io's transport reconnects and
180
+ // the recoveries below) mints its own, and sends none when that fails
181
+ // rather than replaying a spent token.
182
+ let unspent = token;
183
+ const socket = io(this.client.baseOrigin, {
184
+ path: '/socket.io',
185
+ transports: ['websocket', 'polling'],
186
+ // Function-form auth: socket.io invokes this on EVERY connection attempt.
187
+ auth: (cb) => {
188
+ const first = unspent;
189
+ unspent = undefined;
190
+ if (first) {
191
+ cb({ token: first });
192
+ return;
193
+ }
194
+ void this.mintToken().then((fresh) => cb(fresh ? { token: fresh } : {}));
195
+ },
196
+ autoConnect: true,
197
+ reconnection: true,
198
+ reconnectionDelay: this.backoff.reconnectionDelay,
199
+ reconnectionDelayMax: this.backoff.reconnectionDelayMax,
200
+ randomizationFactor: this.backoff.randomizationFactor,
201
+ });
202
+ this.socket = socket;
203
+ this.watchLifecycle(socket);
204
+ for (const [event, listeners] of this.listeners) {
205
+ for (const listener of listeners)
206
+ socket.on(event, listener);
207
+ }
208
+ return await new Promise((resolve, reject) => {
209
+ const settle = () => {
210
+ socket.off('connect', onConnect);
211
+ socket.off('connect_error', onInitialError);
212
+ if (this.pendingOpen?.socket === socket)
213
+ this.pendingOpen = null;
214
+ };
215
+ const onConnect = () => {
216
+ settle();
217
+ resolve(socket);
218
+ };
219
+ const onInitialError = (err) => {
220
+ // Initial handshake failed — tear down and surface the error. This
221
+ // handler is removed on first successful connect, so later reconnect
222
+ // errors never disable the socket.
223
+ settle();
224
+ this.discardSocket(socket, err);
225
+ reject(err);
226
+ };
227
+ // disconnect() before the first handshake completes.
228
+ this.pendingOpen = {
229
+ socket,
230
+ fail: (err) => {
231
+ settle();
232
+ reject(err);
233
+ },
234
+ };
235
+ socket.on('connect', onConnect);
236
+ socket.on('connect_error', onInitialError);
237
+ });
238
+ }
239
+ /** Room rejoin, waiter settlement and recovery from the disconnects socket.io leaves alone. */
240
+ watchLifecycle(socket) {
241
+ let connectedOnce = false;
242
+ let tokenExpired = false;
243
+ socket.on('connect', () => {
244
+ if (this.socket !== socket)
245
+ return;
246
+ connectedOnce = true;
247
+ tokenExpired = false;
248
+ this.recoveryAttempts = 0;
249
+ // Room membership lives on the server-side connection, so a reconnect
250
+ // starts in no rooms. Rejoin every room the app is in.
251
+ for (const conversationId of this.rooms) {
252
+ socket.emit('join_conversation', conversationId);
253
+ }
254
+ const waiters = this.waiters;
255
+ this.waiters = [];
256
+ for (const waiter of waiters)
257
+ waiter.resolve(socket);
258
+ });
259
+ // The server announces an expired token just before it ends the connection.
260
+ socket.on('token_expired', () => {
261
+ tokenExpired = true;
262
+ });
263
+ socket.on('disconnect', (reason) => {
264
+ if (this.socket !== socket)
265
+ return;
266
+ // socket.io never reconnects a server-side disconnect. One that follows
267
+ // token_expired is routine (every token TTL, 10 minutes by default), so
268
+ // reconnect with a fresh token. Any other server disconnect, such as an
269
+ // eviction for too many open sockets, is left alone; the next connect()
270
+ // replaces the socket.
271
+ if (reason === 'io server disconnect' && tokenExpired) {
272
+ tokenExpired = false;
273
+ this.scheduleRecovery(socket);
274
+ }
275
+ });
276
+ socket.on('connect_error', (err) => {
277
+ if (this.socket !== socket || !connectedOnce)
278
+ return;
279
+ const waiters = this.waiters;
280
+ this.waiters = [];
281
+ for (const waiter of waiters)
282
+ waiter.reject(err);
283
+ // `active` stays true while socket.io retries a transport failure. It
284
+ // turns false when the server refused the handshake (spent or expired
285
+ // token, rate limit), which socket.io never retries.
286
+ if (!socket.active) {
287
+ this.scheduleRecovery(socket);
288
+ }
289
+ });
290
+ }
291
+ scheduleRecovery(socket) {
292
+ if (this.socket !== socket || this.recoveryTimer)
293
+ return;
294
+ if (this.credentialRefused) {
295
+ // The token source refused the credential. Stop here; an explicit
296
+ // connect() tries again from scratch.
297
+ this.discardSocket(socket, new Error('Realtime stopped: the WebSocket token request was refused (401/403)'));
298
+ return;
299
+ }
300
+ const delay = this.recoveryDelay(this.recoveryAttempts++);
301
+ this.recoveryTimer = setTimeout(() => {
302
+ this.recoveryTimer = null;
303
+ if (this.socket !== socket || socket.connected || socket.active)
304
+ return;
305
+ socket.connect();
306
+ }, delay);
307
+ }
308
+ /** Exponential backoff with jitter, bounded by reconnectionDelayMax. */
309
+ recoveryDelay(attempt) {
310
+ const { reconnectionDelay, reconnectionDelayMax, randomizationFactor } = this.backoff;
311
+ const base = Math.min(reconnectionDelayMax, reconnectionDelay * 2 ** Math.min(attempt, 20));
312
+ const jitter = base * randomizationFactor * (Math.random() * 2 - 1);
313
+ return Math.round(Math.min(reconnectionDelayMax, Math.max(0, base + jitter)));
314
+ }
315
+ waitForConnect() {
316
+ return new Promise((resolve, reject) => {
317
+ this.waiters.push({ resolve, reject });
318
+ });
319
+ }
320
+ discardSocket(socket, reason) {
321
+ if (this.recoveryTimer) {
322
+ clearTimeout(this.recoveryTimer);
323
+ this.recoveryTimer = null;
324
+ }
325
+ if (this.pendingOpen?.socket === socket) {
326
+ this.pendingOpen.fail(reason);
327
+ }
328
+ socket.disconnect();
329
+ for (const [event, listeners] of this.listeners) {
330
+ for (const listener of listeners)
331
+ socket.off(event, listener);
332
+ }
333
+ if (this.socket === socket) {
334
+ this.socket = null;
335
+ }
336
+ const waiters = this.waiters;
337
+ this.waiters = [];
338
+ for (const waiter of waiters)
339
+ waiter.reject(reason);
340
+ }
341
+ /**
342
+ * Close the connection and stop reconnecting. Joined rooms are forgotten;
343
+ * listeners stay registered and attach to the next connection.
344
+ */
120
345
  disconnect() {
346
+ this.generation += 1;
347
+ this.rooms.clear();
348
+ this.recoveryAttempts = 0;
349
+ this.credentialRefused = false;
121
350
  if (this.socket) {
122
- this.socket.disconnect();
123
- this.socket = null;
351
+ this.discardSocket(this.socket, new Error('Realtime disconnected'));
352
+ }
353
+ else {
354
+ const waiters = this.waiters;
355
+ this.waiters = [];
356
+ for (const waiter of waiters)
357
+ waiter.reject(new Error('Realtime disconnected'));
124
358
  }
125
359
  this.connectionPromise = null;
126
360
  }
127
361
  getSocket() {
128
362
  return this.socket;
129
363
  }
364
+ /**
365
+ * Listen for any server event, including the lifecycle events `connect`
366
+ * (initial and every reconnect: refetch what you may have missed) and
367
+ * `disconnect`. The listener stays attached across reconnects and socket
368
+ * replacement. Returns an unsubscribe function.
369
+ *
370
+ * Server events include `notification`, `feed_update`,
371
+ * `conversation_created`, `chat_message`, `chat_typing` and `agent_thinking`.
372
+ */
373
+ on(event, listener) {
374
+ // Every call is its own subscription, even for a function that is already
375
+ // subscribed: it fires once per subscription, as socket.io's own `on`
376
+ // does, and its unsubscribe removes only itself, so one consumer can never
377
+ // cancel another's.
378
+ const handler = (...args) => listener(...args);
379
+ let set = this.listeners.get(event);
380
+ if (!set) {
381
+ set = new Set();
382
+ this.listeners.set(event, set);
383
+ }
384
+ set.add(handler);
385
+ this.socket?.on(event, handler);
386
+ return () => {
387
+ const current = this.listeners.get(event);
388
+ if (!current?.delete(handler))
389
+ return;
390
+ if (current.size === 0)
391
+ this.listeners.delete(event);
392
+ this.socket?.off(event, handler);
393
+ };
394
+ }
395
+ /** Join a conversation room now, and again after every reconnect, until you leave it. */
130
396
  joinConversation(conversationId) {
397
+ this.rooms.add(conversationId);
131
398
  if (this.socket?.connected) {
132
399
  this.socket.emit('join_conversation', conversationId);
133
400
  }
134
401
  }
135
402
  leaveConversation(conversationId) {
403
+ this.rooms.delete(conversationId);
136
404
  if (this.socket?.connected) {
137
405
  this.socket.emit('leave_conversation', conversationId);
138
406
  }
@@ -143,23 +411,18 @@ export class RealtimeClient {
143
411
  }
144
412
  }
145
413
  onMessage(callback) {
146
- this.socket?.on('chat_message', callback);
147
- return () => this.socket?.off('chat_message', callback);
414
+ return this.on('chat_message', callback);
148
415
  }
149
416
  onMessageEdit(callback) {
150
- this.socket?.on('chat_message_edit', callback);
151
- return () => this.socket?.off('chat_message_edit', callback);
417
+ return this.on('chat_message_edit', callback);
152
418
  }
153
419
  onMessageDelete(callback) {
154
- this.socket?.on('chat_message_delete', callback);
155
- return () => this.socket?.off('chat_message_delete', callback);
420
+ return this.on('chat_message_delete', callback);
156
421
  }
157
422
  onTyping(callback) {
158
- this.socket?.on('chat_typing', callback);
159
- return () => this.socket?.off('chat_typing', callback);
423
+ return this.on('chat_typing', callback);
160
424
  }
161
425
  onAgentThinking(callback) {
162
- this.socket?.on('agent_thinking', callback);
163
- return () => this.socket?.off('agent_thinking', callback);
426
+ return this.on('agent_thinking', callback);
164
427
  }
165
428
  }
@@ -1,5 +1,5 @@
1
1
  import type { HttpClient } from '../client.js';
2
- import type { PaginatedResponse, SingleResponse, DeleteResponse, Agent, AgentDetail, CreateAgentInput, CreatedAgent, UpdateAgentInput, UpdatedAgent, AgentChatInput, AgentChatResult, AgentConversation, AgentConversationMessage, PaginationParams, StreamChunk } from '../types.js';
2
+ import type { PaginatedResponse, SingleResponse, DeleteResponse, Agent, AgentDetail, CreateAgentInput, CreatedAgent, UpdateAgentInput, UpdatedAgent, AgentChatInput, AgentChatRetryInput, AgentChatResult, AgentConversation, AgentConversationMessage, PaginationParams, StreamChunk } from '../types.js';
3
3
  export interface AgentMessage {
4
4
  id: string;
5
5
  networkId: string;
@@ -137,7 +137,14 @@ export declare class AgentsResource {
137
137
  * nothing landed, but never why.
138
138
  */
139
139
  conversationMessages(id: string, conversationId: string, params?: PaginationParams): Promise<PaginatedResponse<AgentConversationMessage>>;
140
- /** List discoverable agents (public + same-org) */
140
+ /**
141
+ * List the agents a member may discover: an app key gets the agents of its
142
+ * project and owning org, a personal key gets global and same-org agents.
143
+ * Only member-facing agents are returned. Agents that run a tenant (a
144
+ * project's Lead, swarm workers, dispatcher role agents, MCP client agents,
145
+ * the workspace AI, anyone's personal AI) never appear here; list your own
146
+ * with `list()`.
147
+ */
141
148
  listDiscoverable(params?: PaginationParams & {
142
149
  organization_id?: string;
143
150
  global?: boolean;
@@ -264,6 +271,16 @@ export declare class AgentsResource {
264
271
  * For React Native, use `chatStreamText()` instead.
265
272
  */
266
273
  chatStream(id: string, input: AgentChatInput): AsyncGenerator<StreamChunk>;
274
+ /**
275
+ * Retry a turn: stream a fresh reply to one of your existing messages
276
+ * without sending it again. Use it for "Try again" after a reply failed.
277
+ * The agent's earlier reply to that message, if any, is removed first.
278
+ *
279
+ * The message must be yours and the latest one in the conversation
280
+ * (409 `retry_not_latest` otherwise), and a reply still being written must
281
+ * finish or be stopped first (409 `run_in_progress`).
282
+ */
283
+ retryChatStream(id: string, input: AgentChatRetryInput): AsyncGenerator<StreamChunk>;
267
284
  /**
268
285
  * Chat with an agent and get the full response text.
269
286
  *
@@ -287,4 +304,13 @@ export declare class AgentsResource {
287
304
  conversationId: string;
288
305
  chunks: StreamChunk[];
289
306
  }>;
307
+ /** `retryChatStream()` for platforms without `ReadableStream` (React Native). */
308
+ retryChatStreamText(id: string, input: AgentChatRetryInput): Promise<{
309
+ content: string;
310
+ conversationId: string;
311
+ chunks: StreamChunk[];
312
+ }>;
313
+ private openStream;
314
+ private streamChunks;
315
+ private streamText;
290
316
  }
@@ -92,7 +92,14 @@ export class AgentsResource {
92
92
  conversationMessages(id, conversationId, params) {
93
93
  return this.client.get(`/agents/${id}/conversations/${conversationId}/messages`, params);
94
94
  }
95
- /** List discoverable agents (public + same-org) */
95
+ /**
96
+ * List the agents a member may discover: an app key gets the agents of its
97
+ * project and owning org, a personal key gets global and same-org agents.
98
+ * Only member-facing agents are returned. Agents that run a tenant (a
99
+ * project's Lead, swarm workers, dispatcher role agents, MCP client agents,
100
+ * the workspace AI, anyone's personal AI) never appear here; list your own
101
+ * with `list()`.
102
+ */
96
103
  listDiscoverable(params) {
97
104
  return this.client.get('/agents/discoverable', params);
98
105
  }
@@ -181,26 +188,19 @@ export class AgentsResource {
181
188
  * For React Native, use `chatStreamText()` instead.
182
189
  */
183
190
  async *chatStream(id, input) {
184
- const response = await this.client.fetchRaw(`/agents/${id}/chat/stream`, {
185
- method: 'POST',
186
- headers: { 'Content-Type': 'application/json' },
187
- body: JSON.stringify(input),
188
- });
189
- if (!response.ok) {
190
- let errorBody;
191
- try {
192
- errorBody = await response.json();
193
- }
194
- catch {
195
- throw new RecursivError(response.status, {
196
- type: 'api_error',
197
- message: `HTTP ${response.status}: ${response.statusText}`,
198
- code: 'stream_error',
199
- });
200
- }
201
- throw new RecursivError(response.status, errorBody.error);
202
- }
203
- yield* parseSSE(response.body);
191
+ yield* this.streamChunks(id, input);
192
+ }
193
+ /**
194
+ * Retry a turn: stream a fresh reply to one of your existing messages
195
+ * without sending it again. Use it for "Try again" after a reply failed.
196
+ * The agent's earlier reply to that message, if any, is removed first.
197
+ *
198
+ * The message must be yours and the latest one in the conversation
199
+ * (409 `retry_not_latest` otherwise), and a reply still being written must
200
+ * finish or be stopped first (409 `run_in_progress`).
201
+ */
202
+ async *retryChatStream(id, input) {
203
+ yield* this.streamChunks(id, input);
204
204
  }
205
205
  /**
206
206
  * Chat with an agent and get the full response text.
@@ -220,12 +220,19 @@ export class AgentsResource {
220
220
  * });
221
221
  * ```
222
222
  */
223
- async chatStreamText(id, input) {
223
+ chatStreamText(id, input) {
224
+ return this.streamText(id, input);
225
+ }
226
+ /** `retryChatStream()` for platforms without `ReadableStream` (React Native). */
227
+ retryChatStreamText(id, input) {
228
+ return this.streamText(id, input);
229
+ }
230
+ async openStream(id, input, accept) {
224
231
  const response = await this.client.fetchRaw(`/agents/${id}/chat/stream`, {
225
232
  method: 'POST',
226
233
  headers: {
227
234
  'Content-Type': 'application/json',
228
- Accept: 'text/event-stream',
235
+ ...(accept ? { Accept: accept } : {}),
229
236
  },
230
237
  body: JSON.stringify(input),
231
238
  });
@@ -243,6 +250,14 @@ export class AgentsResource {
243
250
  }
244
251
  throw new RecursivError(response.status, errorBody.error);
245
252
  }
253
+ return response;
254
+ }
255
+ async *streamChunks(id, input) {
256
+ const response = await this.openStream(id, input);
257
+ yield* parseSSE(response.body);
258
+ }
259
+ async streamText(id, input) {
260
+ const response = await this.openStream(id, input, 'text/event-stream');
246
261
  const text = await response.text();
247
262
  const lines = text.split('\n');
248
263
  let fullText = '';