@vertesia/client 1.5.0-dev.20260725.083715Z → 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.
Files changed (68) 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/ApiKeysApi.d.ts +3 -3
  6. package/lib/ApiKeysApi.d.ts.map +1 -1
  7. package/lib/ApiKeysApi.js.map +1 -1
  8. package/lib/EnvironmentsApi.d.ts +3 -6
  9. package/lib/EnvironmentsApi.d.ts.map +1 -1
  10. package/lib/EnvironmentsApi.js.map +1 -1
  11. package/lib/GroupsApi.d.ts +2 -0
  12. package/lib/GroupsApi.d.ts.map +1 -1
  13. package/lib/GroupsApi.js.map +1 -1
  14. package/lib/InteractionBase.d.ts +686 -2
  15. package/lib/InteractionBase.d.ts.map +1 -1
  16. package/lib/OAuthGrantsApi.d.ts +2 -4
  17. package/lib/OAuthGrantsApi.d.ts.map +1 -1
  18. package/lib/OAuthGrantsApi.js.map +1 -1
  19. package/lib/OAuthProvidersApi.d.ts +6 -1
  20. package/lib/OAuthProvidersApi.d.ts.map +1 -1
  21. package/lib/OAuthProvidersApi.js +5 -0
  22. package/lib/OAuthProvidersApi.js.map +1 -1
  23. package/lib/PromptsApi.d.ts +19 -30
  24. package/lib/PromptsApi.d.ts.map +1 -1
  25. package/lib/PromptsApi.js +1 -1
  26. package/lib/PromptsApi.js.map +1 -1
  27. package/lib/client.d.ts +10 -2
  28. package/lib/client.d.ts.map +1 -1
  29. package/lib/store/AgentsApi.d.ts.map +1 -1
  30. package/lib/store/AgentsApi.js +121 -77
  31. package/lib/store/AgentsApi.js.map +1 -1
  32. package/lib/store/ObjectsApi.d.ts +3 -8
  33. package/lib/store/ObjectsApi.d.ts.map +1 -1
  34. package/lib/store/ObjectsApi.js +2 -2
  35. package/lib/store/ObjectsApi.js.map +1 -1
  36. package/lib/store/TaskApi.d.ts +9 -0
  37. package/lib/store/TaskApi.d.ts.map +1 -1
  38. package/lib/store/TaskApi.js +11 -16
  39. package/lib/store/TaskApi.js.map +1 -1
  40. package/lib/store/TypeCatalogApi.d.ts +21 -7
  41. package/lib/store/TypeCatalogApi.d.ts.map +1 -1
  42. package/lib/store/TypeCatalogApi.js +16 -2
  43. package/lib/store/TypeCatalogApi.js.map +1 -1
  44. package/lib/store/TypesApi.d.ts +2 -2
  45. package/lib/store/TypesApi.d.ts.map +1 -1
  46. package/lib/store/TypesApi.js.map +1 -1
  47. package/lib/store/version.d.ts +1 -2
  48. package/lib/store/version.d.ts.map +1 -1
  49. package/lib/store/version.js +3 -2
  50. package/lib/store/version.js.map +1 -1
  51. package/lib/vertesia-client.js +10 -10
  52. package/lib/vertesia-client.js.map +1 -1
  53. package/package.json +5 -5
  54. package/src/AccountApi.test.ts +30 -0
  55. package/src/AccountApi.ts +10 -8
  56. package/src/ApiKeysApi.ts +4 -3
  57. package/src/EnvironmentsApi.ts +3 -11
  58. package/src/GroupsApi.ts +2 -0
  59. package/src/OAuthGrantsApi.ts +2 -1
  60. package/src/OAuthProvidersApi.ts +6 -1
  61. package/src/PromptsApi.ts +22 -25
  62. package/src/store/AgentsApi.stream.test.ts +298 -0
  63. package/src/store/AgentsApi.ts +120 -68
  64. package/src/store/ObjectsApi.ts +8 -8
  65. package/src/store/TaskApi.ts +11 -11
  66. package/src/store/TypeCatalogApi.ts +21 -7
  67. package/src/store/TypesApi.ts +2 -1
  68. package/src/store/version.ts +3 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertesia/client",
3
- "version": "1.5.0-dev.20260725.083715Z",
3
+ "version": "1.5.0-dev.20260804.124748Z",
4
4
  "type": "module",
5
5
  "main": "./lib/index.js",
6
6
  "types": "./lib/index.d.ts",
@@ -19,9 +19,9 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "eventsource": "^4.1.0",
22
- "@llumiverse/common": "1.5.0-dev.20260725.065646Z",
23
- "@vertesia/api-fetch-client": "1.5.0-dev.20260725.083715Z",
24
- "@vertesia/common": "1.5.0-dev.20260725.083715Z"
22
+ "@llumiverse/common": "1.5.0-dev.20260804.122431Z",
23
+ "@vertesia/common": "1.5.0-dev.20260804.124748Z",
24
+ "@vertesia/api-fetch-client": "1.5.0-dev.20260804.124748Z"
25
25
  },
26
26
  "exports": {
27
27
  ".": {
@@ -48,7 +48,7 @@
48
48
  "ai",
49
49
  "typescript"
50
50
  ],
51
- "gitHead": "57bcc2f27dd17b3387f27c18e89329c4c37f98ac",
51
+ "gitHead": "87ac9166db2b38bd1be6e37adf0414ea83e402ee",
52
52
  "scripts": {
53
53
  "lint": "biome lint src",
54
54
  "clean:lib": "rimraf ./lib ./tsconfig.tsbuildinfo",
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { VertesiaClient } from './client.js';
3
+
4
+ describe('AccountApi', () => {
5
+ it('returns onboarding.completed_at as the ISO string received over JSON', async () => {
6
+ const completedAt = '2026-07-29T10:00:00.000Z';
7
+ const fetchMock = vi.fn(async () => {
8
+ return new Response(
9
+ JSON.stringify({
10
+ id: 'account-id',
11
+ onboarding: { completed: true, completed_at: completedAt },
12
+ }),
13
+ {
14
+ status: 200,
15
+ headers: { 'content-type': 'application/json' },
16
+ },
17
+ );
18
+ });
19
+ const client = new VertesiaClient({
20
+ serverUrl: 'https://api.example.com',
21
+ storeUrl: 'https://api.example.com',
22
+ fetch: fetchMock,
23
+ });
24
+
25
+ const account = await client.account.info();
26
+
27
+ expect(account.onboarding.completed_at).toBe(completedAt);
28
+ expect(account.onboarding.completed_at).not.toBeInstanceOf(Date);
29
+ });
30
+ });
package/src/AccountApi.ts CHANGED
@@ -9,10 +9,9 @@ import type {
9
9
  OnboardingProgress,
10
10
  ProjectRef,
11
11
  StripeBillingStatusResponse,
12
- TransientToken,
13
12
  UpdateAccountPayload,
14
13
  User,
15
- UserInviteTokenData,
14
+ UserInviteToken,
16
15
  } from '@vertesia/common';
17
16
 
18
17
  export default class AccountApi extends ApiTopic {
@@ -56,17 +55,20 @@ export default class AccountApi extends ApiTopic {
56
55
 
57
56
  /**
58
57
  * Fetch Invites for Principal
59
- * @returns UserInviteTokenData[]
60
- * */
61
- listInvites(): Promise<TransientToken<UserInviteTokenData>[]> {
58
+ *
59
+ * Returns `UserInviteToken[]`, not `TransientToken<UserInviteTokenData>[]`. The latter declared
60
+ * `Date` for `expires`, `created_at` and `updated_at`, which no HTTP response can carry — the
61
+ * client hands back whatever `JSON.parse` produced, so those three were always strings. It now
62
+ * says so.
63
+ */
64
+ listInvites(): Promise<UserInviteToken[]> {
62
65
  return this.get('/invites');
63
66
  }
64
67
  /**
65
68
  * Fetch Invites for specific account or project
66
69
  * @param type Filter for the type of invitation, either "project" or "account"
67
- * @returns UserInviteTokenData[]
68
- * */
69
- listInvitation(type: 'project' | 'account' = 'project'): Promise<TransientToken<UserInviteTokenData>[]> {
70
+ */
71
+ listInvitation(type: 'project' | 'account' = 'project'): Promise<UserInviteToken[]> {
70
72
  return this.get(`/invites/${type}`);
71
73
  }
72
74
 
package/src/ApiKeysApi.ts CHANGED
@@ -4,9 +4,10 @@ import type {
4
4
  ApiKeyReadResponse,
5
5
  ApiKeyWithValue,
6
6
  AuthTokenResponse,
7
- CreateOrUpdateApiKeyPayload,
7
+ CreateApiKeyPayload,
8
8
  CreatePublicKeyPayload,
9
9
  DeleteOperationResult,
10
+ UpdateApiKeyPayload,
10
11
  } from '@vertesia/common';
11
12
 
12
13
  export class ApiKeysApi extends ApiTopic {
@@ -28,7 +29,7 @@ export class ApiKeysApi extends ApiTopic {
28
29
  * ALL REQUESTS ARE LOGGED IN SECURITY AUDIT LOG
29
30
  * @returns ApiKeyWithValue
30
31
  */
31
- create(payload: CreateOrUpdateApiKeyPayload): Promise<ApiKeyWithValue> {
32
+ create(payload: CreateApiKeyPayload): Promise<ApiKeyWithValue> {
32
33
  return this.post('/', { payload });
33
34
  }
34
35
 
@@ -36,7 +37,7 @@ export class ApiKeysApi extends ApiTopic {
36
37
  * Update an existing ApiKey for account
37
38
  * @returns ApiKey
38
39
  */
39
- update(id: string, payload: CreateOrUpdateApiKeyPayload): Promise<ApiKey> {
40
+ update(id: string, payload: UpdateApiKeyPayload): Promise<ApiKey> {
40
41
  return this.put(`/${id}`, { payload });
41
42
  }
42
43
 
@@ -5,11 +5,9 @@ import type {
5
5
  EmbeddingsApiResult,
6
6
  EnableEnvironmentModelPayload,
7
7
  ExecutionEnvironment,
8
+ ExecutionEnvironmentConfigUpdatePayload,
8
9
  ExecutionEnvironmentCreatePayload,
9
- ExecutionEnvironmentRef,
10
10
  ExecutionEnvironmentUpdatePayload,
11
- LoadBalancingEnvConfig,
12
- MediatorEnvConfig,
13
11
  MigrateInteractionsPayload,
14
12
  MigrateInteractionsResult,
15
13
  } from '@vertesia/common';
@@ -23,7 +21,7 @@ export default class EnvironmentsApi extends ApiTopic {
23
21
  * List all environments for the current project
24
22
  * @param all if true, list all environments, otherwise only the ones for the current project
25
23
  */
26
- list(all: boolean = false): Promise<ExecutionEnvironmentRef[]> {
24
+ list(all: boolean = false): Promise<ExecutionEnvironment[]> {
27
25
  const query = all ? { all: true } : undefined;
28
26
 
29
27
  return this.get('/', { query });
@@ -54,13 +52,7 @@ export default class EnvironmentsApi extends ApiTopic {
54
52
  * @param payload
55
53
  * @returns
56
54
  */
57
- updateConfig(
58
- id: string,
59
- payload: {
60
- enabled_models?: AIModel[];
61
- config?: MediatorEnvConfig | LoadBalancingEnvConfig;
62
- },
63
- ): Promise<ExecutionEnvironment> {
55
+ updateConfig(id: string, payload: ExecutionEnvironmentConfigUpdatePayload): Promise<ExecutionEnvironment> {
64
56
  return this.put(`/${id}/config`, {
65
57
  payload,
66
58
  });
package/src/GroupsApi.ts CHANGED
@@ -12,6 +12,8 @@ export interface GroupsQueryOptions {
12
12
  tags?: string[];
13
13
  limit?: number;
14
14
  offset?: number;
15
+ /** Only return groups usable in this project (org-wide + restricted-to-this-project). */
16
+ project?: string;
15
17
  [key: string]: string | string[] | number | undefined;
16
18
  }
17
19
 
@@ -5,6 +5,7 @@ import type {
5
5
  OAuthGrant,
6
6
  OAuthGrantListResponse,
7
7
  OAuthGrantRevokeResponse,
8
+ RevokeOAuthGrantQuery,
8
9
  } from '@vertesia/common';
9
10
 
10
11
  function toQueryRecord(query?: ListOAuthGrantsQuery): Record<string, string> | undefined {
@@ -32,7 +33,7 @@ export default class OAuthGrantsApi extends ApiTopic {
32
33
  return this.get(`/${grantId}`);
33
34
  }
34
35
 
35
- revoke(grantId: string, query?: { include_consent?: boolean }): Promise<OAuthGrantRevokeResponse> {
36
+ revoke(grantId: string, query?: RevokeOAuthGrantQuery): Promise<OAuthGrantRevokeResponse> {
36
37
  return this.del(`/${grantId}`, { query });
37
38
  }
38
39
 
@@ -30,7 +30,12 @@ export default class OAuthProvidersApi extends ApiTopic {
30
30
  return this.put(`/${id}`, { payload });
31
31
  }
32
32
 
33
- remove(id: string): Promise<void> {
33
+ /**
34
+ * `SuccessResponse`, not `void`: the endpoint has always returned `{ success: true }` and the
35
+ * document has always published it. Typing it as `void` meant a caller could not read the one
36
+ * field it sends without casting.
37
+ */
38
+ remove(id: string): Promise<SuccessResponse> {
34
39
  return this.del(`/${id}`);
35
40
  }
36
41
 
package/src/PromptsApi.ts CHANGED
@@ -1,29 +1,28 @@
1
- import type { PromptRole } from '@llumiverse/common';
2
1
  import { ApiTopic, type ClientBase } from '@vertesia/api-fetch-client';
3
2
  import type {
3
+ ComputedFacetResponse,
4
4
  ComputePromptFacetPayload,
5
5
  PromptSearchPayload,
6
6
  PromptSearchQuery,
7
7
  PromptTemplate,
8
8
  PromptTemplateCreatePayload,
9
9
  PromptTemplateForkPayload,
10
+ PromptTemplateInteractionsResponse,
10
11
  PromptTemplateRef,
11
12
  PromptTemplateUpdatePayload,
12
- TemplateType,
13
+ RenderPromptPayload,
14
+ RenderPromptResponse,
13
15
  } from '@vertesia/common';
14
16
 
15
- export interface ComputePromptFacetsResponse {
16
- role?: { _id: string; count: number }[];
17
- total?: { count: number }[];
18
- }
17
+ /**
18
+ * @deprecated Use `ComputedFacetResponse` from `@vertesia/common`, which is what the endpoint
19
+ * publishes. This restated the facet buckets in their pre-collapse shape: `total` is a number by
20
+ * the time the response is written, not a one-element array.
21
+ */
22
+ export type ComputePromptFacetsResponse = ComputedFacetResponse;
19
23
 
20
- export interface PromptRenderResponse {
21
- id: string;
22
- name: string;
23
- role: PromptRole;
24
- content_type: TemplateType;
25
- rendered: string;
26
- }
24
+ /** @deprecated Use `RenderPromptResponse` from `@vertesia/common`. */
25
+ export type PromptRenderResponse = RenderPromptResponse;
27
26
 
28
27
  export default class PromptsApi extends ApiTopic {
29
28
  constructor(parent: ClientBase) {
@@ -48,9 +47,9 @@ export default class PromptsApi extends ApiTopic {
48
47
  /**
49
48
  * Get the list of all prompt facets
50
49
  * @param payload query payload to filter facet search
51
- * @returns ComputePromptFacetsResponse[]
50
+ * @returns ComputedFacetResponse
52
51
  **/
53
- computeFacets(query: ComputePromptFacetPayload): Promise<ComputePromptFacetsResponse> {
52
+ computeFacets(query: ComputePromptFacetPayload): Promise<ComputedFacetResponse> {
54
53
  return this.post('/facets', {
55
54
  payload: query,
56
55
  });
@@ -125,7 +124,7 @@ export default class PromptsApi extends ApiTopic {
125
124
  * @throws 404 if not found
126
125
  * @throws 403 if the prompt is not in the current project
127
126
  */
128
- render(id: string, payload: object): Promise<PromptRenderResponse> {
127
+ render(id: string, payload: RenderPromptPayload): Promise<RenderPromptResponse> {
129
128
  return this.post(`/${id}/render`, {
130
129
  payload,
131
130
  });
@@ -153,7 +152,7 @@ export default class PromptsApi extends ApiTopic {
153
152
  /**
154
153
  * Retrieve list of interactions that use the prompt template
155
154
  */
156
- listInteractions(id: string): Promise<ListInteractionsResponse> {
155
+ listInteractions(id: string): Promise<PromptTemplateInteractionsResponse> {
157
156
  return this.get(`/${id}/interactions`);
158
157
  }
159
158
 
@@ -167,11 +166,9 @@ export default class PromptsApi extends ApiTopic {
167
166
  }
168
167
  }
169
168
 
170
- export interface ListInteractionsResponse {
171
- prompt: string;
172
- interactions: {
173
- id: string;
174
- name: string;
175
- versions: string[];
176
- }[];
177
- }
169
+ /**
170
+ * @deprecated Use `PromptTemplateInteractionsResponse` from `@vertesia/common`, which is what the
171
+ * endpoint publishes. This declared `versions: string[]`; the endpoint has always sent
172
+ * `[{ version: number }]`.
173
+ */
174
+ export type ListInteractionsResponse = PromptTemplateInteractionsResponse;
@@ -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
+ });