@vertesia/client 1.5.0-dev.20260722.120446Z → 1.5.0-dev.20260804.124748Z
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/lib/AccountApi.d.ts +10 -7
- package/lib/AccountApi.d.ts.map +1 -1
- package/lib/AccountApi.js +7 -4
- package/lib/AccountApi.js.map +1 -1
- package/lib/ApiKeysApi.d.ts +3 -3
- package/lib/ApiKeysApi.d.ts.map +1 -1
- package/lib/ApiKeysApi.js.map +1 -1
- package/lib/AppsApi.d.ts +7 -1
- package/lib/AppsApi.d.ts.map +1 -1
- package/lib/AppsApi.js +12 -0
- package/lib/AppsApi.js.map +1 -1
- package/lib/EnvironmentsApi.d.ts +3 -6
- package/lib/EnvironmentsApi.d.ts.map +1 -1
- package/lib/EnvironmentsApi.js.map +1 -1
- package/lib/GroupsApi.d.ts +2 -0
- package/lib/GroupsApi.d.ts.map +1 -1
- package/lib/GroupsApi.js.map +1 -1
- package/lib/InteractionBase.d.ts +686 -2
- package/lib/InteractionBase.d.ts.map +1 -1
- package/lib/OAuthGrantsApi.d.ts +2 -4
- package/lib/OAuthGrantsApi.d.ts.map +1 -1
- package/lib/OAuthGrantsApi.js.map +1 -1
- package/lib/OAuthProvidersApi.d.ts +6 -1
- package/lib/OAuthProvidersApi.d.ts.map +1 -1
- package/lib/OAuthProvidersApi.js +5 -0
- package/lib/OAuthProvidersApi.js.map +1 -1
- package/lib/PromptsApi.d.ts +19 -30
- package/lib/PromptsApi.d.ts.map +1 -1
- package/lib/PromptsApi.js +1 -1
- package/lib/PromptsApi.js.map +1 -1
- package/lib/client.d.ts +10 -2
- package/lib/client.d.ts.map +1 -1
- package/lib/store/AgentsApi.d.ts +5 -1
- package/lib/store/AgentsApi.d.ts.map +1 -1
- package/lib/store/AgentsApi.js +129 -77
- package/lib/store/AgentsApi.js.map +1 -1
- package/lib/store/FilesApi.d.ts.map +1 -1
- package/lib/store/FilesApi.js +10 -4
- package/lib/store/FilesApi.js.map +1 -1
- package/lib/store/ObjectsApi.d.ts +3 -8
- package/lib/store/ObjectsApi.d.ts.map +1 -1
- package/lib/store/ObjectsApi.js +5 -4
- package/lib/store/ObjectsApi.js.map +1 -1
- package/lib/store/TaskApi.d.ts +9 -0
- package/lib/store/TaskApi.d.ts.map +1 -1
- package/lib/store/TaskApi.js +11 -16
- package/lib/store/TaskApi.js.map +1 -1
- package/lib/store/TypeCatalogApi.d.ts +21 -7
- package/lib/store/TypeCatalogApi.d.ts.map +1 -1
- package/lib/store/TypeCatalogApi.js +16 -2
- package/lib/store/TypeCatalogApi.js.map +1 -1
- package/lib/store/TypesApi.d.ts +2 -2
- package/lib/store/TypesApi.d.ts.map +1 -1
- package/lib/store/TypesApi.js.map +1 -1
- package/lib/store/uploadMimeType.d.ts +3 -0
- package/lib/store/uploadMimeType.d.ts.map +1 -0
- package/lib/store/uploadMimeType.js +11 -0
- package/lib/store/uploadMimeType.js.map +1 -0
- package/lib/store/version.d.ts +1 -2
- package/lib/store/version.d.ts.map +1 -1
- package/lib/store/version.js +3 -2
- package/lib/store/version.js.map +1 -1
- package/lib/vertesia-client.js +10 -10
- package/lib/vertesia-client.js.map +1 -1
- package/package.json +6 -6
- package/src/AccountApi.test.ts +30 -0
- package/src/AccountApi.ts +10 -8
- package/src/ApiKeysApi.ts +4 -3
- package/src/AppsApi.test.ts +14 -6
- package/src/AppsApi.ts +19 -0
- package/src/EnvironmentsApi.ts +3 -11
- package/src/GroupsApi.ts +2 -0
- package/src/OAuthGrantsApi.ts +2 -1
- package/src/OAuthProvidersApi.ts +6 -1
- package/src/PromptsApi.ts +22 -25
- package/src/store/AgentsApi.stream.test.ts +298 -0
- package/src/store/AgentsApi.ts +137 -68
- package/src/store/FilesApi.ts +10 -4
- package/src/store/ObjectsApi.ts +11 -10
- package/src/store/TaskApi.ts +11 -11
- package/src/store/TypeCatalogApi.ts +21 -7
- package/src/store/TypesApi.ts +2 -1
- package/src/store/uploadMimeType.test.ts +22 -0
- package/src/store/uploadMimeType.ts +16 -0
- package/src/store/version.ts +3 -2
|
@@ -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
|
+
});
|
package/src/store/AgentsApi.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ApiTopic, type ClientBase } from '@vertesia/api-fetch-client';
|
|
2
2
|
import {
|
|
3
3
|
type ActiveWorkstreamsQueryResult,
|
|
4
|
+
type AgentArtifactContentResponse,
|
|
4
5
|
type AgentArtifactUrlResponse,
|
|
5
6
|
type AgentEvent,
|
|
6
7
|
type AgentMessage,
|
|
@@ -41,6 +42,8 @@ import {
|
|
|
41
42
|
type ToolParameterAnalyticsResponse,
|
|
42
43
|
type TopPrincipalsAnalyticsResponse,
|
|
43
44
|
toAgentMessage,
|
|
45
|
+
type UpdateAgentArtifactContentPayload,
|
|
46
|
+
type UpdateAgentArtifactContentResponse,
|
|
44
47
|
type UpdateAgentRunStatusPayload,
|
|
45
48
|
type WorkflowAnalyticsFilterOptionsResponse,
|
|
46
49
|
type WorkflowAnalyticsSummaryQuery,
|
|
@@ -140,8 +143,8 @@ export class AgentsApi extends ApiTopic {
|
|
|
140
143
|
}
|
|
141
144
|
if (query?.interaction) params.interaction = query.interaction;
|
|
142
145
|
if (query?.started_by) params.started_by = query.started_by;
|
|
143
|
-
if (query?.since) params.since = query.since
|
|
144
|
-
if (query?.until) params.until = query.until
|
|
146
|
+
if (query?.since) params.since = query.since;
|
|
147
|
+
if (query?.until) params.until = query.until;
|
|
145
148
|
if (query?.schedule_id) params.schedule_id = query.schedule_id;
|
|
146
149
|
if (query?.type) params.type = query.type;
|
|
147
150
|
if (query?.run_type)
|
|
@@ -169,8 +172,8 @@ export class AgentsApi extends ApiTopic {
|
|
|
169
172
|
if (query?.categories?.length) params.categories = query.categories.join(',');
|
|
170
173
|
if (query?.tags?.length) params.tags = query.tags.join(',');
|
|
171
174
|
if (query?.content_type_name) params.content_type_name = query.content_type_name;
|
|
172
|
-
if (query?.since) params.since = query.since
|
|
173
|
-
if (query?.until) params.until = query.until
|
|
175
|
+
if (query?.since) params.since = query.since;
|
|
176
|
+
if (query?.until) params.until = query.until;
|
|
174
177
|
if (query?.limit) params.limit = String(query.limit);
|
|
175
178
|
if (query?.offset) params.offset = String(query.offset);
|
|
176
179
|
if (query?.sort?.length) params.sort = query.sort.join(',');
|
|
@@ -323,6 +326,10 @@ export class AgentsApi extends ApiTopic {
|
|
|
323
326
|
let currentSse: EventSource | null = null;
|
|
324
327
|
let interval: ReturnType<typeof setInterval> | null = null;
|
|
325
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;
|
|
326
333
|
let abortHandler: (() => void) | null = null;
|
|
327
334
|
|
|
328
335
|
const maxReconnectAttempts = 10;
|
|
@@ -335,11 +342,24 @@ export class AgentsApi extends ApiTopic {
|
|
|
335
342
|
return exponentialDelay + jitter;
|
|
336
343
|
};
|
|
337
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
|
+
|
|
338
357
|
const cleanup = () => {
|
|
339
358
|
if (reconnectTimer) {
|
|
340
359
|
clearTimeout(reconnectTimer);
|
|
341
360
|
reconnectTimer = null;
|
|
342
361
|
}
|
|
362
|
+
stopPolling();
|
|
343
363
|
if (interval) {
|
|
344
364
|
clearInterval(interval);
|
|
345
365
|
interval = null;
|
|
@@ -369,52 +389,101 @@ export class AgentsApi extends ApiTopic {
|
|
|
369
389
|
// for any messages we missed and exit cleanly once the run is terminal,
|
|
370
390
|
// even if the terminal message never streamed.
|
|
371
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;
|
|
372
396
|
const isTerminalStatus = (status: unknown): boolean =>
|
|
373
397
|
typeof status === 'string' && ['completed', 'failed', 'cancelled'].includes(status);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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++;
|
|
377
403
|
console.warn(
|
|
378
|
-
`Agent stream ${id}:
|
|
379
|
-
|
|
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,
|
|
380
406
|
);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (isClosed) return;
|
|
400
|
-
// Exit even when no terminal message ever streams, by reading the run record.
|
|
401
|
-
try {
|
|
402
|
-
const run = (await this.get(`/${id}`)) as { status?: unknown };
|
|
403
|
-
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)) {
|
|
404
425
|
exit(null);
|
|
405
426
|
return;
|
|
406
427
|
}
|
|
407
|
-
} catch {
|
|
408
|
-
// ignore — retry next tick
|
|
409
428
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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;
|
|
415
439
|
}
|
|
416
|
-
|
|
417
|
-
|
|
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);
|
|
418
487
|
};
|
|
419
488
|
|
|
420
489
|
if (signal) {
|
|
@@ -465,7 +534,7 @@ export class AgentsApi extends ApiTopic {
|
|
|
465
534
|
}
|
|
466
535
|
|
|
467
536
|
// 2. Connect to SSE for real-time updates
|
|
468
|
-
const setupStream = async (
|
|
537
|
+
const setupStream = async (mode: 'initial' | 'reconnect' | 'resume' = 'initial') => {
|
|
469
538
|
if (isClosed) return;
|
|
470
539
|
try {
|
|
471
540
|
const EventSourceImpl = await EventSourceProvider();
|
|
@@ -478,6 +547,10 @@ export class AgentsApi extends ApiTopic {
|
|
|
478
547
|
}
|
|
479
548
|
streamUrl.searchParams.set('skipHistory', 'true');
|
|
480
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.
|
|
481
554
|
const bearerToken = client._auth ? await client._auth() : undefined;
|
|
482
555
|
if (isClosed) return;
|
|
483
556
|
if (!bearerToken) {
|
|
@@ -490,9 +563,9 @@ export class AgentsApi extends ApiTopic {
|
|
|
490
563
|
const token = bearerToken.split(' ')[1];
|
|
491
564
|
streamUrl.searchParams.set('access_token', token);
|
|
492
565
|
|
|
493
|
-
if (
|
|
566
|
+
if (mode === 'reconnect') {
|
|
494
567
|
console.log(
|
|
495
|
-
`Reconnecting to agent stream ${id} (attempt ${reconnectAttempts
|
|
568
|
+
`Reconnecting to agent stream ${id} (attempt ${reconnectAttempts}/${maxReconnectAttempts})`,
|
|
496
569
|
);
|
|
497
570
|
}
|
|
498
571
|
|
|
@@ -504,7 +577,7 @@ export class AgentsApi extends ApiTopic {
|
|
|
504
577
|
let connectionOpenedAt = 0;
|
|
505
578
|
|
|
506
579
|
sse.onopen = () => {
|
|
507
|
-
if (
|
|
580
|
+
if (mode !== 'initial') console.log(`Reconnected to agent stream ${id}`);
|
|
508
581
|
connectionOpenedAt = Date.now();
|
|
509
582
|
};
|
|
510
583
|
|
|
@@ -546,35 +619,17 @@ export class AgentsApi extends ApiTopic {
|
|
|
546
619
|
reconnectAttempts = 0;
|
|
547
620
|
}
|
|
548
621
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
reconnectTimer = setTimeout(() => {
|
|
553
|
-
reconnectTimer = null;
|
|
554
|
-
if (!isClosed) void setupStream(true);
|
|
555
|
-
}, delay);
|
|
556
|
-
} else {
|
|
557
|
-
degradeToPolling(
|
|
558
|
-
new Error(`SSE connection failed after ${maxReconnectAttempts} reconnection attempts`),
|
|
559
|
-
);
|
|
560
|
-
}
|
|
622
|
+
scheduleReconnect(
|
|
623
|
+
new Error(`SSE connection failed after ${maxReconnectAttempts} reconnection attempts`),
|
|
624
|
+
);
|
|
561
625
|
};
|
|
562
626
|
} catch (err) {
|
|
563
627
|
if (isClosed) return;
|
|
564
|
-
|
|
565
|
-
const delay = calculateBackoffDelay(reconnectAttempts);
|
|
566
|
-
reconnectAttempts++;
|
|
567
|
-
reconnectTimer = setTimeout(() => {
|
|
568
|
-
reconnectTimer = null;
|
|
569
|
-
if (!isClosed) void setupStream(true);
|
|
570
|
-
}, delay);
|
|
571
|
-
} else {
|
|
572
|
-
degradeToPolling(err);
|
|
573
|
-
}
|
|
628
|
+
scheduleReconnect(err);
|
|
574
629
|
}
|
|
575
630
|
};
|
|
576
631
|
|
|
577
|
-
void setupStream(
|
|
632
|
+
void setupStream('initial');
|
|
578
633
|
return promise;
|
|
579
634
|
}
|
|
580
635
|
|
|
@@ -734,6 +789,20 @@ export class AgentsApi extends ApiTopic {
|
|
|
734
789
|
return this.get(`/${id}/artifacts`, { query });
|
|
735
790
|
}
|
|
736
791
|
|
|
792
|
+
/** Read a text artifact together with its conditional-write generation token. */
|
|
793
|
+
getArtifactContent(id: string, path: string): Promise<AgentArtifactContentResponse> {
|
|
794
|
+
return this.get(`/${id}/artifact-content/${path}`);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Conditionally replace the text content of an agent artifact. */
|
|
798
|
+
updateArtifactContent(
|
|
799
|
+
id: string,
|
|
800
|
+
path: string,
|
|
801
|
+
payload: UpdateAgentArtifactContentPayload,
|
|
802
|
+
): Promise<UpdateAgentArtifactContentResponse> {
|
|
803
|
+
return this.put(`/${id}/artifact-content/${path}`, { payload });
|
|
804
|
+
}
|
|
805
|
+
|
|
737
806
|
/**
|
|
738
807
|
* Get a signed download URL for an artifact.
|
|
739
808
|
*/
|
package/src/store/FilesApi.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
} from '@vertesia/common';
|
|
18
18
|
import { StreamSource } from '../StreamSource.js';
|
|
19
19
|
import { fetchSignedUrl } from './signed-url.js';
|
|
20
|
+
import { getUploadMimeTypeHint, resolveUploadMimeType } from './uploadMimeType.js';
|
|
20
21
|
|
|
21
22
|
export const MEMORIES_PREFIX = 'memories';
|
|
22
23
|
export const ARTIFACTS_PREFIX = 'agents';
|
|
@@ -137,7 +138,7 @@ export class FilesApi extends ApiTopic {
|
|
|
137
138
|
): Promise<{ source: string; name: string; type?: string; etag?: string }[]> {
|
|
138
139
|
const fileDescriptors = sources.map((s) => ({
|
|
139
140
|
name: s.name,
|
|
140
|
-
mime_type: s.type,
|
|
141
|
+
mime_type: getUploadMimeTypeHint(s.type),
|
|
141
142
|
id: s instanceof StreamSource ? s.id : undefined,
|
|
142
143
|
}));
|
|
143
144
|
|
|
@@ -153,7 +154,7 @@ export class FilesApi extends ApiTopic {
|
|
|
153
154
|
const idx = i + j;
|
|
154
155
|
const { url, id, mime_type } = files[idx];
|
|
155
156
|
const isStream = source instanceof StreamSource;
|
|
156
|
-
const sourceMimeType = source.type
|
|
157
|
+
const sourceMimeType = resolveUploadMimeType(source.type, mime_type);
|
|
157
158
|
|
|
158
159
|
const res = await fetchSignedUrl(url, {
|
|
159
160
|
method: 'PUT',
|
|
@@ -193,13 +194,18 @@ export class FilesApi extends ApiTopic {
|
|
|
193
194
|
*/
|
|
194
195
|
async uploadFileWithPath(source: StreamSource | File): Promise<{ id: string; path: string }> {
|
|
195
196
|
const isStream = source instanceof StreamSource;
|
|
196
|
-
const { url, id, path } = await this.getUploadUrl(
|
|
197
|
+
const { url, id, path, mime_type } = await this.getUploadUrl({
|
|
198
|
+
id: isStream ? source.id : undefined,
|
|
199
|
+
name: source.name,
|
|
200
|
+
mime_type: getUploadMimeTypeHint(source.type),
|
|
201
|
+
});
|
|
202
|
+
const sourceMimeType = resolveUploadMimeType(source.type, mime_type) || 'application/gzip';
|
|
197
203
|
|
|
198
204
|
const res = await fetchSignedUrl(url, {
|
|
199
205
|
method: 'PUT',
|
|
200
206
|
body: isStream ? source.stream : source,
|
|
201
207
|
headers: {
|
|
202
|
-
'Content-Type':
|
|
208
|
+
'Content-Type': sourceMimeType,
|
|
203
209
|
},
|
|
204
210
|
}).catch((err) => {
|
|
205
211
|
console.error('Failed to upload file', { err, url, id, path });
|