@coder/ai-sdk-agent 0.4.3 → 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
  /**
@@ -1016,6 +1137,95 @@ function dynamicToolNames(tools) {
1016
1137
  return names;
1017
1138
  }
1018
1139
 
1140
+ // src/model/session-stream.ts
1141
+ var SessionChatStream = class {
1142
+ chatId;
1143
+ #events;
1144
+ #controller = new AbortController();
1145
+ #pending;
1146
+ #attached = true;
1147
+ #reusable = false;
1148
+ #closePromise;
1149
+ constructor(opts) {
1150
+ this.chatId = opts.chatId;
1151
+ this.#events = opts.open(this.#controller.signal);
1152
+ }
1153
+ get closed() {
1154
+ return this.#closePromise !== void 0;
1155
+ }
1156
+ /** Whether a segment is currently consuming this stream. */
1157
+ get attached() {
1158
+ return this.#attached;
1159
+ }
1160
+ /** Whether a resume segment for `chatId` may {@link attach}. */
1161
+ canAttach(chatId) {
1162
+ return !this.closed && this.#reusable && this.chatId === chatId;
1163
+ }
1164
+ /**
1165
+ * The next event, memoized: repeated calls return the SAME promise until
1166
+ * {@link consumed} acknowledges delivery, so a read abandoned by one segment
1167
+ * is handed to the next instead of losing its event.
1168
+ */
1169
+ read() {
1170
+ if (!this.#pending) {
1171
+ if (!this.#attached && !this.closed) {
1172
+ throw new CoderAgentError("SessionChatStream.read() without an attached segment");
1173
+ }
1174
+ this.#pending = this.#events.next();
1175
+ this.#pending.catch(() => {
1176
+ });
1177
+ }
1178
+ return this.#pending;
1179
+ }
1180
+ /**
1181
+ * Acknowledge that the segment received `p`'s result and the next
1182
+ * {@link read} should issue a fresh read. A no-op unless `p` is the
1183
+ * outstanding read — a raced-out segment never consumes.
1184
+ */
1185
+ consumed(p) {
1186
+ if (this.#pending === p) this.#pending = void 0;
1187
+ }
1188
+ /** Attach the resume segment to a stream paused at `requires_action`. */
1189
+ attach() {
1190
+ if (!this.canAttach(this.chatId) || this.#attached) {
1191
+ throw new CoderAgentError("SessionChatStream.attach() on a non-reusable stream");
1192
+ }
1193
+ this.#attached = true;
1194
+ this.#reusable = false;
1195
+ }
1196
+ /**
1197
+ * Segment settled at a healthy `requires_action` pause: keep the stream for
1198
+ * the tool-result resume, with a read outstanding so the reader's redial
1199
+ * machinery stays live while the client tools execute.
1200
+ */
1201
+ pause() {
1202
+ this.#attached = false;
1203
+ if (this.closed) return;
1204
+ this.#reusable = true;
1205
+ if (!this.#pending) {
1206
+ this.#pending = this.#events.next();
1207
+ this.#pending.catch(() => {
1208
+ });
1209
+ }
1210
+ }
1211
+ /**
1212
+ * Close the underlying reader (and socket). Idempotent; safe on a stream
1213
+ * whose generator already ended or threw.
1214
+ */
1215
+ close() {
1216
+ this.#closePromise ??= (async () => {
1217
+ this.#attached = false;
1218
+ this.#reusable = false;
1219
+ this.#controller.abort();
1220
+ try {
1221
+ await this.#events.return(void 0);
1222
+ } catch {
1223
+ }
1224
+ })();
1225
+ return this.#closePromise;
1226
+ }
1227
+ };
1228
+
1019
1229
  // src/model/translate.ts
1020
1230
  function addDefined(a, b) {
1021
1231
  if (b === void 0) return a;
@@ -1075,6 +1285,7 @@ function jsonResult(value) {
1075
1285
  }
1076
1286
  var TurnTranslator = class {
1077
1287
  #dynamicToolNames;
1288
+ #submittedToolCallIds;
1078
1289
  #seq = 0;
1079
1290
  #text = { id: void 0, len: 0, sawDelta: false };
1080
1291
  #reasoning = { id: void 0, len: 0, sawDelta: false };
@@ -1111,6 +1322,7 @@ var TurnTranslator = class {
1111
1322
  constructor(opts) {
1112
1323
  this.#dynamicToolNames = opts.dynamicToolNames;
1113
1324
  this.#turnCursor = opts.turnCursor ?? 0;
1325
+ this.#submittedToolCallIds = opts.submittedToolCallIds ?? /* @__PURE__ */ new Set();
1114
1326
  }
1115
1327
  get terminalStatus() {
1116
1328
  return this.#terminalStatus;
@@ -1260,6 +1472,7 @@ var TurnTranslator = class {
1260
1472
  case "action_required":
1261
1473
  for (const tc of ev.action_required?.tool_calls ?? []) {
1262
1474
  if (this.#clientToolCalls.has(tc.tool_call_id)) continue;
1475
+ if (this.#submittedToolCallIds.has(tc.tool_call_id)) continue;
1263
1476
  this.#closeText(out);
1264
1477
  this.#closeReasoning(out);
1265
1478
  this.#clientToolCalls.add(tc.tool_call_id);
@@ -1281,8 +1494,12 @@ var TurnTranslator = class {
1281
1494
  }
1282
1495
  break;
1283
1496
  case "status":
1284
- if (ev.status && TERMINAL_STATUSES.has(ev.status.status)) {
1285
- this.#terminalStatus = ev.status.status;
1497
+ if (ev.status) {
1498
+ if (TERMINAL_STATUSES.has(ev.status.status)) {
1499
+ this.#terminalStatus = ev.status.status;
1500
+ } else {
1501
+ this.#terminalStatus = void 0;
1502
+ }
1286
1503
  }
1287
1504
  break;
1288
1505
  default:
@@ -1416,6 +1633,7 @@ var TurnTranslator = class {
1416
1633
  // src/model/language-model.ts
1417
1634
  var ACTION_REQUIRED_GRACE_MS = 2e3;
1418
1635
  var GRACE_EXPIRED = /* @__PURE__ */ Symbol("action-required-grace-expired");
1636
+ var SEGMENT_ABORTED = /* @__PURE__ */ Symbol("segment-aborted");
1419
1637
  function startGraceTimer(ms) {
1420
1638
  let timer;
1421
1639
  const expired = new Promise((resolve) => {
@@ -1444,20 +1662,90 @@ var CoderLanguageModel = class {
1444
1662
  #modelResolved = false;
1445
1663
  #submittedToolCallIds = /* @__PURE__ */ new Set();
1446
1664
  #inFlight = false;
1665
+ /** The turn-lifetime event stream, retained across client-tool pauses. */
1666
+ #retainedStream;
1667
+ /** Exception-isolated observability emitter for `segment:*` events. */
1668
+ #emitTransportEvent;
1669
+ #segmentSeq = 0;
1447
1670
  constructor(config) {
1448
1671
  assertSupportedAiVersion();
1449
1672
  this.#config = config;
1450
1673
  this.modelId = config.model ?? "chatd";
1451
1674
  this.#chatId = config.chatId;
1675
+ this.#emitTransportEvent = safeTransportEmitter(config.onTransportEvent);
1452
1676
  }
1453
1677
  get chatId() {
1454
1678
  return this.#chatId;
1455
1679
  }
1456
- /** Drops the current session so the next turn creates a fresh chat. */
1680
+ /**
1681
+ * Drops the current session so the next turn creates a fresh chat. Also
1682
+ * closes the retained event stream, if any (fire-and-forget — a sync reset
1683
+ * must not wait on socket teardown, and `close()` never rejects).
1684
+ */
1457
1685
  resetSession() {
1458
1686
  this.#chatId = void 0;
1459
1687
  this.#lastSeenMessageId = 0;
1460
1688
  this.#submittedToolCallIds.clear();
1689
+ const stream = this.#retainedStream;
1690
+ this.#retainedStream = void 0;
1691
+ if (stream) void stream.close();
1692
+ }
1693
+ /**
1694
+ * Closes the retained event stream, if any. The stream otherwise closes
1695
+ * when its turn settles or fails, or when the next turn replaces it — but a
1696
+ * turn that pauses for client tools and never resumes (e.g. the caller
1697
+ * stopped the tool loop, or aborted between segments) leaves the socket
1698
+ * open, so dispose (`await using`) or {@link resetSession} sessions you
1699
+ * abandon mid-turn.
1700
+ */
1701
+ async [Symbol.asyncDispose]() {
1702
+ const stream = this.#retainedStream;
1703
+ this.#retainedStream = void 0;
1704
+ if (stream) await stream.close();
1705
+ }
1706
+ /**
1707
+ * Closes a stream retained by a client-tool pause. Called on explicit
1708
+ * interrupts ({@link CoderAgent.interrupt}): an interrupted pause is dead —
1709
+ * its buffer would otherwise collect the interrupt's own settle events,
1710
+ * which a later tool-result resume would consume as if they were the
1711
+ * resumed generation's (truncating the turn), and if the tool loop never
1712
+ * resumes the socket would outlive the explicit interrupt. A stream a
1713
+ * segment is actively reading is left alone: that segment observes the
1714
+ * settle the interrupt produces and closes the stream itself.
1715
+ */
1716
+ async closePausedStream() {
1717
+ const stream = this.#retainedStream;
1718
+ if (!stream || stream.attached) return;
1719
+ this.#retainedStream = void 0;
1720
+ await stream.close();
1721
+ }
1722
+ /**
1723
+ * The stream a segment reads: a resume segment re-attaches to the stream
1724
+ * its predecessor left paused at `requires_action` (the whole point of
1725
+ * retention — no re-dial per tool round trip); everything else — a new
1726
+ * turn, or a pause the caller abandoned — replaces it with a fresh dial,
1727
+ * because a stale buffer from a settle this client never observed must not
1728
+ * leak into the new turn.
1729
+ */
1730
+ #acquireStream(resume, chatId, afterId) {
1731
+ const retained = this.#retainedStream;
1732
+ if (resume && retained?.canAttach(chatId)) {
1733
+ retained.attach();
1734
+ return retained;
1735
+ }
1736
+ this.#retainedStream = void 0;
1737
+ if (retained) void retained.close();
1738
+ const created = new SessionChatStream({
1739
+ chatId,
1740
+ open: (streamSignal) => this.#config.client.streamEvents(chatId, { afterId, signal: streamSignal })
1741
+ });
1742
+ this.#retainedStream = created;
1743
+ return created;
1744
+ }
1745
+ /** Close `stream` and drop the retention slot if it still holds it. */
1746
+ async #closeStream(stream) {
1747
+ if (this.#retainedStream === stream) this.#retainedStream = void 0;
1748
+ await stream.close();
1461
1749
  }
1462
1750
  async #resolveModelConfigId(signal) {
1463
1751
  if (this.#modelResolved) return this.#resolvedModelConfigId;
@@ -1544,7 +1832,93 @@ var CoderLanguageModel = class {
1544
1832
  });
1545
1833
  return events;
1546
1834
  }
1547
- 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) {
1548
1922
  if (this.#inFlight) {
1549
1923
  throw new CoderAgentError(
1550
1924
  "A generation is already in flight on this CoderAgent (single-flight). Use a separate CoderAgent instance for concurrent sessions."
@@ -1559,10 +1933,12 @@ var CoderLanguageModel = class {
1559
1933
  );
1560
1934
  const signal = sources.length > 0 ? AbortSignal.any(sources) : void 0;
1561
1935
  let translator;
1936
+ let streamAcquired = false;
1562
1937
  let discardSession = false;
1563
1938
  let interruptSent = false;
1939
+ let turnChatId;
1564
1940
  const interrupt = () => {
1565
- const id = this.#chatId;
1941
+ const id = turnChatId ?? this.#chatId;
1566
1942
  if (interruptSent || !id) return;
1567
1943
  interruptSent = true;
1568
1944
  void this.#config.client.interruptChat(id).catch(() => {
@@ -1660,25 +2036,49 @@ var CoderLanguageModel = class {
1660
2036
  afterId = this.#lastSeenMessageId;
1661
2037
  }
1662
2038
  const chatId = this.#chatId;
2039
+ turnChatId = chatId;
2040
+ if (segmentChat) segmentChat.chatId = chatId;
1663
2041
  const dynamicNames = dynamicToolNames(options.tools);
1664
2042
  translator = new TurnTranslator({
1665
2043
  dynamicToolNames: dynamicNames,
1666
- turnCursor: afterId ?? 0
2044
+ turnCursor: afterId ?? 0,
2045
+ // The retained stream redials with the TURN's original cursor, so a
2046
+ // reconnect can replay an earlier segment's `action_required`
2047
+ // snapshot; calls this session already answered must not run again.
2048
+ submittedToolCallIds: this.#submittedToolCallIds
1667
2049
  });
1668
2050
  let sinceRequiresAction = 0;
1669
2051
  let grace;
1670
2052
  let recovery;
1671
2053
  let recoveryAbort;
1672
2054
  let recoveryAttempted = false;
1673
- const stream = this.#config.client.streamEvents(chatId, { afterId, signal });
2055
+ const stream = this.#acquireStream(action.kind === "resume", chatId, afterId);
2056
+ streamAcquired = true;
2057
+ let onSegmentAbort;
2058
+ const segmentAborted = signal ? new Promise((resolve) => {
2059
+ onSegmentAbort = () => resolve(SEGMENT_ABORTED);
2060
+ if (signal.aborted) onSegmentAbort();
2061
+ else signal.addEventListener("abort", onSegmentAbort, { once: true });
2062
+ }) : void 0;
2063
+ const raceSegment = (p) => segmentAborted ? Promise.race([p, segmentAborted]) : p;
2064
+ let retainStream = false;
1674
2065
  try {
1675
- let next = stream.next();
1676
2066
  for (; ; ) {
1677
- let result;
2067
+ const next = stream.read();
1678
2068
  const awaitingActionRequired = translator.terminalStatus === "requires_action" && !translator.clientToolCallSeen;
2069
+ if (!awaitingActionRequired && (grace !== void 0 || recoveryAttempted)) {
2070
+ grace?.cancel();
2071
+ grace = void 0;
2072
+ recoveryAbort?.abort();
2073
+ recoveryAbort = void 0;
2074
+ recovery = void 0;
2075
+ recoveryAttempted = false;
2076
+ sinceRequiresAction = 0;
2077
+ }
2078
+ let raced;
1679
2079
  if (awaitingActionRequired && !recoveryAttempted) {
1680
2080
  grace ??= startGraceTimer(ACTION_REQUIRED_GRACE_MS);
1681
- const raced = await Promise.race([next, grace.expired]);
2081
+ raced = await raceSegment(Promise.race([next, grace.expired]));
1682
2082
  if (raced === GRACE_EXPIRED) {
1683
2083
  recoveryAttempted = true;
1684
2084
  throwIfAborted();
@@ -1691,27 +2091,25 @@ var CoderLanguageModel = class {
1691
2091
  ).catch(() => []);
1692
2092
  continue;
1693
2093
  }
1694
- result = raced;
1695
2094
  } else if (awaitingActionRequired && recovery) {
1696
- const raced = await Promise.race([next, recovery]);
2095
+ raced = await raceSegment(Promise.race([next, recovery]));
1697
2096
  if (Array.isArray(raced)) {
1698
2097
  recovery = void 0;
1699
2098
  throwIfAborted();
1700
2099
  for (const ev of raced) for (const part of translator.ingest(ev)) yield part;
1701
- if (translator.clientToolCallSeen) {
1702
- void next.catch(() => {
1703
- });
1704
- break;
1705
- }
2100
+ if (translator.clientToolCallSeen) break;
1706
2101
  continue;
1707
2102
  }
1708
- result = raced;
1709
2103
  } else {
1710
- result = await next;
2104
+ raced = await raceSegment(next);
2105
+ }
2106
+ if (raced === SEGMENT_ABORTED) {
2107
+ throwIfAborted();
2108
+ throw new CoderAgentError("segment abort sentinel resolved without an abort");
1711
2109
  }
1712
- if (result.done) break;
1713
- next = stream.next();
1714
- for (const part of translator.ingest(result.value)) yield part;
2110
+ stream.consumed(next);
2111
+ if (raced.done) break;
2112
+ for (const part of translator.ingest(raced.value)) yield part;
1715
2113
  const status = translator.terminalStatus;
1716
2114
  if (status) {
1717
2115
  if (status !== "requires_action") break;
@@ -1719,6 +2117,7 @@ var CoderLanguageModel = class {
1719
2117
  if (++sinceRequiresAction > 200) break;
1720
2118
  }
1721
2119
  }
2120
+ retainStream = !signal?.aborted && translator.terminalStatus === "requires_action" && translator.clientToolCallSeen && !translator.error;
1722
2121
  } catch (err) {
1723
2122
  throwIfAborted();
1724
2123
  if (err instanceof CoderStreamError) {
@@ -1745,8 +2144,12 @@ var CoderLanguageModel = class {
1745
2144
  } finally {
1746
2145
  grace?.cancel();
1747
2146
  recoveryAbort?.abort();
1748
- await stream.return(void 0).catch(() => {
1749
- });
2147
+ if (signal && onSegmentAbort) signal.removeEventListener("abort", onSegmentAbort);
2148
+ if (retainStream && !signal?.aborted && !stream.closed) {
2149
+ stream.pause();
2150
+ } else {
2151
+ await this.#closeStream(stream);
2152
+ }
1750
2153
  }
1751
2154
  throwIfAborted();
1752
2155
  if (!translator.terminalStatus && !translator.error) {
@@ -1765,6 +2168,11 @@ var CoderLanguageModel = class {
1765
2168
  this.#lastSeenMessageId = translator.maxMessageId;
1766
2169
  signal?.removeEventListener("abort", interrupt);
1767
2170
  if (!translator?.terminalStatus) interrupt();
2171
+ if (!streamAcquired && this.#retainedStream) {
2172
+ const stale = this.#retainedStream;
2173
+ this.#retainedStream = void 0;
2174
+ void stale.close();
2175
+ }
1768
2176
  if (discardSession) this.resetSession();
1769
2177
  this.#inFlight = false;
1770
2178
  }
@@ -1772,7 +2180,8 @@ var CoderLanguageModel = class {
1772
2180
  async doStream(options) {
1773
2181
  const cancelController = new AbortController();
1774
2182
  const abortSignal = options.abortSignal ? AbortSignal.any([options.abortSignal, cancelController.signal]) : cancelController.signal;
1775
- const gen = this.#runTurn({ ...options, abortSignal });
2183
+ let consumerCancelled = false;
2184
+ const gen = this.#runTurn({ ...options, abortSignal }, () => consumerCancelled);
1776
2185
  const stream = new ReadableStream({
1777
2186
  async pull(controller) {
1778
2187
  try {
@@ -1785,6 +2194,7 @@ var CoderLanguageModel = class {
1785
2194
  }
1786
2195
  },
1787
2196
  async cancel() {
2197
+ consumerCancelled = !options.abortSignal?.aborted;
1788
2198
  cancelController.abort();
1789
2199
  await gen.return().catch(() => {
1790
2200
  });
@@ -1935,7 +2345,8 @@ var CoderAgent = class {
1935
2345
  baseUrl: settings.baseUrl,
1936
2346
  token: settings.token,
1937
2347
  fetch: settings.fetch,
1938
- webSocketFactory: settings.webSocketFactory
2348
+ webSocketFactory: settings.webSocketFactory,
2349
+ onTransportEvent: settings.onTransportEvent
1939
2350
  });
1940
2351
  } else {
1941
2352
  throw new CoderAgentError(
@@ -1961,7 +2372,8 @@ var CoderAgent = class {
1961
2372
  mcpServerIds: settings.mcpServerIds,
1962
2373
  planMode: settings.planMode,
1963
2374
  chatId: settings.chatId,
1964
- requestTimeoutMs: settings.requestTimeoutMs
2375
+ requestTimeoutMs: settings.requestTimeoutMs,
2376
+ onTransportEvent: settings.onTransportEvent
1965
2377
  });
1966
2378
  this.#inner = new ToolLoopAgent({
1967
2379
  model: this.#model,
@@ -2014,7 +2426,9 @@ var CoderAgent = class {
2014
2426
  */
2015
2427
  async interrupt(opts) {
2016
2428
  const id = this.#model.chatId;
2017
- if (id) await this.#client.interruptChat(id, opts?.signal);
2429
+ if (!id) return;
2430
+ await this.#model.closePausedStream();
2431
+ await this.#client.interruptChat(id, opts?.signal);
2018
2432
  }
2019
2433
  /**
2020
2434
  * Archive the underlying chat (safe cleanup; hides it from listings).
@@ -2028,6 +2442,7 @@ var CoderAgent = class {
2028
2442
  async archive(opts) {
2029
2443
  const id = this.#model.chatId;
2030
2444
  if (!id) return;
2445
+ await this.#model.closePausedStream();
2031
2446
  await archiveWhenSettled(this.#client, id, {
2032
2447
  deadlineMs: this.#settleDeadlineMs,
2033
2448
  retryDelayMs: this.#settleRetryDelayMs,
@@ -2051,6 +2466,10 @@ var CoderAgent = class {
2051
2466
  * ```
2052
2467
  */
2053
2468
  async [Symbol.asyncDispose]() {
2469
+ try {
2470
+ await this.#model[Symbol.asyncDispose]();
2471
+ } catch {
2472
+ }
2054
2473
  const deadline = AbortSignal.timeout(this.#settleDeadlineMs);
2055
2474
  try {
2056
2475
  await this.interrupt({ signal: deadline });