@open-mercato/ai-assistant 0.4.11-develop.2502.29119c6047 → 0.4.11-develop.2525.b7ff53cce2

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.
@@ -0,0 +1,76 @@
1
+ import { OpenCodeClient } from '../opencode-client'
2
+
3
+ describe('OpenCodeClient.subscribeToEvents', () => {
4
+ const originalFetch = global.fetch
5
+ let originalSseEnv: string | undefined
6
+
7
+ beforeEach(() => {
8
+ originalSseEnv = process.env.OPENCODE_SSE_CONNECT_TIMEOUT_MS
9
+ process.env.OPENCODE_SSE_CONNECT_TIMEOUT_MS = '25'
10
+ })
11
+
12
+ afterEach(() => {
13
+ global.fetch = originalFetch
14
+ if (originalSseEnv === undefined) {
15
+ delete process.env.OPENCODE_SSE_CONNECT_TIMEOUT_MS
16
+ } else {
17
+ process.env.OPENCODE_SSE_CONNECT_TIMEOUT_MS = originalSseEnv
18
+ }
19
+ })
20
+
21
+ const waitFor = async (predicate: () => boolean, timeoutMs = 500): Promise<void> => {
22
+ const deadline = Date.now() + timeoutMs
23
+ while (!predicate()) {
24
+ if (Date.now() > deadline) throw new Error('waitFor: predicate never became true')
25
+ await new Promise((r) => setTimeout(r, 10))
26
+ }
27
+ }
28
+
29
+ it('invokes onError with the connect-timeout reason when the deadline elapses before the SSE handshake', async () => {
30
+ global.fetch = jest.fn((_input, init) => {
31
+ return new Promise((_resolve, reject) => {
32
+ const signal = (init as RequestInit | undefined)?.signal
33
+ if (!signal) return
34
+ signal.addEventListener('abort', () => {
35
+ const err = new Error('Aborted')
36
+ ;(err as Error & { name: string }).name = 'AbortError'
37
+ reject(err)
38
+ })
39
+ })
40
+ }) as unknown as typeof fetch
41
+
42
+ const client = new OpenCodeClient({ baseUrl: 'http://opencode.local' })
43
+ const onError = jest.fn()
44
+
45
+ client.subscribeToEvents(() => {}, onError)
46
+
47
+ await waitFor(() => onError.mock.calls.length > 0)
48
+
49
+ expect(onError).toHaveBeenCalledTimes(1)
50
+ const err = onError.mock.calls[0][0] as Error
51
+ expect(err.message).toMatch(/OpenCode SSE connection timed out after \d+ms/)
52
+ })
53
+
54
+ it('does not invoke onError when the caller disposes the stream before it connects', async () => {
55
+ global.fetch = jest.fn((_input, init) => {
56
+ return new Promise((_resolve, reject) => {
57
+ const signal = (init as RequestInit | undefined)?.signal
58
+ if (!signal) return
59
+ signal.addEventListener('abort', () => {
60
+ const err = new Error('Aborted')
61
+ ;(err as Error & { name: string }).name = 'AbortError'
62
+ reject(err)
63
+ })
64
+ })
65
+ }) as unknown as typeof fetch
66
+
67
+ const client = new OpenCodeClient({ baseUrl: 'http://opencode.local' })
68
+ const onError = jest.fn()
69
+
70
+ const dispose = client.subscribeToEvents(() => {}, onError)
71
+ dispose()
72
+ await new Promise((r) => setTimeout(r, 10))
73
+
74
+ expect(onError).not.toHaveBeenCalled()
75
+ })
76
+ })
@@ -16,6 +16,15 @@ import {
16
16
  searchEndpoints,
17
17
  simplifyRequestBodySchema,
18
18
  } from './api-endpoint-index'
19
+ import { fetchWithTimeout, resolveTimeoutMs } from '@open-mercato/shared/lib/http/fetchWithTimeout'
20
+
21
+ const DEFAULT_AI_API_REQUEST_TIMEOUT_MS = 30_000
22
+
23
+ function resolveAiApiRequestTimeoutMs(): number {
24
+ const raw = process.env.AI_API_REQUEST_TIMEOUT_MS
25
+ const parsed = raw ? Number.parseInt(raw, 10) : undefined
26
+ return resolveTimeoutMs(parsed, DEFAULT_AI_API_REQUEST_TIMEOUT_MS)
27
+ }
19
28
 
20
29
  /**
21
30
  * Load API discovery tools into the registry
@@ -194,10 +203,11 @@ Confirm with user before POST/PUT/DELETE operations.`,
194
203
 
195
204
  // Execute request
196
205
  try {
197
- const response = await fetch(url, {
206
+ const response = await fetchWithTimeout(url, {
198
207
  method,
199
208
  headers,
200
209
  body: requestBody ? JSON.stringify(requestBody) : undefined,
210
+ timeoutMs: resolveAiApiRequestTimeoutMs(),
201
211
  })
202
212
 
203
213
  const responseText = await response.text()
@@ -9,6 +9,7 @@ import { buildOpenApiDocument } from '@open-mercato/shared/lib/openapi'
9
9
  import type { Module } from '@open-mercato/shared/modules/registry'
10
10
  import type { SearchService } from '@open-mercato/search/service'
11
11
  import type { IndexableRecord } from '@open-mercato/search/types'
12
+ import { fetchWithTimeout, resolveTimeoutMs } from '@open-mercato/shared/lib/http/fetchWithTimeout'
12
13
  import {
13
14
  API_ENDPOINT_ENTITY_ID,
14
15
  GLOBAL_TENANT_ID,
@@ -17,6 +18,14 @@ import {
17
18
  computeEndpointsChecksum,
18
19
  } from './api-endpoint-index-config'
19
20
 
21
+ const DEFAULT_OPENAPI_FETCH_TIMEOUT_MS = 10_000
22
+
23
+ function resolveOpenapiFetchTimeoutMs(): number {
24
+ const raw = process.env.AI_OPENAPI_FETCH_TIMEOUT_MS
25
+ const parsed = raw ? Number.parseInt(raw, 10) : undefined
26
+ return resolveTimeoutMs(parsed, DEFAULT_OPENAPI_FETCH_TIMEOUT_MS)
27
+ }
28
+
20
29
  /**
21
30
  * Indexed API endpoint structure
22
31
  */
@@ -208,7 +217,9 @@ async function loadRawOpenApiSpec(): Promise<OpenApiDocument | null> {
208
217
  'http://localhost:3000'
209
218
 
210
219
  try {
211
- const response = await fetch(`${baseUrl}/api/docs/openapi`)
220
+ const response = await fetchWithTimeout(`${baseUrl}/api/docs/openapi`, {
221
+ timeoutMs: resolveOpenapiFetchTimeoutMs(),
222
+ })
212
223
  if (response.ok) {
213
224
  const doc = (await response.json()) as OpenApiDocument
214
225
  console.error('[API Index] Raw OpenAPI spec fetched via HTTP')
@@ -320,7 +331,9 @@ async function parseApiEndpointsFromHttp(): Promise<ApiEndpoint[]> {
320
331
 
321
332
  try {
322
333
  console.error(`[API Index] Fetching OpenAPI spec from ${openApiUrl}...`)
323
- const response = await fetch(openApiUrl)
334
+ const response = await fetchWithTimeout(openApiUrl, {
335
+ timeoutMs: resolveOpenapiFetchTimeoutMs(),
336
+ })
324
337
 
325
338
  if (!response.ok) {
326
339
  console.error(`[API Index] Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`)
@@ -28,6 +28,15 @@ import {
28
28
  buildSearchLabel,
29
29
  incrementToolCallCount,
30
30
  } from './session-memory'
31
+ import { fetchWithTimeout, resolveTimeoutMs } from '@open-mercato/shared/lib/http/fetchWithTimeout'
32
+
33
+ const DEFAULT_AI_API_REQUEST_TIMEOUT_MS = 30_000
34
+
35
+ function resolveAiApiRequestTimeoutMs(): number {
36
+ const raw = process.env.AI_API_REQUEST_TIMEOUT_MS
37
+ const parsed = raw ? Number.parseInt(raw, 10) : undefined
38
+ return resolveTimeoutMs(parsed, DEFAULT_AI_API_REQUEST_TIMEOUT_MS)
39
+ }
31
40
 
32
41
  /**
33
42
  * Cached spec object combining OpenAPI paths + entity schemas.
@@ -823,10 +832,11 @@ function createApiRequestFn(
823
832
  if (ctx.organizationId) headers['X-Organization-Id'] = ctx.organizationId
824
833
 
825
834
  // Execute request using host fetch (not sandbox)
826
- const response = await globalThis.fetch(url, {
835
+ const response = await fetchWithTimeout(url, {
827
836
  method: normalizedMethod,
828
837
  headers,
829
838
  body: requestBody ? JSON.stringify(requestBody) : undefined,
839
+ timeoutMs: resolveAiApiRequestTimeoutMs(),
830
840
  })
831
841
 
832
842
  const responseText = await response.text()
@@ -5,6 +5,16 @@
5
5
  * OpenCode is used as an AI agent that can execute MCP tools.
6
6
  */
7
7
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
8
+ import { fetchWithTimeout, resolveTimeoutMs } from '@open-mercato/shared/lib/http/fetchWithTimeout'
9
+
10
+ const DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS = 30_000
11
+ const DEFAULT_OPENCODE_SSE_CONNECT_TIMEOUT_MS = 15_000
12
+
13
+ function resolveOpencodeTimeoutMs(envVar: string, fallback: number): number {
14
+ const raw = process.env[envVar]
15
+ const parsed = raw ? Number.parseInt(raw, 10) : undefined
16
+ return resolveTimeoutMs(parsed, fallback)
17
+ }
8
18
 
9
19
  export type OpenCodeClientConfig = {
10
20
  baseUrl: string
@@ -132,6 +142,22 @@ export class OpenCodeClient {
132
142
  onError?: (error: Error) => void
133
143
  ): () => void {
134
144
  const controller = new AbortController()
145
+ const connectTimeoutMs = resolveOpencodeTimeoutMs(
146
+ 'OPENCODE_SSE_CONNECT_TIMEOUT_MS',
147
+ DEFAULT_OPENCODE_SSE_CONNECT_TIMEOUT_MS,
148
+ )
149
+ const connectTimeoutReason = new Error(
150
+ `OpenCode SSE connection timed out after ${connectTimeoutMs}ms`,
151
+ )
152
+ let connectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
153
+ controller.abort(connectTimeoutReason)
154
+ }, connectTimeoutMs)
155
+ const clearConnectTimer = () => {
156
+ if (connectTimer !== null) {
157
+ clearTimeout(connectTimer)
158
+ connectTimer = null
159
+ }
160
+ }
135
161
 
136
162
  const connect = async () => {
137
163
  try {
@@ -142,6 +168,7 @@ export class OpenCodeClient {
142
168
  },
143
169
  signal: controller.signal,
144
170
  })
171
+ clearConnectTimer()
145
172
 
146
173
  if (!res.ok || !res.body) {
147
174
  throw new Error(`SSE connection failed: ${res.status}`)
@@ -173,23 +200,33 @@ export class OpenCodeClient {
173
200
  }
174
201
  }
175
202
  } catch (error) {
176
- if ((error as Error).name !== 'AbortError') {
177
- onError?.(error as Error)
203
+ clearConnectTimer()
204
+ if ((error as Error).name === 'AbortError') {
205
+ const reason = (controller.signal as AbortSignal & { reason?: unknown }).reason
206
+ if (reason === connectTimeoutReason) {
207
+ onError?.(connectTimeoutReason)
208
+ }
209
+ return
178
210
  }
211
+ onError?.(error as Error)
179
212
  }
180
213
  }
181
214
 
182
215
  connect()
183
216
 
184
- return () => controller.abort()
217
+ return () => {
218
+ clearConnectTimer()
219
+ controller.abort()
220
+ }
185
221
  }
186
222
 
187
223
  /**
188
224
  * Check OpenCode server health.
189
225
  */
190
226
  async health(): Promise<OpenCodeHealth> {
191
- const res = await fetch(`${this.baseUrl}/global/health`, {
227
+ const res = await fetchWithTimeout(`${this.baseUrl}/global/health`, {
192
228
  headers: this.headers,
229
+ timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS),
193
230
  })
194
231
 
195
232
  if (!res.ok) {
@@ -205,8 +242,9 @@ export class OpenCodeClient {
205
242
  * Get MCP server connection status.
206
243
  */
207
244
  async mcpStatus(): Promise<OpenCodeMcpStatus> {
208
- const res = await fetch(`${this.baseUrl}/mcp`, {
245
+ const res = await fetchWithTimeout(`${this.baseUrl}/mcp`, {
209
246
  headers: this.headers,
247
+ timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS),
210
248
  })
211
249
 
212
250
  if (!res.ok) {
@@ -222,10 +260,11 @@ export class OpenCodeClient {
222
260
  * Create a new conversation session.
223
261
  */
224
262
  async createSession(): Promise<OpenCodeSession> {
225
- const res = await fetch(`${this.baseUrl}/session`, {
263
+ const res = await fetchWithTimeout(`${this.baseUrl}/session`, {
226
264
  method: 'POST',
227
265
  headers: this.headers,
228
266
  body: JSON.stringify({}),
267
+ timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS),
229
268
  })
230
269
 
231
270
  if (!res.ok) {
@@ -242,8 +281,9 @@ export class OpenCodeClient {
242
281
  * Get an existing session by ID.
243
282
  */
244
283
  async getSession(sessionId: string): Promise<OpenCodeSession> {
245
- const res = await fetch(`${this.baseUrl}/session/${sessionId}`, {
284
+ const res = await fetchWithTimeout(`${this.baseUrl}/session/${sessionId}`, {
246
285
  headers: this.headers,
286
+ timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS),
247
287
  })
248
288
 
249
289
  if (!res.ok) {
@@ -273,10 +313,14 @@ export class OpenCodeClient {
273
313
  body.model = options.model
274
314
  }
275
315
 
276
- const res = await fetch(`${this.baseUrl}/session/${sessionId}/message`, {
316
+ const res = await fetchWithTimeout(`${this.baseUrl}/session/${sessionId}/message`, {
277
317
  method: 'POST',
278
318
  headers: this.headers,
279
319
  body: JSON.stringify(body),
320
+ timeoutMs: resolveOpencodeTimeoutMs(
321
+ 'OPENCODE_SEND_MESSAGE_TIMEOUT_MS',
322
+ resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS),
323
+ ),
280
324
  })
281
325
 
282
326
  if (!res.ok) {
@@ -293,10 +337,11 @@ export class OpenCodeClient {
293
337
  * Set authentication credentials for a provider.
294
338
  */
295
339
  async setAuth(providerId: string, apiKey: string): Promise<void> {
296
- const res = await fetch(`${this.baseUrl}/auth/${providerId}`, {
340
+ const res = await fetchWithTimeout(`${this.baseUrl}/auth/${providerId}`, {
297
341
  method: 'PUT',
298
342
  headers: this.headers,
299
343
  body: JSON.stringify({ type: 'api', key: apiKey }),
344
+ timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS),
300
345
  })
301
346
 
302
347
  if (!res.ok) {
@@ -308,8 +353,9 @@ export class OpenCodeClient {
308
353
  * Get current configuration.
309
354
  */
310
355
  async getConfig(): Promise<Record<string, unknown>> {
311
- const res = await fetch(`${this.baseUrl}/config`, {
356
+ const res = await fetchWithTimeout(`${this.baseUrl}/config`, {
312
357
  headers: this.headers,
358
+ timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS),
313
359
  })
314
360
 
315
361
  if (!res.ok) {
@@ -325,8 +371,9 @@ export class OpenCodeClient {
325
371
  * Get pending questions that need user response.
326
372
  */
327
373
  async getPendingQuestions(): Promise<OpenCodeQuestion[]> {
328
- const res = await fetch(`${this.baseUrl}/question`, {
374
+ const res = await fetchWithTimeout(`${this.baseUrl}/question`, {
329
375
  headers: this.headers,
376
+ timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS),
330
377
  })
331
378
 
332
379
  if (!res.ok) {
@@ -366,10 +413,11 @@ export class OpenCodeClient {
366
413
 
367
414
  console.log('[OpenCode Client] Answering question', questionId, 'with body:', JSON.stringify(body))
368
415
 
369
- const res = await fetch(`${this.baseUrl}/question/${questionId}/reply`, {
416
+ const res = await fetchWithTimeout(`${this.baseUrl}/question/${questionId}/reply`, {
370
417
  method: 'POST',
371
418
  headers: this.headers,
372
419
  body: JSON.stringify(body),
420
+ timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS),
373
421
  })
374
422
 
375
423
  const responseText = await res.text()
@@ -386,9 +434,10 @@ export class OpenCodeClient {
386
434
  async rejectQuestion(questionId: string): Promise<void> {
387
435
  console.log('[OpenCode Client] Rejecting question', questionId)
388
436
 
389
- const res = await fetch(`${this.baseUrl}/question/${questionId}/reject`, {
437
+ const res = await fetchWithTimeout(`${this.baseUrl}/question/${questionId}/reject`, {
390
438
  method: 'POST',
391
439
  headers: this.headers,
440
+ timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS),
392
441
  })
393
442
 
394
443
  if (!res.ok) {
@@ -403,8 +452,9 @@ export class OpenCodeClient {
403
452
  */
404
453
  async getSessionStatus(sessionId: string): Promise<{ status: string; questionId?: string }> {
405
454
  try {
406
- const res = await fetch(`${this.baseUrl}/session/${sessionId}/status`, {
455
+ const res = await fetchWithTimeout(`${this.baseUrl}/session/${sessionId}/status`, {
407
456
  headers: this.headers,
457
+ timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS),
408
458
  })
409
459
 
410
460
  if (res.ok) {