@vertesia/client 1.5.0-dev.20260725.083715Z → 1.5.0-dev.20260806.125520Z

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 (154) hide show
  1. package/lib/AccountApi.d.ts +10 -7
  2. package/lib/AccountApi.d.ts.map +1 -1
  3. package/lib/AccountApi.js +7 -4
  4. package/lib/AccountApi.js.map +1 -1
  5. package/lib/AccountsApi.d.ts.map +1 -1
  6. package/lib/AnalyticsApi.d.ts.map +1 -1
  7. package/lib/ApiKeysApi.d.ts +3 -3
  8. package/lib/ApiKeysApi.d.ts.map +1 -1
  9. package/lib/ApiKeysApi.js.map +1 -1
  10. package/lib/AppsApi.d.ts +12 -2
  11. package/lib/AppsApi.d.ts.map +1 -1
  12. package/lib/AppsApi.js +20 -1
  13. package/lib/AppsApi.js.map +1 -1
  14. package/lib/AuditTrailApi.d.ts.map +1 -1
  15. package/lib/CommandsApi.d.ts.map +1 -1
  16. package/lib/EnvironmentsApi.d.ts +3 -6
  17. package/lib/EnvironmentsApi.d.ts.map +1 -1
  18. package/lib/EnvironmentsApi.js.map +1 -1
  19. package/lib/GroupsApi.d.ts +2 -0
  20. package/lib/GroupsApi.d.ts.map +1 -1
  21. package/lib/GroupsApi.js.map +1 -1
  22. package/lib/IamApi.d.ts.map +1 -1
  23. package/lib/InteractionBase.d.ts +686 -2
  24. package/lib/InteractionBase.d.ts.map +1 -1
  25. package/lib/InteractionBase.js.map +1 -1
  26. package/lib/InteractionCatalogApi.d.ts.map +1 -1
  27. package/lib/InteractionCatalogApi.js.map +1 -1
  28. package/lib/InteractionOutput.d.ts.map +1 -1
  29. package/lib/InteractionOutput.js.map +1 -1
  30. package/lib/InteractionsApi.d.ts.map +1 -1
  31. package/lib/InteractionsApi.js.map +1 -1
  32. package/lib/InternalSecretsApi.d.ts.map +1 -1
  33. package/lib/OAuthClientsApi.d.ts.map +1 -1
  34. package/lib/OAuthGrantsApi.d.ts +2 -4
  35. package/lib/OAuthGrantsApi.d.ts.map +1 -1
  36. package/lib/OAuthGrantsApi.js.map +1 -1
  37. package/lib/OAuthProvidersApi.d.ts +6 -1
  38. package/lib/OAuthProvidersApi.d.ts.map +1 -1
  39. package/lib/OAuthProvidersApi.js +5 -0
  40. package/lib/OAuthProvidersApi.js.map +1 -1
  41. package/lib/OAuthServerApi.d.ts.map +1 -1
  42. package/lib/OAuthServerApi.js.map +1 -1
  43. package/lib/ProjectsApi.d.ts.map +1 -1
  44. package/lib/PromptsApi.d.ts +19 -30
  45. package/lib/PromptsApi.d.ts.map +1 -1
  46. package/lib/PromptsApi.js +1 -1
  47. package/lib/PromptsApi.js.map +1 -1
  48. package/lib/QuotaApi.d.ts.map +1 -1
  49. package/lib/RefsApi.d.ts.map +1 -1
  50. package/lib/RemoteMcpConnectionsApi.d.ts.map +1 -1
  51. package/lib/RunsApi.d.ts.map +1 -1
  52. package/lib/SecretsApi.d.ts.map +1 -1
  53. package/lib/SkillsApi.d.ts.map +1 -1
  54. package/lib/StreamSource.d.ts.map +1 -1
  55. package/lib/StreamSource.js.map +1 -1
  56. package/lib/ToolsApi.d.ts.map +1 -1
  57. package/lib/TrainingApi.d.ts.map +1 -1
  58. package/lib/TrainingApi.js.map +1 -1
  59. package/lib/UsersApi.d.ts.map +1 -1
  60. package/lib/ViewsApi.d.ts.map +1 -1
  61. package/lib/ViewsApi.js.map +1 -1
  62. package/lib/client.d.ts +10 -2
  63. package/lib/client.d.ts.map +1 -1
  64. package/lib/client.js +1 -0
  65. package/lib/client.js.map +1 -1
  66. package/lib/execute.js.map +1 -1
  67. package/lib/managed-sse.js +1 -1
  68. package/lib/managed-sse.js.map +1 -1
  69. package/lib/nodejs/NodeStreamSource.d.ts.map +1 -1
  70. package/lib/store/AgentsApi.d.ts.map +1 -1
  71. package/lib/store/AgentsApi.js +121 -77
  72. package/lib/store/AgentsApi.js.map +1 -1
  73. package/lib/store/AnalyzeDocApi.d.ts.map +1 -1
  74. package/lib/store/AnalyzeDocApi.js.map +1 -1
  75. package/lib/store/CollectionsApi.d.ts.map +1 -1
  76. package/lib/store/CollectionsApi.js.map +1 -1
  77. package/lib/store/CostApi.d.ts.map +1 -1
  78. package/lib/store/CostApi.js.map +1 -1
  79. package/lib/store/DashboardApi.d.ts.map +1 -1
  80. package/lib/store/DataApi.d.ts.map +1 -1
  81. package/lib/store/EmailApi.d.ts.map +1 -1
  82. package/lib/store/EmbeddingsApi.d.ts.map +1 -1
  83. package/lib/store/EmbeddingsApi.js.map +1 -1
  84. package/lib/store/EventIngestChannelsApi.d.ts.map +1 -1
  85. package/lib/store/EventSubscriptionsApi.d.ts.map +1 -1
  86. package/lib/store/EventsApi.d.ts +2 -1
  87. package/lib/store/EventsApi.d.ts.map +1 -1
  88. package/lib/store/EventsApi.js +3 -0
  89. package/lib/store/EventsApi.js.map +1 -1
  90. package/lib/store/FilesApi.d.ts +4 -0
  91. package/lib/store/FilesApi.d.ts.map +1 -1
  92. package/lib/store/FilesApi.js +48 -23
  93. package/lib/store/FilesApi.js.map +1 -1
  94. package/lib/store/HiveMemoryApi.d.ts.map +1 -1
  95. package/lib/store/IndexingApi.d.ts.map +1 -1
  96. package/lib/store/IndexingApi.js.map +1 -1
  97. package/lib/store/ObjectsApi.d.ts +3 -8
  98. package/lib/store/ObjectsApi.d.ts.map +1 -1
  99. package/lib/store/ObjectsApi.js +2 -2
  100. package/lib/store/ObjectsApi.js.map +1 -1
  101. package/lib/store/PendingAsksApi.d.ts.map +1 -1
  102. package/lib/store/ProcessApi.d.ts.map +1 -1
  103. package/lib/store/QueryApi.d.ts.map +1 -1
  104. package/lib/store/QueryApi.js.map +1 -1
  105. package/lib/store/RenderingApi.d.ts.map +1 -1
  106. package/lib/store/RenderingApi.js.map +1 -1
  107. package/lib/store/SchedulesApi.d.ts.map +1 -1
  108. package/lib/store/TaskApi.d.ts +9 -0
  109. package/lib/store/TaskApi.d.ts.map +1 -1
  110. package/lib/store/TaskApi.js +11 -16
  111. package/lib/store/TaskApi.js.map +1 -1
  112. package/lib/store/TypeCatalogApi.d.ts +21 -7
  113. package/lib/store/TypeCatalogApi.d.ts.map +1 -1
  114. package/lib/store/TypeCatalogApi.js +16 -2
  115. package/lib/store/TypeCatalogApi.js.map +1 -1
  116. package/lib/store/TypesApi.d.ts +2 -2
  117. package/lib/store/TypesApi.d.ts.map +1 -1
  118. package/lib/store/TypesApi.js.map +1 -1
  119. package/lib/store/ViewsApi.d.ts.map +1 -1
  120. package/lib/store/ViewsApi.js.map +1 -1
  121. package/lib/store/WorkflowsApi.d.ts.map +1 -1
  122. package/lib/store/WorkflowsApi.js.map +1 -1
  123. package/lib/store/client.d.ts.map +1 -1
  124. package/lib/store/client.js.map +1 -1
  125. package/lib/store/errors.d.ts.map +1 -1
  126. package/lib/store/signed-url.js.map +1 -1
  127. package/lib/store/version.d.ts +1 -3
  128. package/lib/store/version.d.ts.map +1 -1
  129. package/lib/store/version.js +3 -3
  130. package/lib/store/version.js.map +1 -1
  131. package/lib/vertesia-client.js +10 -10
  132. package/lib/vertesia-client.js.map +1 -1
  133. package/package.json +6 -6
  134. package/src/AccountApi.test.ts +30 -0
  135. package/src/AccountApi.ts +10 -8
  136. package/src/ApiKeysApi.ts +4 -3
  137. package/src/AppsApi.ts +26 -0
  138. package/src/EnvironmentsApi.ts +3 -11
  139. package/src/GroupsApi.ts +2 -0
  140. package/src/OAuthGrantsApi.ts +2 -1
  141. package/src/OAuthProvidersApi.ts +6 -1
  142. package/src/PromptsApi.ts +22 -25
  143. package/src/client.test.ts +16 -0
  144. package/src/client.ts +1 -0
  145. package/src/store/AgentsApi.stream.test.ts +298 -0
  146. package/src/store/AgentsApi.ts +120 -68
  147. package/src/store/EventsApi.ts +8 -0
  148. package/src/store/FilesApi.test.ts +37 -1
  149. package/src/store/FilesApi.ts +49 -20
  150. package/src/store/ObjectsApi.ts +8 -8
  151. package/src/store/TaskApi.ts +11 -11
  152. package/src/store/TypeCatalogApi.ts +21 -7
  153. package/src/store/TypesApi.ts +2 -1
  154. package/src/store/version.ts +7 -3
@@ -0,0 +1,298 @@
1
+ import { type AgentMessage, AgentMessageType, type CompactMessage } from '@vertesia/common';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { VertesiaClient } from '../client.js';
4
+
5
+ const AGENT_RUN_ID = 'run-1';
6
+ const STORE_URL = 'https://store.example.test';
7
+
8
+ class FakeEventSource {
9
+ static readonly CONNECTING = 0;
10
+ static readonly OPEN = 1;
11
+ static readonly CLOSED = 2;
12
+
13
+ static instances: FakeEventSource[] = [];
14
+ static urls: string[] = [];
15
+
16
+ readonly url: string;
17
+ readonly withCredentials = false;
18
+ readyState = FakeEventSource.CONNECTING;
19
+ onopen: ((event: Event) => void) | null = null;
20
+ onmessage: ((event: MessageEvent) => void) | null = null;
21
+ onerror: ((event: Event) => void) | null = null;
22
+
23
+ constructor(url: string) {
24
+ this.url = url;
25
+ FakeEventSource.urls.push(url);
26
+ FakeEventSource.instances.push(this);
27
+ }
28
+
29
+ close() {
30
+ this.readyState = FakeEventSource.CLOSED;
31
+ }
32
+
33
+ addEventListener() {}
34
+ removeEventListener() {}
35
+ dispatchEvent() {
36
+ return true;
37
+ }
38
+
39
+ fail() {
40
+ this.onerror?.(new Event('error'));
41
+ }
42
+
43
+ emit(message: CompactMessage) {
44
+ this.onmessage?.({ data: JSON.stringify(message) } as MessageEvent);
45
+ }
46
+ }
47
+
48
+ interface Harness {
49
+ client: VertesiaClient;
50
+ /** `since` query value of every GET /updates call after the initial history fetch. */
51
+ pollSince: number[];
52
+ setHistory: (messages: CompactMessage[]) => void;
53
+ setPollResponse: (handler: (since: number) => CompactMessage[]) => void;
54
+ setRunStatus: (status: string) => void;
55
+ issuedTokens: string[];
56
+ }
57
+
58
+ function createHarness(): Harness {
59
+ let history: CompactMessage[] = [];
60
+ let pollResponse: (since: number) => CompactMessage[] = () => [];
61
+ let runStatus = 'running';
62
+ let historyServed = false;
63
+ const pollSince: number[] = [];
64
+ const issuedTokens: string[] = [];
65
+
66
+ const fetchMock = vi.fn(async (input: Request | string) => {
67
+ const url = new URL(typeof input === 'string' ? input : input.url);
68
+ if (url.pathname.endsWith('/updates')) {
69
+ if (!historyServed) {
70
+ historyServed = true;
71
+ return Response.json({ messages: history });
72
+ }
73
+ const since = Number(url.searchParams.get('since') ?? 0);
74
+ pollSince.push(since);
75
+ return Response.json({ messages: pollResponse(since) });
76
+ }
77
+ if (url.pathname.endsWith(`/${AGENT_RUN_ID}`)) {
78
+ return Response.json({ id: AGENT_RUN_ID, status: runStatus });
79
+ }
80
+ return Response.json({ error: 'not found' }, { status: 404 });
81
+ });
82
+
83
+ const client = new VertesiaClient({
84
+ serverUrl: 'https://studio.example.test',
85
+ storeUrl: STORE_URL,
86
+ fetch: fetchMock as unknown as typeof fetch,
87
+ });
88
+
89
+ // Mimics a refreshing credential provider: every call hands out a newer token.
90
+ client.withAuthCallback(async () => {
91
+ const token = `token-${issuedTokens.length + 1}`;
92
+ issuedTokens.push(token);
93
+ return `Bearer ${token}`;
94
+ });
95
+
96
+ return {
97
+ client,
98
+ pollSince,
99
+ issuedTokens,
100
+ setHistory: (messages) => {
101
+ history = messages;
102
+ },
103
+ setPollResponse: (handler) => {
104
+ pollResponse = handler;
105
+ },
106
+ setRunStatus: (status) => {
107
+ runStatus = status;
108
+ },
109
+ };
110
+ }
111
+
112
+ /** Advance fake timers and drain the microtask queue so awaited fetches settle. */
113
+ async function settle(ms = 0) {
114
+ await vi.advanceTimersByTimeAsync(ms);
115
+ for (let i = 0; i < 20; i++) {
116
+ await Promise.resolve();
117
+ }
118
+ }
119
+
120
+ /** Longer than the capped backoff delay (30s + jitter). */
121
+ const BACKOFF_CEILING_MS = 40000;
122
+
123
+ async function failCurrentConnection() {
124
+ FakeEventSource.instances[FakeEventSource.instances.length - 1].fail();
125
+ await settle(BACKOFF_CEILING_MS);
126
+ }
127
+
128
+ describe('AgentsApi.streamMessages reconnection', () => {
129
+ let logs: string[];
130
+ let warnings: string[];
131
+
132
+ beforeEach(() => {
133
+ vi.useFakeTimers();
134
+ FakeEventSource.instances = [];
135
+ FakeEventSource.urls = [];
136
+ logs = [];
137
+ warnings = [];
138
+ vi.stubGlobal('EventSource', FakeEventSource);
139
+ vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
140
+ logs.push(args.map(String).join(' '));
141
+ });
142
+ vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => {
143
+ warnings.push(args.map(String).join(' '));
144
+ });
145
+ vi.spyOn(console, 'error').mockImplementation(() => {});
146
+ });
147
+
148
+ afterEach(() => {
149
+ vi.unstubAllGlobals();
150
+ vi.restoreAllMocks();
151
+ vi.useRealTimers();
152
+ });
153
+
154
+ it('resolves a fresh auth token for every reconnection attempt', async () => {
155
+ const harness = createHarness();
156
+ const abort = new AbortController();
157
+ void harness.client.agents.streamMessages(AGENT_RUN_ID, undefined, undefined, abort.signal);
158
+ await settle();
159
+
160
+ expect(FakeEventSource.instances).toHaveLength(1);
161
+ await failCurrentConnection();
162
+ await failCurrentConnection();
163
+ expect(FakeEventSource.instances).toHaveLength(3);
164
+
165
+ const tokens = FakeEventSource.urls.map((url) => new URL(url).searchParams.get('access_token'));
166
+ expect(tokens).toHaveLength(3);
167
+ expect(new Set(tokens).size).toBe(3);
168
+ expect(tokens.every((token) => token?.startsWith('token-'))).toBe(true);
169
+
170
+ abort.abort();
171
+ await settle();
172
+ });
173
+
174
+ it('never announces an attempt number above the stated maximum', async () => {
175
+ const harness = createHarness();
176
+ const abort = new AbortController();
177
+ void harness.client.agents.streamMessages(AGENT_RUN_ID, undefined, undefined, abort.signal);
178
+ await settle();
179
+
180
+ // 10 reconnects are allowed; the 11th failure must degrade instead of announcing 11/10.
181
+ for (let i = 0; i < 11; i++) {
182
+ await failCurrentConnection();
183
+ }
184
+
185
+ const announced = logs
186
+ .map((line) => /\(attempt (\d+)\/(\d+)\)/.exec(line))
187
+ .filter((match): match is RegExpExecArray => match !== null)
188
+ .map((match) => ({ attempt: Number(match[1]), max: Number(match[2]) }));
189
+
190
+ expect(announced).toHaveLength(10);
191
+ expect(announced.map((entry) => entry.attempt)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
192
+ expect(announced.every((entry) => entry.attempt <= entry.max)).toBe(true);
193
+ expect(FakeEventSource.instances).toHaveLength(11);
194
+ expect(warnings.some((line) => line.includes('SSE unavailable after 10 attempts'))).toBe(true);
195
+
196
+ abort.abort();
197
+ await settle();
198
+ });
199
+
200
+ it('polls GET /updates from the last received timestamp and emits the messages', async () => {
201
+ const harness = createHarness();
202
+ harness.setHistory([{ t: AgentMessageType.UPDATE, m: 'history', ts: 1000 }]);
203
+ harness.setPollResponse((since) =>
204
+ since < 2000 ? [{ t: AgentMessageType.UPDATE, m: 'polled', ts: 2000 }] : [],
205
+ );
206
+
207
+ const received: AgentMessage[] = [];
208
+ const abort = new AbortController();
209
+ void harness.client.agents.streamMessages(
210
+ AGENT_RUN_ID,
211
+ (message) => received.push(message),
212
+ undefined,
213
+ abort.signal,
214
+ );
215
+ await settle();
216
+
217
+ for (let i = 0; i < 11; i++) {
218
+ await failCurrentConnection();
219
+ }
220
+ await settle(6000);
221
+
222
+ // The first poll resumes at the history cursor, later polls at the last polled message.
223
+ expect(harness.pollSince[0]).toBe(1000);
224
+ expect(harness.pollSince[1]).toBe(2000);
225
+ expect(received.map((message) => message.message)).toEqual(['history', 'polled']);
226
+ // Polled messages reach the caller as fully-formed AgentMessages, like the SSE path.
227
+ expect(received[1]).toMatchObject({
228
+ type: AgentMessageType.UPDATE,
229
+ workflow_run_id: AGENT_RUN_ID,
230
+ workstream_id: 'main',
231
+ timestamp: 2000,
232
+ });
233
+
234
+ abort.abort();
235
+ await settle();
236
+ });
237
+
238
+ it('warns on every polling failure instead of failing silently', async () => {
239
+ const harness = createHarness();
240
+ harness.setHistory([{ t: AgentMessageType.UPDATE, m: 'history', ts: 1000 }]);
241
+ harness.setPollResponse(() => {
242
+ throw new Error('boom');
243
+ });
244
+
245
+ const abort = new AbortController();
246
+ void harness.client.agents.streamMessages(AGENT_RUN_ID, undefined, undefined, abort.signal);
247
+ await settle();
248
+
249
+ for (let i = 0; i < 11; i++) {
250
+ await failCurrentConnection();
251
+ }
252
+ await settle(6000);
253
+
254
+ const pollWarnings = warnings.filter((line) => line.includes('GET /updates failed while polling'));
255
+ expect(pollWarnings.length).toBeGreaterThanOrEqual(2);
256
+ expect(pollWarnings[0]).toContain('consecutive failures: 1');
257
+ expect(pollWarnings[1]).toContain('consecutive failures: 2');
258
+
259
+ abort.abort();
260
+ await settle();
261
+ });
262
+
263
+ it('retries SSE after the polling fallback so an outage is not permanent', async () => {
264
+ const harness = createHarness();
265
+ const abort = new AbortController();
266
+ void harness.client.agents.streamMessages(AGENT_RUN_ID, undefined, undefined, abort.signal);
267
+ await settle();
268
+
269
+ for (let i = 0; i < 11; i++) {
270
+ await failCurrentConnection();
271
+ }
272
+ const connectionsBeforeRetry = FakeEventSource.instances.length;
273
+ expect(warnings.some((line) => line.includes('SSE retried every 60s'))).toBe(true);
274
+
275
+ await settle(61000);
276
+
277
+ expect(FakeEventSource.instances.length).toBe(connectionsBeforeRetry + 1);
278
+ expect(logs.some((line) => line.includes('retrying the SSE connection'))).toBe(true);
279
+
280
+ abort.abort();
281
+ await settle();
282
+ });
283
+
284
+ it('closes the stream when polling observes a terminal run status', async () => {
285
+ const harness = createHarness();
286
+ const abort = new AbortController();
287
+ const done = harness.client.agents.streamMessages(AGENT_RUN_ID, undefined, undefined, abort.signal);
288
+ await settle();
289
+
290
+ for (let i = 0; i < 11; i++) {
291
+ await failCurrentConnection();
292
+ }
293
+ harness.setRunStatus('completed');
294
+ await settle(6000);
295
+
296
+ await expect(done).resolves.toBeNull();
297
+ });
298
+ });
@@ -143,8 +143,8 @@ export class AgentsApi extends ApiTopic {
143
143
  }
144
144
  if (query?.interaction) params.interaction = query.interaction;
145
145
  if (query?.started_by) params.started_by = query.started_by;
146
- if (query?.since) params.since = query.since.toISOString();
147
- if (query?.until) params.until = query.until.toISOString();
146
+ if (query?.since) params.since = query.since;
147
+ if (query?.until) params.until = query.until;
148
148
  if (query?.schedule_id) params.schedule_id = query.schedule_id;
149
149
  if (query?.type) params.type = query.type;
150
150
  if (query?.run_type)
@@ -172,8 +172,8 @@ export class AgentsApi extends ApiTopic {
172
172
  if (query?.categories?.length) params.categories = query.categories.join(',');
173
173
  if (query?.tags?.length) params.tags = query.tags.join(',');
174
174
  if (query?.content_type_name) params.content_type_name = query.content_type_name;
175
- if (query?.since) params.since = query.since.toISOString();
176
- if (query?.until) params.until = query.until.toISOString();
175
+ if (query?.since) params.since = query.since;
176
+ if (query?.until) params.until = query.until;
177
177
  if (query?.limit) params.limit = String(query.limit);
178
178
  if (query?.offset) params.offset = String(query.offset);
179
179
  if (query?.sort?.length) params.sort = query.sort.join(',');
@@ -326,6 +326,10 @@ export class AgentsApi extends ApiTopic {
326
326
  let currentSse: EventSource | null = null;
327
327
  let interval: ReturnType<typeof setInterval> | null = null;
328
328
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
329
+ let pollTimer: ReturnType<typeof setTimeout> | null = null;
330
+ let sseRetryTimer: ReturnType<typeof setTimeout> | null = null;
331
+ let isPolling = false;
332
+ let consecutivePollFailures = 0;
329
333
  let abortHandler: (() => void) | null = null;
330
334
 
331
335
  const maxReconnectAttempts = 10;
@@ -338,11 +342,24 @@ export class AgentsApi extends ApiTopic {
338
342
  return exponentialDelay + jitter;
339
343
  };
340
344
 
345
+ const stopPolling = () => {
346
+ isPolling = false;
347
+ if (pollTimer) {
348
+ clearTimeout(pollTimer);
349
+ pollTimer = null;
350
+ }
351
+ if (sseRetryTimer) {
352
+ clearTimeout(sseRetryTimer);
353
+ sseRetryTimer = null;
354
+ }
355
+ };
356
+
341
357
  const cleanup = () => {
342
358
  if (reconnectTimer) {
343
359
  clearTimeout(reconnectTimer);
344
360
  reconnectTimer = null;
345
361
  }
362
+ stopPolling();
346
363
  if (interval) {
347
364
  clearInterval(interval);
348
365
  interval = null;
@@ -372,52 +389,101 @@ export class AgentsApi extends ApiTopic {
372
389
  // for any messages we missed and exit cleanly once the run is terminal,
373
390
  // even if the terminal message never streamed.
374
391
  const POLL_INTERVAL_MS = 5000;
392
+ // Polling is a degraded mode: retry the real-time channel periodically so a transient
393
+ // outage (proxy restart, expired credentials that the auth callback later refreshes)
394
+ // does not permanently downgrade the stream.
395
+ const SSE_RETRY_INTERVAL_MS = 60000;
375
396
  const isTerminalStatus = (status: unknown): boolean =>
376
397
  typeof status === 'string' && ['completed', 'failed', 'cancelled'].includes(status);
377
- const degradeToPolling = (reason: unknown) => {
378
- if (isClosed) return;
379
- cleanup();
398
+
399
+ // Never swallow a poll failure: while polling, it is the only channel left, so a silent
400
+ // catch turns a recoverable error (expired token, network blip) into an endless silence.
401
+ const warnPollFailure = (what: string, error: unknown) => {
402
+ consecutivePollFailures++;
380
403
  console.warn(
381
- `Agent stream ${id}: SSE unavailable after ${maxReconnectAttempts} attempts; falling back to /updates polling.`,
382
- reason instanceof Error ? reason.message : reason,
404
+ `Agent stream ${id}: ${what} failed while polling (consecutive failures: ${consecutivePollFailures}); retrying in ${Math.round(POLL_INTERVAL_MS / 1000)}s:`,
405
+ error instanceof Error ? error.message : error,
383
406
  );
384
- const tick = async () => {
385
- if (isClosed) return;
386
- try {
387
- const recent = await this.retrieveMessages(id, lastMessageTimestamp || undefined);
388
- for (const msg of recent) {
389
- if (isClosed) return;
390
- if ((msg.timestamp || 0) <= lastMessageTimestamp) continue;
391
- lastMessageTimestamp = msg.timestamp || lastMessageTimestamp;
392
- if (onMessage) onMessage(msg, exit);
393
- if (isClosed) return;
394
- if (shouldCloseAgentRunStream(msg, id)) {
395
- exit(null);
396
- return;
397
- }
398
- }
399
- } catch {
400
- // transient poll error — retry next tick
401
- }
402
- if (isClosed) return;
403
- // Exit even when no terminal message ever streams, by reading the run record.
404
- try {
405
- const run = (await this.get(`/${id}`)) as { status?: unknown };
406
- if (isTerminalStatus(run?.status)) {
407
+ };
408
+
409
+ const pollTick = async () => {
410
+ if (isClosed || !isPolling) return;
411
+ let polledMessages = false;
412
+ try {
413
+ // Resume from the last message we handed to the caller, whether it came from
414
+ // history, SSE, or an earlier poll. The server returns messages with ts > since.
415
+ const recent = await this.retrieveMessages(id, lastMessageTimestamp || undefined);
416
+ polledMessages = true;
417
+ for (const msg of recent) {
418
+ if (isClosed) return;
419
+ const timestamp = msg.timestamp || 0;
420
+ if (timestamp <= lastMessageTimestamp) continue;
421
+ lastMessageTimestamp = timestamp;
422
+ if (onMessage) onMessage(msg, exit);
423
+ if (isClosed) return;
424
+ if (shouldCloseAgentRunStream(msg, id)) {
407
425
  exit(null);
408
426
  return;
409
427
  }
410
- } catch {
411
- // ignore — retry next tick
412
428
  }
413
- if (!isClosed) {
414
- reconnectTimer = setTimeout(() => {
415
- reconnectTimer = null;
416
- void tick();
417
- }, POLL_INTERVAL_MS);
429
+ } catch (err) {
430
+ warnPollFailure('GET /updates', err);
431
+ }
432
+ if (isClosed || !isPolling) return;
433
+ // Exit even when no terminal message ever streams, by reading the run record.
434
+ try {
435
+ const run = (await this.get(`/${id}`)) as { status?: unknown };
436
+ if (isTerminalStatus(run?.status)) {
437
+ exit(null);
438
+ return;
418
439
  }
419
- };
420
- void tick();
440
+ if (polledMessages) consecutivePollFailures = 0;
441
+ } catch (err) {
442
+ warnPollFailure('run status check', err);
443
+ }
444
+ if (isClosed || !isPolling) return;
445
+ pollTimer = setTimeout(() => {
446
+ pollTimer = null;
447
+ void pollTick();
448
+ }, POLL_INTERVAL_MS);
449
+ };
450
+
451
+ const retrySseAfterPolling = () => {
452
+ sseRetryTimer = null;
453
+ if (isClosed || !isPolling) return;
454
+ stopPolling();
455
+ reconnectAttempts = 0;
456
+ console.log(`Agent stream ${id}: retrying the SSE connection after the polling fallback.`);
457
+ void setupStream('resume');
458
+ };
459
+
460
+ const degradeToPolling = (reason: unknown) => {
461
+ if (isClosed || isPolling) return;
462
+ cleanup();
463
+ isPolling = true;
464
+ consecutivePollFailures = 0;
465
+ console.warn(
466
+ `Agent stream ${id}: SSE unavailable after ${maxReconnectAttempts} attempts; falling back to GET /updates polling every ${Math.round(POLL_INTERVAL_MS / 1000)}s (SSE retried every ${Math.round(SSE_RETRY_INTERVAL_MS / 1000)}s).`,
467
+ reason instanceof Error ? reason.message : reason,
468
+ );
469
+ sseRetryTimer = setTimeout(retrySseAfterPolling, SSE_RETRY_INTERVAL_MS);
470
+ void pollTick();
471
+ };
472
+
473
+ // Schedules attempt N of `maxReconnectAttempts`. `reconnectAttempts` holds the number of
474
+ // the attempt about to run, so the announced counter never exceeds the announced maximum.
475
+ const scheduleReconnect = (reason: unknown) => {
476
+ if (isClosed) return;
477
+ if (reconnectAttempts >= maxReconnectAttempts) {
478
+ degradeToPolling(reason);
479
+ return;
480
+ }
481
+ const delay = calculateBackoffDelay(reconnectAttempts);
482
+ reconnectAttempts++;
483
+ reconnectTimer = setTimeout(() => {
484
+ reconnectTimer = null;
485
+ if (!isClosed) void setupStream('reconnect');
486
+ }, delay);
421
487
  };
422
488
 
423
489
  if (signal) {
@@ -468,7 +534,7 @@ export class AgentsApi extends ApiTopic {
468
534
  }
469
535
 
470
536
  // 2. Connect to SSE for real-time updates
471
- const setupStream = async (isReconnect: boolean = false) => {
537
+ const setupStream = async (mode: 'initial' | 'reconnect' | 'resume' = 'initial') => {
472
538
  if (isClosed) return;
473
539
  try {
474
540
  const EventSourceImpl = await EventSourceProvider();
@@ -481,6 +547,10 @@ export class AgentsApi extends ApiTopic {
481
547
  }
482
548
  streamUrl.searchParams.set('skipHistory', 'true');
483
549
 
550
+ // Resolve the credential on every attempt (never reuse the one captured when the
551
+ // stream started): a stream can stay open far longer than the access token TTL,
552
+ // so a pinned token makes every reconnect fail with 401/403 until the retry
553
+ // budget is exhausted. The auth callback owns refreshing.
484
554
  const bearerToken = client._auth ? await client._auth() : undefined;
485
555
  if (isClosed) return;
486
556
  if (!bearerToken) {
@@ -493,9 +563,9 @@ export class AgentsApi extends ApiTopic {
493
563
  const token = bearerToken.split(' ')[1];
494
564
  streamUrl.searchParams.set('access_token', token);
495
565
 
496
- if (isReconnect) {
566
+ if (mode === 'reconnect') {
497
567
  console.log(
498
- `Reconnecting to agent stream ${id} (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`,
568
+ `Reconnecting to agent stream ${id} (attempt ${reconnectAttempts}/${maxReconnectAttempts})`,
499
569
  );
500
570
  }
501
571
 
@@ -507,7 +577,7 @@ export class AgentsApi extends ApiTopic {
507
577
  let connectionOpenedAt = 0;
508
578
 
509
579
  sse.onopen = () => {
510
- if (isReconnect) console.log(`Reconnected to agent stream ${id}`);
580
+ if (mode !== 'initial') console.log(`Reconnected to agent stream ${id}`);
511
581
  connectionOpenedAt = Date.now();
512
582
  };
513
583
 
@@ -549,35 +619,17 @@ export class AgentsApi extends ApiTopic {
549
619
  reconnectAttempts = 0;
550
620
  }
551
621
 
552
- if (reconnectAttempts < maxReconnectAttempts) {
553
- const delay = calculateBackoffDelay(reconnectAttempts);
554
- reconnectAttempts++;
555
- reconnectTimer = setTimeout(() => {
556
- reconnectTimer = null;
557
- if (!isClosed) void setupStream(true);
558
- }, delay);
559
- } else {
560
- degradeToPolling(
561
- new Error(`SSE connection failed after ${maxReconnectAttempts} reconnection attempts`),
562
- );
563
- }
622
+ scheduleReconnect(
623
+ new Error(`SSE connection failed after ${maxReconnectAttempts} reconnection attempts`),
624
+ );
564
625
  };
565
626
  } catch (err) {
566
627
  if (isClosed) return;
567
- if (reconnectAttempts < maxReconnectAttempts) {
568
- const delay = calculateBackoffDelay(reconnectAttempts);
569
- reconnectAttempts++;
570
- reconnectTimer = setTimeout(() => {
571
- reconnectTimer = null;
572
- if (!isClosed) void setupStream(true);
573
- }, delay);
574
- } else {
575
- degradeToPolling(err);
576
- }
628
+ scheduleReconnect(err);
577
629
  }
578
630
  };
579
631
 
580
- void setupStream(false);
632
+ void setupStream('initial');
581
633
  return promise;
582
634
  }
583
635
 
@@ -1,5 +1,7 @@
1
1
  import { ApiTopic, type ClientBase } from '@vertesia/api-fetch-client';
2
2
  import type {
3
+ CancelEventDeliveryIntentsPayload,
4
+ CancelEventDeliveryIntentsResponse,
3
5
  EventDeliveryQueueSummaryPayload,
4
6
  EventDeliveryQueueSummaryResponse,
5
7
  EventDeliveryStreamEnvelope,
@@ -46,6 +48,12 @@ export class EventsApi extends ApiTopic {
46
48
  return this.post('/deliveries/queue-summary', { payload });
47
49
  }
48
50
 
51
+ cancelQueuedDeliveries(
52
+ payload: CancelEventDeliveryIntentsPayload = {},
53
+ ): Promise<CancelEventDeliveryIntentsResponse> {
54
+ return this.post('/deliveries/cancel', { payload });
55
+ }
56
+
49
57
  subscribeDeliveries(options: SubscribeEventDeliveriesOptions): ManagedEventSourceConnection {
50
58
  const { signal, max_reconnect_attempts, on_snapshot, on_delivery, on_envelope, on_error, on_status, ...query } =
51
59
  options;
@@ -1,7 +1,12 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import { ZenoClient } from './client.js';
3
3
 
4
4
  describe('FilesApi', () => {
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ vi.unstubAllGlobals();
8
+ });
9
+
5
10
  it('retries signed upload URL creation after a transient connection failure', async () => {
6
11
  let attempts = 0;
7
12
  const fetchSignedUploadUrl = async (): Promise<Response> => {
@@ -66,4 +71,35 @@ describe('FilesApi', () => {
66
71
  expect(result).toEqual({ id: 'file-1', path: 'agents/run/conversation.json', url: 'https://signed' });
67
72
  expect(attempts).toBe(2);
68
73
  });
74
+
75
+ it('does not expose a signed URL when a file download returns 404', async () => {
76
+ const signedUrl =
77
+ 'https://storage.googleapis.com/test-bucket/agents/run/files/missing.png?X-Goog-Credential=secret&X-Goog-Signature=do-not-log';
78
+ const fetchDownloadUrl = (): Promise<Response> =>
79
+ Promise.resolve(
80
+ new Response(JSON.stringify({ url: signedUrl }), {
81
+ status: 200,
82
+ headers: { 'content-type': 'application/json' },
83
+ }),
84
+ );
85
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404, statusText: 'Not Found' })));
86
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
87
+ const client = new ZenoClient({
88
+ serverUrl: 'https://store.test',
89
+ apikey: 'token',
90
+ fetch: fetchDownloadUrl,
91
+ });
92
+
93
+ const error = await client.files.downloadFile('agents/run/files/missing.png').catch((cause: unknown) => cause);
94
+
95
+ expect(error).toMatchObject({
96
+ name: 'FileDownloadError',
97
+ status: 404,
98
+ message: 'File download not found: agents/run/files/missing.png',
99
+ });
100
+ expect(String(error)).not.toContain('X-Goog-Credential');
101
+ expect(String(error)).not.toContain('X-Goog-Signature');
102
+ expect(String(error)).not.toContain('do-not-log');
103
+ expect(consoleError).not.toHaveBeenCalled();
104
+ });
69
105
  });