@coder/ai-sdk-agent 0.5.0 → 0.6.0

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/dist/index.js CHANGED
@@ -163,6 +163,20 @@ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
163
163
 
164
164
  // src/coder/ws.ts
165
165
  import NodeWebSocket from "ws";
166
+
167
+ // src/transport-events.ts
168
+ function safeTransportEmitter(handler) {
169
+ if (!handler) return void 0;
170
+ return (event) => {
171
+ try {
172
+ void Promise.resolve(handler(event)).catch(() => {
173
+ });
174
+ } catch {
175
+ }
176
+ };
177
+ }
178
+
179
+ // src/coder/ws.ts
166
180
  var defaultFactory = (url, { headers }) => {
167
181
  return new NodeWebSocket(url, { headers });
168
182
  };
@@ -228,6 +242,8 @@ function streamChatEvents(options) {
228
242
  async function* streamChatEventsLoop(options) {
229
243
  const { baseUrl, token, chatId, signal } = options;
230
244
  const factory = options.webSocketFactory ?? defaultFactory;
245
+ const emit = safeTransportEmitter(options.onTransportEvent);
246
+ let attempt = 0;
231
247
  const wsBase = httpToWs(baseUrl);
232
248
  const path = `/api/experimental/chats/${chatId}/stream`;
233
249
  const query = options.afterId !== void 0 ? `?after_id=${options.afterId}` : "";
@@ -246,11 +262,24 @@ async function* streamChatEventsLoop(options) {
246
262
  let finished = false;
247
263
  let failure;
248
264
  let parseFailure = false;
265
+ let closeEmitted = false;
249
266
  const wake = () => {
250
267
  resolveNext?.();
251
268
  resolveNext = void 0;
252
269
  };
253
- const ws = factory(url, { headers: { "Coder-Session-Token": token } });
270
+ attempt += 1;
271
+ emit?.({ type: "ws:dial", chatId, attempt, url, timestamp: Date.now() });
272
+ let ws;
273
+ try {
274
+ ws = factory(url, { headers: { "Coder-Session-Token": token } });
275
+ } catch (err) {
276
+ if (emit) {
277
+ const message = err instanceof Error ? err.message : String(err);
278
+ emit({ type: "ws:error", chatId, attempt, message, timestamp: Date.now() });
279
+ emit({ type: "ws:close", chatId, attempt, timestamp: Date.now() });
280
+ }
281
+ throw err;
282
+ }
254
283
  const onAbort = () => {
255
284
  finished = true;
256
285
  try {
@@ -274,6 +303,13 @@ async function* streamChatEventsLoop(options) {
274
303
  const data = typeof ev.data === "string" ? ev.data : String(ev.data);
275
304
  batch = JSON.parse(data);
276
305
  } catch (err) {
306
+ emit?.({
307
+ type: "ws:error",
308
+ chatId,
309
+ attempt,
310
+ message: "failed to parse chat stream frame",
311
+ timestamp: Date.now()
312
+ });
277
313
  failure = new CoderAgentError("failed to parse chat stream frame", { cause: err });
278
314
  parseFailure = true;
279
315
  finished = true;
@@ -281,24 +317,55 @@ async function* streamChatEventsLoop(options) {
281
317
  return;
282
318
  }
283
319
  if (Array.isArray(batch)) {
284
- for (const e of batch) queue.push(e);
320
+ for (const e of batch) {
321
+ queue.push(e);
322
+ emit?.({
323
+ type: "ws:event",
324
+ chatId,
325
+ attempt,
326
+ event: e,
327
+ timestamp: Date.now()
328
+ });
329
+ }
285
330
  } else if (batch && typeof batch === "object") {
286
331
  queue.push(batch);
332
+ emit?.({
333
+ type: "ws:event",
334
+ chatId,
335
+ attempt,
336
+ event: batch,
337
+ timestamp: Date.now()
338
+ });
287
339
  }
288
340
  wake();
289
341
  };
290
342
  const onError = (ev) => {
291
343
  if (finished) return;
292
344
  const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "chat stream socket error";
345
+ emit?.({ type: "ws:error", chatId, attempt, message, timestamp: Date.now() });
293
346
  const status = upgradeStatus(message);
294
347
  failure = status !== void 0 && status >= 400 && status < 500 && !TRANSIENT_UPGRADE_STATUSES.has(status) ? new CoderApiError({ status, method: "GET", path, message }) : new CoderAgentError(message);
295
348
  finished = true;
296
349
  wake();
297
350
  };
298
- const onClose = () => {
351
+ const onClose = (ev) => {
352
+ if (emit && !closeEmitted) {
353
+ closeEmitted = true;
354
+ const closeEvent = {
355
+ type: "ws:close",
356
+ chatId,
357
+ attempt,
358
+ timestamp: Date.now()
359
+ };
360
+ if (ev?.code !== void 0) closeEvent.code = ev.code;
361
+ if (ev?.reason !== void 0 && ev.reason !== "") closeEvent.reason = ev.reason;
362
+ emit(closeEvent);
363
+ }
299
364
  finished = true;
300
365
  wake();
301
366
  };
367
+ const onOpen = emit ? () => emit({ type: "ws:open", chatId, attempt, timestamp: Date.now() }) : void 0;
368
+ if (onOpen) ws.addEventListener("open", onOpen);
302
369
  ws.addEventListener("message", onMessage);
303
370
  ws.addEventListener("error", onError);
304
371
  ws.addEventListener("close", onClose);
@@ -344,6 +411,7 @@ async function* streamChatEventsLoop(options) {
344
411
  } finally {
345
412
  finished = true;
346
413
  if (signal && abortListenerAdded) signal.removeEventListener("abort", onAbort);
414
+ if (onOpen) ws.removeEventListener("open", onOpen);
347
415
  ws.removeEventListener("message", onMessage);
348
416
  ws.removeEventListener("error", onError);
349
417
  ws.removeEventListener("close", onClose);
@@ -351,6 +419,10 @@ async function* streamChatEventsLoop(options) {
351
419
  ws.close(1e3);
352
420
  } catch {
353
421
  }
422
+ if (emit && !closeEmitted) {
423
+ closeEmitted = true;
424
+ emit({ type: "ws:close", chatId, attempt, timestamp: Date.now() });
425
+ }
354
426
  }
355
427
  if (signal?.aborted) return;
356
428
  if (failure instanceof CoderApiError) throw failure;
@@ -371,6 +443,15 @@ async function* streamChatEventsLoop(options) {
371
443
  cause: lastFailure
372
444
  });
373
445
  }
446
+ emit?.({
447
+ type: "ws:redial",
448
+ chatId,
449
+ attempt,
450
+ consecutiveFailures: failures,
451
+ maxConsecutiveFailures: STREAM_MAX_CONSECUTIVE_FAILURES,
452
+ backoffMs,
453
+ timestamp: Date.now()
454
+ });
374
455
  await sleep(backoffMs, signal);
375
456
  if (signal?.aborted) return;
376
457
  backoffMs = Math.min(backoffMs * 2, STREAM_BACKOFF_CAP_MS);
@@ -554,11 +635,18 @@ var CoderChatClient = class {
554
635
  #token;
555
636
  #fetch;
556
637
  #webSocketFactory;
638
+ /** The raw subscriber, forwarded to stream readers (which wrap it themselves). */
639
+ #onTransportEvent;
640
+ /** Exception-isolated emitter for this client's own HTTP events. */
641
+ #emitTransportEvent;
642
+ #httpSeq = 0;
557
643
  constructor(options) {
558
644
  this.baseUrl = options.baseUrl.replace(/\/$/, "");
559
645
  this.#token = options.token;
560
646
  this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
561
647
  this.#webSocketFactory = options.webSocketFactory;
648
+ this.#onTransportEvent = options.onTransportEvent;
649
+ this.#emitTransportEvent = safeTransportEmitter(options.onTransportEvent);
562
650
  }
563
651
  /**
564
652
  * Issue a request and return the raw `Response` on success (body unread). On a
@@ -574,7 +662,39 @@ var CoderChatClient = class {
574
662
  if (typeof ReadableStream !== "undefined" && opts?.body instanceof ReadableStream) {
575
663
  init.duplex = "half";
576
664
  }
577
- const res = await this.#fetch(`${this.baseUrl}${path}`, init);
665
+ const emit = this.#emitTransportEvent;
666
+ let requestId = 0;
667
+ let startedAt = 0;
668
+ if (emit) {
669
+ requestId = ++this.#httpSeq;
670
+ startedAt = performance.now();
671
+ emit({ type: "http:request", id: requestId, method, path, timestamp: Date.now() });
672
+ }
673
+ let res;
674
+ try {
675
+ res = await this.#fetch(`${this.baseUrl}${path}`, init);
676
+ } catch (err) {
677
+ emit?.({
678
+ type: "http:error",
679
+ id: requestId,
680
+ method,
681
+ path,
682
+ message: err instanceof Error ? err.message : String(err),
683
+ durationMs: performance.now() - startedAt,
684
+ timestamp: Date.now()
685
+ });
686
+ throw err;
687
+ }
688
+ emit?.({
689
+ type: "http:response",
690
+ id: requestId,
691
+ method,
692
+ path,
693
+ status: res.status,
694
+ ok: res.ok,
695
+ durationMs: performance.now() - startedAt,
696
+ timestamp: Date.now()
697
+ });
578
698
  if (!res.ok) {
579
699
  const errObj = await this.#json(res) ?? {};
580
700
  throw new CoderApiError({
@@ -742,7 +862,8 @@ var CoderChatClient = class {
742
862
  chatId,
743
863
  afterId: opts?.afterId,
744
864
  signal: opts?.signal,
745
- webSocketFactory: this.#webSocketFactory
865
+ webSocketFactory: this.#webSocketFactory,
866
+ onTransportEvent: this.#onTransportEvent
746
867
  });
747
868
  }
748
869
  /**
@@ -1543,11 +1664,15 @@ var CoderLanguageModel = class {
1543
1664
  #inFlight = false;
1544
1665
  /** The turn-lifetime event stream, retained across client-tool pauses. */
1545
1666
  #retainedStream;
1667
+ /** Exception-isolated observability emitter for `segment:*` events. */
1668
+ #emitTransportEvent;
1669
+ #segmentSeq = 0;
1546
1670
  constructor(config) {
1547
1671
  assertSupportedAiVersion();
1548
1672
  this.#config = config;
1549
1673
  this.modelId = config.model ?? "chatd";
1550
1674
  this.#chatId = config.chatId;
1675
+ this.#emitTransportEvent = safeTransportEmitter(config.onTransportEvent);
1551
1676
  }
1552
1677
  get chatId() {
1553
1678
  return this.#chatId;
@@ -1707,7 +1832,93 @@ var CoderLanguageModel = class {
1707
1832
  });
1708
1833
  return events;
1709
1834
  }
1710
- async *#runTurn(options) {
1835
+ /**
1836
+ * Wraps {@link #runTurnInner} with `segment:start`/`segment:settle`
1837
+ * observability events. A pure pass-through without a subscriber — no
1838
+ * counters, clocks, or event objects on the unobserved path. The settle
1839
+ * event is emitted in a `finally`, so every started segment settles exactly
1840
+ * once: cleanly (with the terminal status carried by the `finish` part that
1841
+ * streamed through), with an `error` summary, or — when the consumer tears
1842
+ * the stream down mid-segment — with neither. Consumer teardown has two
1843
+ * shapes: a plain generator return (no catch runs), and a
1844
+ * `ReadableStream.cancel()` that {@link doStream} routes through an abort to
1845
+ * unblock a pending socket read — the latter surfaces here as an abort
1846
+ * throw, so `isConsumerCancel` (supplied by `doStream`) reclassifies it as
1847
+ * teardown rather than a failed segment.
1848
+ *
1849
+ * Two more classification wrinkles (review findings):
1850
+ * - a streamed server `error` part is captured BEFORE being yielded —
1851
+ * `doGenerate()` throws on receiving it and closes this generator without
1852
+ * another pull, so the catch below never sees that failure;
1853
+ * - the segment's chat id is tracked in `segmentChat` (written by
1854
+ * {@link #runTurnInner} once known) — a retry-safe stream failure on a
1855
+ * freshly created chat discards the session (clearing `this.#chatId`)
1856
+ * before the finally runs, and the settle must still name the chat that
1857
+ * the preceding `ws:*`/`http:*` events identified.
1858
+ */
1859
+ async *#runTurn(options, isConsumerCancel) {
1860
+ const emit = this.#emitTransportEvent;
1861
+ if (!emit) {
1862
+ yield* this.#runTurnInner(options);
1863
+ return;
1864
+ }
1865
+ const segment = ++this.#segmentSeq;
1866
+ const startedAt = performance.now();
1867
+ const startEvent = {
1868
+ type: "segment:start",
1869
+ segment,
1870
+ timestamp: Date.now()
1871
+ };
1872
+ if (this.#chatId !== void 0) startEvent.chatId = this.#chatId;
1873
+ emit(startEvent);
1874
+ const segmentChat = {};
1875
+ let finishReason;
1876
+ let failure;
1877
+ let failed = false;
1878
+ try {
1879
+ for await (const part of this.#runTurnInner(options, segmentChat)) {
1880
+ if (part.type === "finish") finishReason = part.finishReason;
1881
+ else if (part.type === "error" && !failed) {
1882
+ failure = part.error;
1883
+ failed = true;
1884
+ }
1885
+ yield part;
1886
+ }
1887
+ } catch (err) {
1888
+ if (!isConsumerCancel?.()) {
1889
+ failure = err;
1890
+ failed = true;
1891
+ }
1892
+ throw err;
1893
+ } finally {
1894
+ const settleEvent = {
1895
+ type: "segment:settle",
1896
+ segment,
1897
+ durationMs: performance.now() - startedAt,
1898
+ timestamp: Date.now()
1899
+ };
1900
+ const chatId = segmentChat.chatId ?? this.#chatId;
1901
+ if (chatId !== void 0) settleEvent.chatId = chatId;
1902
+ if (!failed && finishReason === void 0 && options.abortSignal?.aborted) {
1903
+ if (!isConsumerCancel?.()) {
1904
+ failure = options.abortSignal.reason;
1905
+ failed = true;
1906
+ }
1907
+ }
1908
+ if (failed) {
1909
+ settleEvent.error = {
1910
+ name: failure instanceof Error ? failure.name : "Error",
1911
+ message: failure instanceof Error ? failure.message : String(failure)
1912
+ };
1913
+ }
1914
+ if (finishReason !== void 0) {
1915
+ if (finishReason.raw !== void 0) settleEvent.status = finishReason.raw;
1916
+ if (!failed) settleEvent.finishReason = finishReason.unified;
1917
+ }
1918
+ emit(settleEvent);
1919
+ }
1920
+ }
1921
+ async *#runTurnInner(options, segmentChat) {
1711
1922
  if (this.#inFlight) {
1712
1923
  throw new CoderAgentError(
1713
1924
  "A generation is already in flight on this CoderAgent (single-flight). Use a separate CoderAgent instance for concurrent sessions."
@@ -1826,6 +2037,7 @@ var CoderLanguageModel = class {
1826
2037
  }
1827
2038
  const chatId = this.#chatId;
1828
2039
  turnChatId = chatId;
2040
+ if (segmentChat) segmentChat.chatId = chatId;
1829
2041
  const dynamicNames = dynamicToolNames(options.tools);
1830
2042
  translator = new TurnTranslator({
1831
2043
  dynamicToolNames: dynamicNames,
@@ -1968,7 +2180,8 @@ var CoderLanguageModel = class {
1968
2180
  async doStream(options) {
1969
2181
  const cancelController = new AbortController();
1970
2182
  const abortSignal = options.abortSignal ? AbortSignal.any([options.abortSignal, cancelController.signal]) : cancelController.signal;
1971
- const gen = this.#runTurn({ ...options, abortSignal });
2183
+ let consumerCancelled = false;
2184
+ const gen = this.#runTurn({ ...options, abortSignal }, () => consumerCancelled);
1972
2185
  const stream = new ReadableStream({
1973
2186
  async pull(controller) {
1974
2187
  try {
@@ -1981,6 +2194,7 @@ var CoderLanguageModel = class {
1981
2194
  }
1982
2195
  },
1983
2196
  async cancel() {
2197
+ consumerCancelled = !options.abortSignal?.aborted;
1984
2198
  cancelController.abort();
1985
2199
  await gen.return().catch(() => {
1986
2200
  });
@@ -2131,7 +2345,8 @@ var CoderAgent = class {
2131
2345
  baseUrl: settings.baseUrl,
2132
2346
  token: settings.token,
2133
2347
  fetch: settings.fetch,
2134
- webSocketFactory: settings.webSocketFactory
2348
+ webSocketFactory: settings.webSocketFactory,
2349
+ onTransportEvent: settings.onTransportEvent
2135
2350
  });
2136
2351
  } else {
2137
2352
  throw new CoderAgentError(
@@ -2157,7 +2372,8 @@ var CoderAgent = class {
2157
2372
  mcpServerIds: settings.mcpServerIds,
2158
2373
  planMode: settings.planMode,
2159
2374
  chatId: settings.chatId,
2160
- requestTimeoutMs: settings.requestTimeoutMs
2375
+ requestTimeoutMs: settings.requestTimeoutMs,
2376
+ onTransportEvent: settings.onTransportEvent
2161
2377
  });
2162
2378
  this.#inner = new ToolLoopAgent({
2163
2379
  model: this.#model,