@vertesia/client 1.5.0-dev.20260714.072725Z → 1.5.0-dev.20260717.131047Z

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 (58) hide show
  1. package/lib/AppsApi.d.ts +61 -5
  2. package/lib/AppsApi.d.ts.map +1 -1
  3. package/lib/AppsApi.js +100 -4
  4. package/lib/AppsApi.js.map +1 -1
  5. package/lib/RunsApi.d.ts +8 -2
  6. package/lib/RunsApi.d.ts.map +1 -1
  7. package/lib/RunsApi.js +6 -2
  8. package/lib/RunsApi.js.map +1 -1
  9. package/lib/client.d.ts +8 -1
  10. package/lib/client.d.ts.map +1 -1
  11. package/lib/client.js +17 -0
  12. package/lib/client.js.map +1 -1
  13. package/lib/index.d.ts +1 -0
  14. package/lib/index.d.ts.map +1 -1
  15. package/lib/index.js +1 -0
  16. package/lib/index.js.map +1 -1
  17. package/lib/managed-sse.d.ts +27 -0
  18. package/lib/managed-sse.d.ts.map +1 -0
  19. package/lib/managed-sse.js +159 -0
  20. package/lib/managed-sse.js.map +1 -0
  21. package/lib/store/AgentsApi.d.ts.map +1 -1
  22. package/lib/store/AgentsApi.js +61 -6
  23. package/lib/store/AgentsApi.js.map +1 -1
  24. package/lib/store/EventsApi.d.ts +15 -1
  25. package/lib/store/EventsApi.d.ts.map +1 -1
  26. package/lib/store/EventsApi.js +57 -0
  27. package/lib/store/EventsApi.js.map +1 -1
  28. package/lib/store/FilesApi.d.ts.map +1 -1
  29. package/lib/store/FilesApi.js +6 -0
  30. package/lib/store/FilesApi.js.map +1 -1
  31. package/lib/store/client.d.ts +3 -1
  32. package/lib/store/client.d.ts.map +1 -1
  33. package/lib/store/client.js +11 -0
  34. package/lib/store/client.js.map +1 -1
  35. package/lib/store/index.d.ts +1 -0
  36. package/lib/store/index.d.ts.map +1 -1
  37. package/lib/store/index.js +1 -0
  38. package/lib/store/index.js.map +1 -1
  39. package/lib/store/version.d.ts +1 -0
  40. package/lib/store/version.d.ts.map +1 -1
  41. package/lib/store/version.js +1 -0
  42. package/lib/store/version.js.map +1 -1
  43. package/lib/vertesia-client.js +10 -10
  44. package/lib/vertesia-client.js.map +1 -1
  45. package/package.json +5 -4
  46. package/src/AppsApi.ts +133 -4
  47. package/src/RunsApi.ts +12 -2
  48. package/src/client.ts +18 -2
  49. package/src/index.ts +1 -0
  50. package/src/managed-sse.ts +205 -0
  51. package/src/store/AgentsApi.ts +57 -6
  52. package/src/store/EventsApi.test.ts +131 -0
  53. package/src/store/EventsApi.ts +80 -0
  54. package/src/store/FilesApi.test.ts +69 -0
  55. package/src/store/FilesApi.ts +8 -1
  56. package/src/store/client.ts +11 -1
  57. package/src/store/index.ts +1 -0
  58. package/src/store/version.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertesia/client",
3
- "version": "1.5.0-dev.20260714.072725Z",
3
+ "version": "1.5.0-dev.20260717.131047Z",
4
4
  "type": "module",
5
5
  "main": "./lib/index.js",
6
6
  "types": "./lib/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  "license": "Apache-2.0",
13
13
  "devDependencies": {
14
14
  "@types/node": "^24.13.2",
15
- "rolldown": "1.1.3",
15
+ "rolldown": "1.1.4",
16
16
  "typescript": "^6.0.3",
17
17
  "vitest": "^4.1.9",
18
18
  "@vertesia/tsconfig": "0.1.0"
@@ -20,8 +20,8 @@
20
20
  "dependencies": {
21
21
  "eventsource": "^4.1.0",
22
22
  "@llumiverse/common": "1.5.0-dev.20260713.114357Z",
23
- "@vertesia/api-fetch-client": "1.5.0-dev.20260714.072725Z",
24
- "@vertesia/common": "1.5.0-dev.20260714.072725Z"
23
+ "@vertesia/common": "1.5.0-dev.20260717.131047Z",
24
+ "@vertesia/api-fetch-client": "1.5.0-dev.20260717.131047Z"
25
25
  },
26
26
  "exports": {
27
27
  ".": {
@@ -48,6 +48,7 @@
48
48
  "ai",
49
49
  "typescript"
50
50
  ],
51
+ "gitHead": "8aaad228827b2927db8e6aa75b27bfac90fec36f",
51
52
  "scripts": {
52
53
  "lint": "biome lint src",
53
54
  "clean:lib": "rimraf ./lib ./tsconfig.tsbuildinfo",
package/src/AppsApi.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { ApiTopic, type ClientBase, type ServerError } from '@vertesia/api-fetch-client';
2
2
  import type {
3
+ ActivateAppVersionResponse,
4
+ AppBuildProgress,
5
+ AppDeleteSummary,
6
+ AppInspectionResult,
3
7
  AppInstallation,
4
8
  AppInstallationKind,
5
9
  AppInstallationListEntry,
@@ -7,11 +11,21 @@ import type {
7
11
  AppInstallationWithManifest,
8
12
  AppManifest,
9
13
  AppManifestData,
14
+ AppPackage,
15
+ AppPackageScope,
16
+ AppRepoFile,
17
+ AppRepoRefs,
18
+ AppRepoTree,
10
19
  AppToolCollection,
20
+ AppVersionListQuery,
21
+ AppVersionRecord,
11
22
  CountResult,
12
23
  ProjectRef,
13
24
  RequireAtLeastOne,
25
+ StartAppBuildRequest,
26
+ StartAppBuildResponse,
14
27
  UpdateAppInstallationToolAllowlistPayload,
28
+ UpsertAppVersionRequest,
15
29
  ValidateUrlRequest,
16
30
  ValidateUrlResponse,
17
31
  } from '@vertesia/common';
@@ -34,14 +48,55 @@ export default class AppsApi extends ApiTopic {
34
48
  }
35
49
 
36
50
  /**
37
- * Delete an app manifest by id. The owning account must permit the operation.
38
- * Note: also uninstall any existing installations of this manifest before deleting,
39
- * otherwise the server may refuse or leave orphaned installation records.
51
+ * Preview what the cascade delete would remove. Calls DELETE without confirm,
52
+ * which the server treats as a dry-run and returns counts + paths.
40
53
  */
41
- delete(id: string): Promise<CountResult> {
54
+ previewDelete(id: string): Promise<AppDeleteSummary> {
42
55
  return this.del(`/${id}`);
43
56
  }
44
57
 
58
+ /**
59
+ * Cascade-delete an app and everything attached to it (versions,
60
+ * installations + ACEs, git repo on the app-git server). Pass through
61
+ * the dry-run summary; confirm flag is required server-side.
62
+ */
63
+ deleteApp(id: string): Promise<AppDeleteSummary> {
64
+ return this.del(`/${id}`, { query: { confirm: 'true' } });
65
+ }
66
+
67
+ listVersions(query?: AppVersionListQuery): Promise<AppVersionRecord[]> {
68
+ return this.get('/versions', {
69
+ query: {
70
+ ...(query?.app_id && { app_id: query.app_id }),
71
+ ...(query?.kind && { kind: query.kind }),
72
+ ...(query?.include_expired !== undefined && { include_expired: query.include_expired }),
73
+ ...(query?.limit !== undefined && { limit: query.limit }),
74
+ },
75
+ });
76
+ }
77
+
78
+ upsertVersion(payload: UpsertAppVersionRequest): Promise<AppVersionRecord> {
79
+ return this.post('/versions', { payload });
80
+ }
81
+
82
+ getVersion(recordId: string): Promise<AppVersionRecord> {
83
+ return this.get(`/versions/${recordId}`);
84
+ }
85
+
86
+ activateVersion(recordId: string): Promise<ActivateAppVersionResponse> {
87
+ return this.post(`/versions/${recordId}/activate`);
88
+ }
89
+
90
+ startBuild(appIdOrRecordId: string, payload: StartAppBuildRequest): Promise<StartAppBuildResponse> {
91
+ return this.post(`/${encodeURIComponent(appIdOrRecordId)}/builds`, { payload });
92
+ }
93
+
94
+ getBuildProgress(appIdOrRecordId: string, workflowId: string, runId: string): Promise<AppBuildProgress> {
95
+ return this.get(
96
+ `/${encodeURIComponent(appIdOrRecordId)}/builds/${encodeURIComponent(workflowId)}/${encodeURIComponent(runId)}/progress`,
97
+ );
98
+ }
99
+
45
100
  /**
46
101
  * Get the list if tools provided by the given app.
47
102
  * @param appId
@@ -51,6 +106,80 @@ export default class AppsApi extends ApiTopic {
51
106
  return this.get(`/installations/${appInstallId}/tools`);
52
107
  }
53
108
 
109
+ /**
110
+ * Inspect an app's registration: resolved manifest state, what the published
111
+ * package actually exposes per capability, endpoint reachability, install
112
+ * state, and diagnostics. Use this to verify what an app registers vs what it
113
+ * declares, instead of inferring it from failed object/import calls.
114
+ */
115
+ inspect(appIdOrName: string): Promise<AppInspectionResult> {
116
+ return this.get(`/${appIdOrName}/inspect`);
117
+ }
118
+
119
+ /**
120
+ * List files/directories under a prefix in the app's git repository (default
121
+ * branch unless `ref` is given). Read-only; reads live from the git server
122
+ * without a clone. Used by the Build > Design view to surface generated `docs/`.
123
+ */
124
+ getRepoTree(appIdOrName: string, options?: { prefix?: string; ref?: string }): Promise<AppRepoTree> {
125
+ return this.get(`/${encodeURIComponent(appIdOrName)}/repo/tree`, {
126
+ query: { prefix: options?.prefix, ref: options?.ref },
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Read the UTF-8 content of a single file in the app's git repository (default
132
+ * branch unless `ref` is given). Read-only; reads live from the git server.
133
+ */
134
+ getRepoFile(appIdOrName: string, path: string, options?: { ref?: string }): Promise<AppRepoFile> {
135
+ return this.get(`/${encodeURIComponent(appIdOrName)}/repo/file`, {
136
+ query: { path, ref: options?.ref },
137
+ });
138
+ }
139
+
140
+ /**
141
+ * List the branches and tags of the app's git repository, each resolved to its latest commit.
142
+ * Read-only; reads live from the git server without a clone. Used by the Source view to let a
143
+ * user pick a ref to build from.
144
+ */
145
+ getRepoRefs(appIdOrName: string): Promise<AppRepoRefs> {
146
+ return this.get(`/${encodeURIComponent(appIdOrName)}/repo/refs`);
147
+ }
148
+
149
+ /**
150
+ * Get the published package an app exposes, filtered by scope. App-owned (in-code) artifacts are
151
+ * package-resolved (no per-id route), so this is how a client reads the full definition — type
152
+ * schema, interaction prompt, process definition, dashboard spec — to visualize an exposed id.
153
+ */
154
+ getAppPackage(appIdOrName: string, scope: AppPackageScope | AppPackageScope[] = 'all'): Promise<AppPackage> {
155
+ return this.get(`/${encodeURIComponent(appIdOrName)}/package`, {
156
+ query: { scope: Array.isArray(scope) ? scope.join(',') : scope },
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Get package capabilities exposed by an app installation.
162
+ */
163
+ getAppInstallationPackage(
164
+ appInstallId: string,
165
+ scope: AppPackageScope | AppPackageScope[] = 'all',
166
+ ): Promise<AppPackage> {
167
+ return this.get(`/installations/${appInstallId}/package`, {
168
+ query: {
169
+ scope: Array.isArray(scope) ? scope.join(',') : scope,
170
+ },
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Fetch the always-on system tools package served by studio-server.
176
+ * Tools and skills (`learn_*`) are returned on separate fields so UIs can
177
+ * render them distinctly. URLs are already resolved per deployment.
178
+ */
179
+ getSystemToolsPackage(scope: string = 'tools'): Promise<AppPackage> {
180
+ return this.get('/studio-tools/package', { query: { scope } });
181
+ }
182
+
54
183
  /**
55
184
  * @param ids - ids to filter by
56
185
  * @returns the app manifests but without the agent.tool property which can be big.
package/src/RunsApi.ts CHANGED
@@ -115,9 +115,14 @@ export class RunsApi extends ApiTopic {
115
115
  * @param payload
116
116
  * @returns
117
117
  */
118
- sendToolResults(payload: ToolResultsPayload): Promise<ExecutionResponse> {
118
+ sendToolResults(
119
+ payload: ToolResultsPayload,
120
+ options?: { timeoutMs?: number | false | null; signal?: AbortSignal },
121
+ ): Promise<ExecutionResponse> {
119
122
  return this.post(`/tool-results`, {
120
123
  payload,
124
+ timeoutMs: options?.timeoutMs,
125
+ signal: options?.signal,
121
126
  });
122
127
  }
123
128
 
@@ -126,9 +131,14 @@ export class RunsApi extends ApiTopic {
126
131
  * @param payload
127
132
  * @returns
128
133
  */
129
- sendUserMessage(payload: UserMessagePayload): Promise<ExecutionResponse> {
134
+ sendUserMessage(
135
+ payload: UserMessagePayload,
136
+ options?: { timeoutMs?: number | false | null; signal?: AbortSignal },
137
+ ): Promise<ExecutionResponse> {
130
138
  return this.post(`/user-message`, {
131
139
  payload,
140
+ timeoutMs: options?.timeoutMs,
141
+ signal: options?.signal,
132
142
  });
133
143
  }
134
144
 
package/src/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AbstractFetchClient, type FETCH_FN, type IRequestRetryPolicy } from '@vertesia/api-fetch-client';
2
- import type { AuthTokenPayload, AuthTokenResponse } from '@vertesia/common';
2
+ import { APP_VERSION_HEADER, type AuthTokenPayload, type AuthTokenResponse } from '@vertesia/common';
3
3
  import AccountApi from './AccountApi.js';
4
4
  import AccountsApi from './AccountsApi.js';
5
5
  import AnalyticsApi from './AnalyticsApi.js';
@@ -116,7 +116,7 @@ export class VertesiaClient extends AbstractFetchClient<VertesiaClient> {
116
116
  static async fromAuthToken(
117
117
  token: string,
118
118
  payload?: AuthTokenPayload,
119
- endpoints?: { studio: string; store: string; token?: string },
119
+ endpoints?: { studio: string; store: string; token?: string; git?: string },
120
120
  ) {
121
121
  if (!payload) {
122
122
  payload = decodeJWT(token);
@@ -240,6 +240,20 @@ export class VertesiaClient extends AbstractFetchClient<VertesiaClient> {
240
240
  return this;
241
241
  }
242
242
 
243
+ /**
244
+ * Pin the app version this client's requests resolve against. A generated app's UI sets this from
245
+ * its build-time `VITE_APP_VERSION` so studio/zeno resolve its `app:<app>:…` refs against that
246
+ * published version (candidate testing) instead of current. Pass null/empty to clear (→ current).
247
+ */
248
+ withAppVersion(version: string | null | undefined) {
249
+ if (!version) {
250
+ delete this.headers[APP_VERSION_HEADER];
251
+ } else {
252
+ this.headers[APP_VERSION_HEADER] = String(version);
253
+ }
254
+ return this;
255
+ }
256
+
243
257
  /**
244
258
  * Overwrite to keep store and composable clients synchronized on the auth callback
245
259
  * @param authCb
@@ -500,6 +514,7 @@ function getEndpointsFromDomain(domain: string) {
500
514
  studio: `http://localhost:8091`,
501
515
  store: `http://localhost:8092`,
502
516
  token: getRuntimeStsUrl() ?? 'https://sts.dev1.vertesia.io',
517
+ git: 'https://git.dev1.vertesia.io',
503
518
  };
504
519
  } else {
505
520
  const url = `https://${domain}`;
@@ -509,6 +524,7 @@ function getEndpointsFromDomain(domain: string) {
509
524
  studio: url,
510
525
  store: url,
511
526
  token: url.replace('api', 'sts'),
527
+ git: url.replace('api', 'git'),
512
528
  };
513
529
  }
514
530
  }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export type { GroupsQueryOptions } from './GroupsApi.js';
7
7
  export * from './InteractionBase.js';
8
8
  export * from './InteractionOutput.js';
9
9
  export { default as InternalSecretsApi } from './InternalSecretsApi.js';
10
+ export * from './managed-sse.js';
10
11
  export { default as OAuthClientsApi } from './OAuthClientsApi.js';
11
12
  export { default as OAuthGrantsApi } from './OAuthGrantsApi.js';
12
13
  export { default as OAuthProvidersApi } from './OAuthProvidersApi.js';
@@ -0,0 +1,205 @@
1
+ import { EventSourceProvider } from './execute.js';
2
+
3
+ export type ManagedEventSourceStatus = 'connecting' | 'open' | 'reconnecting' | 'closed';
4
+
5
+ export interface ManagedEventSourceEvent<T> {
6
+ data: T;
7
+ event_type: string;
8
+ event_id?: string;
9
+ }
10
+
11
+ export interface ManagedEventSourceConnection {
12
+ readonly closed: boolean;
13
+ close(): void;
14
+ }
15
+
16
+ export interface ManagedEventSourceOptions<T> {
17
+ url: URL | string | (() => URL | string | Promise<URL | string>);
18
+ event_types?: string[];
19
+ signal?: AbortSignal;
20
+ max_reconnect_attempts?: number;
21
+ base_reconnect_delay_ms?: number;
22
+ max_reconnect_delay_ms?: number;
23
+ last_event_id_query_param?: string;
24
+ get_access_token?: () => Promise<string | undefined>;
25
+ parse?: (data: string, eventType: string) => T;
26
+ get_cursor?: (data: T) => string | undefined;
27
+ on_event: (event: ManagedEventSourceEvent<T>) => void;
28
+ on_error?: (error: unknown) => void;
29
+ on_status?: (status: ManagedEventSourceStatus) => void;
30
+ }
31
+
32
+ function defaultParse<T>(data: string): T {
33
+ return JSON.parse(data) as T;
34
+ }
35
+
36
+ function calculateBackoffDelay(attempts: number, baseDelayMs: number, maxDelayMs: number): number {
37
+ const exponentialDelay = Math.min(baseDelayMs * 2 ** attempts, maxDelayMs);
38
+ const jitter = Math.random() * 0.1 * exponentialDelay;
39
+ return exponentialDelay + jitter;
40
+ }
41
+
42
+ function withAccessToken(url: URL, bearerToken: string | undefined): void {
43
+ if (!bearerToken) {
44
+ return;
45
+ }
46
+ const token = bearerToken.startsWith('Bearer ') ? bearerToken.slice('Bearer '.length) : bearerToken;
47
+ url.searchParams.set('access_token', token);
48
+ }
49
+
50
+ async function resolveUrl(value: ManagedEventSourceOptions<unknown>['url']): Promise<URL> {
51
+ const resolved = typeof value === 'function' ? await value() : value;
52
+ return resolved instanceof URL ? new URL(resolved.href) : new URL(resolved);
53
+ }
54
+
55
+ export function openManagedEventSource<T>(options: ManagedEventSourceOptions<T>): ManagedEventSourceConnection {
56
+ const eventTypes = options.event_types?.length ? options.event_types : ['message'];
57
+ const maxReconnectAttempts = options.max_reconnect_attempts ?? 10;
58
+ const baseDelayMs = options.base_reconnect_delay_ms ?? 1000;
59
+ const maxDelayMs = options.max_reconnect_delay_ms ?? 30_000;
60
+ const parse = options.parse ?? defaultParse<T>;
61
+
62
+ let closed = false;
63
+ let reconnectAttempts = 0;
64
+ let currentSse: EventSource | null = null;
65
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
66
+ let abortHandler: (() => void) | null = null;
67
+ let connectionOpenedAt = 0;
68
+ let lastEventId: string | undefined;
69
+
70
+ const emitStatus = (status: ManagedEventSourceStatus) => {
71
+ options.on_status?.(status);
72
+ };
73
+
74
+ const cleanupCurrentSource = () => {
75
+ if (currentSse) {
76
+ currentSse.close();
77
+ currentSse = null;
78
+ }
79
+ if (reconnectTimer) {
80
+ clearTimeout(reconnectTimer);
81
+ reconnectTimer = null;
82
+ }
83
+ };
84
+
85
+ const close = () => {
86
+ if (closed) {
87
+ return;
88
+ }
89
+ closed = true;
90
+ cleanupCurrentSource();
91
+ if (options.signal && abortHandler) {
92
+ options.signal.removeEventListener('abort', abortHandler);
93
+ abortHandler = null;
94
+ }
95
+ emitStatus('closed');
96
+ };
97
+
98
+ const scheduleReconnect = (error: unknown) => {
99
+ if (closed) {
100
+ return;
101
+ }
102
+ cleanupCurrentSource();
103
+ const connectionDuration = connectionOpenedAt ? Date.now() - connectionOpenedAt : 0;
104
+ if (connectionDuration > 5000) {
105
+ reconnectAttempts = 0;
106
+ }
107
+ if (reconnectAttempts >= maxReconnectAttempts) {
108
+ close();
109
+ options.on_error?.(error);
110
+ return;
111
+ }
112
+ const delay = calculateBackoffDelay(reconnectAttempts, baseDelayMs, maxDelayMs);
113
+ reconnectAttempts += 1;
114
+ emitStatus('reconnecting');
115
+ reconnectTimer = setTimeout(() => {
116
+ reconnectTimer = null;
117
+ void connect(true);
118
+ }, delay);
119
+ };
120
+
121
+ const handleMessage = (eventType: string, ev: MessageEvent) => {
122
+ if (closed || !ev.data || String(ev.data).startsWith(':')) {
123
+ return;
124
+ }
125
+ try {
126
+ const data = parse(String(ev.data), eventType);
127
+ const cursor = options.get_cursor?.(data);
128
+ lastEventId = ev.lastEventId || cursor || lastEventId;
129
+ options.on_event({
130
+ data,
131
+ event_type: eventType,
132
+ event_id: ev.lastEventId || cursor,
133
+ });
134
+ } catch (err) {
135
+ options.on_error?.(err);
136
+ }
137
+ };
138
+
139
+ const connect = async (isReconnect: boolean) => {
140
+ if (closed) {
141
+ return;
142
+ }
143
+ try {
144
+ emitStatus(isReconnect ? 'reconnecting' : 'connecting');
145
+ const EventSourceImpl = await EventSourceProvider();
146
+ if (closed) {
147
+ return;
148
+ }
149
+ const url = await resolveUrl(options.url);
150
+ if (
151
+ lastEventId &&
152
+ options.last_event_id_query_param &&
153
+ !url.searchParams.has(options.last_event_id_query_param)
154
+ ) {
155
+ url.searchParams.set(options.last_event_id_query_param, lastEventId);
156
+ }
157
+ withAccessToken(url, await options.get_access_token?.());
158
+ if (closed) {
159
+ return;
160
+ }
161
+ const sse = new EventSourceImpl(url.href);
162
+ currentSse = sse;
163
+ connectionOpenedAt = 0;
164
+
165
+ sse.onopen = () => {
166
+ connectionOpenedAt = Date.now();
167
+ emitStatus('open');
168
+ };
169
+
170
+ for (const eventType of eventTypes) {
171
+ sse.addEventListener(eventType, (ev) => handleMessage(eventType, ev as MessageEvent));
172
+ }
173
+
174
+ sse.onerror = (err: unknown) => {
175
+ scheduleReconnect(err);
176
+ };
177
+ } catch (err) {
178
+ scheduleReconnect(err);
179
+ }
180
+ };
181
+
182
+ if (options.signal) {
183
+ if (options.signal.aborted) {
184
+ closed = true;
185
+ emitStatus('closed');
186
+ return {
187
+ get closed() {
188
+ return closed;
189
+ },
190
+ close,
191
+ };
192
+ }
193
+ abortHandler = close;
194
+ options.signal.addEventListener('abort', abortHandler, { once: true });
195
+ }
196
+
197
+ void connect(false);
198
+
199
+ return {
200
+ get closed() {
201
+ return closed;
202
+ },
203
+ close,
204
+ };
205
+ }
@@ -362,6 +362,61 @@ export class AgentsApi extends ApiTopic {
362
362
  }
363
363
  };
364
364
 
365
+ // SSE reconnection exhausted. The run is durable and still executing
366
+ // server-side — only the live view dropped. Failing fatally here aborts
367
+ // headless consumers (CLI `agents stream`, agent test harnesses) before
368
+ // they can read the result. Instead degrade gracefully: poll GET /updates
369
+ // for any messages we missed and exit cleanly once the run is terminal,
370
+ // even if the terminal message never streamed.
371
+ const POLL_INTERVAL_MS = 5000;
372
+ const isTerminalStatus = (status: unknown): boolean =>
373
+ typeof status === 'string' && ['completed', 'failed', 'cancelled'].includes(status);
374
+ const degradeToPolling = (reason: unknown) => {
375
+ if (isClosed) return;
376
+ cleanup();
377
+ console.warn(
378
+ `Agent stream ${id}: SSE unavailable after ${maxReconnectAttempts} attempts; falling back to /updates polling.`,
379
+ reason instanceof Error ? reason.message : reason,
380
+ );
381
+ const tick = async () => {
382
+ if (isClosed) return;
383
+ try {
384
+ const recent = await this.retrieveMessages(id, lastMessageTimestamp || undefined);
385
+ for (const msg of recent) {
386
+ if (isClosed) return;
387
+ if ((msg.timestamp || 0) <= lastMessageTimestamp) continue;
388
+ lastMessageTimestamp = msg.timestamp || lastMessageTimestamp;
389
+ if (onMessage) onMessage(msg, exit);
390
+ if (isClosed) return;
391
+ if (shouldCloseAgentRunStream(msg, id)) {
392
+ exit(null);
393
+ return;
394
+ }
395
+ }
396
+ } catch {
397
+ // transient poll error — retry next tick
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)) {
404
+ exit(null);
405
+ return;
406
+ }
407
+ } catch {
408
+ // ignore — retry next tick
409
+ }
410
+ if (!isClosed) {
411
+ reconnectTimer = setTimeout(() => {
412
+ reconnectTimer = null;
413
+ void tick();
414
+ }, POLL_INTERVAL_MS);
415
+ }
416
+ };
417
+ void tick();
418
+ };
419
+
365
420
  if (signal) {
366
421
  if (signal.aborted) {
367
422
  isClosed = true;
@@ -499,9 +554,7 @@ export class AgentsApi extends ApiTopic {
499
554
  if (!isClosed) void setupStream(true);
500
555
  }, delay);
501
556
  } else {
502
- isClosed = true;
503
- cleanup();
504
- rejectFn(
557
+ degradeToPolling(
505
558
  new Error(`SSE connection failed after ${maxReconnectAttempts} reconnection attempts`),
506
559
  );
507
560
  }
@@ -516,9 +569,7 @@ export class AgentsApi extends ApiTopic {
516
569
  if (!isClosed) void setupStream(true);
517
570
  }, delay);
518
571
  } else {
519
- isClosed = true;
520
- cleanup();
521
- rejectFn(err);
572
+ degradeToPolling(err);
522
573
  }
523
574
  }
524
575
  };