@coder/ai-sdk-agent 0.5.0 → 0.6.1

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,11 +242,14 @@ 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}` : "";
234
250
  const url = `${wsBase}${path}${query}`;
235
251
  let maxCommittedId = options.afterId;
252
+ const lastMessageJson = /* @__PURE__ */ new Map();
236
253
  let lastHistoryVersion;
237
254
  let lastGenerationAttempt;
238
255
  let lastSeq;
@@ -246,11 +263,24 @@ async function* streamChatEventsLoop(options) {
246
263
  let finished = false;
247
264
  let failure;
248
265
  let parseFailure = false;
266
+ let closeEmitted = false;
249
267
  const wake = () => {
250
268
  resolveNext?.();
251
269
  resolveNext = void 0;
252
270
  };
253
- const ws = factory(url, { headers: { "Coder-Session-Token": token } });
271
+ attempt += 1;
272
+ emit?.({ type: "ws:dial", chatId, attempt, url, timestamp: Date.now() });
273
+ let ws;
274
+ try {
275
+ ws = factory(url, { headers: { "Coder-Session-Token": token } });
276
+ } catch (err) {
277
+ if (emit) {
278
+ const message = err instanceof Error ? err.message : String(err);
279
+ emit({ type: "ws:error", chatId, attempt, message, timestamp: Date.now() });
280
+ emit({ type: "ws:close", chatId, attempt, timestamp: Date.now() });
281
+ }
282
+ throw err;
283
+ }
254
284
  const onAbort = () => {
255
285
  finished = true;
256
286
  try {
@@ -274,6 +304,13 @@ async function* streamChatEventsLoop(options) {
274
304
  const data = typeof ev.data === "string" ? ev.data : String(ev.data);
275
305
  batch = JSON.parse(data);
276
306
  } catch (err) {
307
+ emit?.({
308
+ type: "ws:error",
309
+ chatId,
310
+ attempt,
311
+ message: "failed to parse chat stream frame",
312
+ timestamp: Date.now()
313
+ });
277
314
  failure = new CoderAgentError("failed to parse chat stream frame", { cause: err });
278
315
  parseFailure = true;
279
316
  finished = true;
@@ -281,24 +318,55 @@ async function* streamChatEventsLoop(options) {
281
318
  return;
282
319
  }
283
320
  if (Array.isArray(batch)) {
284
- for (const e of batch) queue.push(e);
321
+ for (const e of batch) {
322
+ queue.push(e);
323
+ emit?.({
324
+ type: "ws:event",
325
+ chatId,
326
+ attempt,
327
+ event: e,
328
+ timestamp: Date.now()
329
+ });
330
+ }
285
331
  } else if (batch && typeof batch === "object") {
286
332
  queue.push(batch);
333
+ emit?.({
334
+ type: "ws:event",
335
+ chatId,
336
+ attempt,
337
+ event: batch,
338
+ timestamp: Date.now()
339
+ });
287
340
  }
288
341
  wake();
289
342
  };
290
343
  const onError = (ev) => {
291
344
  if (finished) return;
292
345
  const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "chat stream socket error";
346
+ emit?.({ type: "ws:error", chatId, attempt, message, timestamp: Date.now() });
293
347
  const status = upgradeStatus(message);
294
348
  failure = status !== void 0 && status >= 400 && status < 500 && !TRANSIENT_UPGRADE_STATUSES.has(status) ? new CoderApiError({ status, method: "GET", path, message }) : new CoderAgentError(message);
295
349
  finished = true;
296
350
  wake();
297
351
  };
298
- const onClose = () => {
352
+ const onClose = (ev) => {
353
+ if (emit && !closeEmitted) {
354
+ closeEmitted = true;
355
+ const closeEvent = {
356
+ type: "ws:close",
357
+ chatId,
358
+ attempt,
359
+ timestamp: Date.now()
360
+ };
361
+ if (ev?.code !== void 0) closeEvent.code = ev.code;
362
+ if (ev?.reason !== void 0 && ev.reason !== "") closeEvent.reason = ev.reason;
363
+ emit(closeEvent);
364
+ }
299
365
  finished = true;
300
366
  wake();
301
367
  };
368
+ const onOpen = emit ? () => emit({ type: "ws:open", chatId, attempt, timestamp: Date.now() }) : void 0;
369
+ if (onOpen) ws.addEventListener("open", onOpen);
302
370
  ws.addEventListener("message", onMessage);
303
371
  ws.addEventListener("error", onError);
304
372
  ws.addEventListener("close", onClose);
@@ -324,9 +392,13 @@ async function* streamChatEventsLoop(options) {
324
392
  sawUntrackableDelta = true;
325
393
  }
326
394
  } else if (next.type === "message" && next.message) {
395
+ const serialized = JSON.stringify(next.message);
396
+ if (lastMessageJson.get(next.message.id) !== serialized) {
397
+ lastMessageJson.set(next.message.id, serialized);
398
+ progress = true;
399
+ }
327
400
  if (maxCommittedId === void 0 || next.message.id > maxCommittedId) {
328
401
  maxCommittedId = next.message.id;
329
- progress = true;
330
402
  sawUntrackableDelta = false;
331
403
  }
332
404
  }
@@ -344,6 +416,7 @@ async function* streamChatEventsLoop(options) {
344
416
  } finally {
345
417
  finished = true;
346
418
  if (signal && abortListenerAdded) signal.removeEventListener("abort", onAbort);
419
+ if (onOpen) ws.removeEventListener("open", onOpen);
347
420
  ws.removeEventListener("message", onMessage);
348
421
  ws.removeEventListener("error", onError);
349
422
  ws.removeEventListener("close", onClose);
@@ -351,6 +424,10 @@ async function* streamChatEventsLoop(options) {
351
424
  ws.close(1e3);
352
425
  } catch {
353
426
  }
427
+ if (emit && !closeEmitted) {
428
+ closeEmitted = true;
429
+ emit({ type: "ws:close", chatId, attempt, timestamp: Date.now() });
430
+ }
354
431
  }
355
432
  if (signal?.aborted) return;
356
433
  if (failure instanceof CoderApiError) throw failure;
@@ -371,6 +448,15 @@ async function* streamChatEventsLoop(options) {
371
448
  cause: lastFailure
372
449
  });
373
450
  }
451
+ emit?.({
452
+ type: "ws:redial",
453
+ chatId,
454
+ attempt,
455
+ consecutiveFailures: failures,
456
+ maxConsecutiveFailures: STREAM_MAX_CONSECUTIVE_FAILURES,
457
+ backoffMs,
458
+ timestamp: Date.now()
459
+ });
374
460
  await sleep(backoffMs, signal);
375
461
  if (signal?.aborted) return;
376
462
  backoffMs = Math.min(backoffMs * 2, STREAM_BACKOFF_CAP_MS);
@@ -554,11 +640,18 @@ var CoderChatClient = class {
554
640
  #token;
555
641
  #fetch;
556
642
  #webSocketFactory;
643
+ /** The raw subscriber, forwarded to stream readers (which wrap it themselves). */
644
+ #onTransportEvent;
645
+ /** Exception-isolated emitter for this client's own HTTP events. */
646
+ #emitTransportEvent;
647
+ #httpSeq = 0;
557
648
  constructor(options) {
558
649
  this.baseUrl = options.baseUrl.replace(/\/$/, "");
559
650
  this.#token = options.token;
560
651
  this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
561
652
  this.#webSocketFactory = options.webSocketFactory;
653
+ this.#onTransportEvent = options.onTransportEvent;
654
+ this.#emitTransportEvent = safeTransportEmitter(options.onTransportEvent);
562
655
  }
563
656
  /**
564
657
  * Issue a request and return the raw `Response` on success (body unread). On a
@@ -574,7 +667,39 @@ var CoderChatClient = class {
574
667
  if (typeof ReadableStream !== "undefined" && opts?.body instanceof ReadableStream) {
575
668
  init.duplex = "half";
576
669
  }
577
- const res = await this.#fetch(`${this.baseUrl}${path}`, init);
670
+ const emit = this.#emitTransportEvent;
671
+ let requestId = 0;
672
+ let startedAt = 0;
673
+ if (emit) {
674
+ requestId = ++this.#httpSeq;
675
+ startedAt = performance.now();
676
+ emit({ type: "http:request", id: requestId, method, path, timestamp: Date.now() });
677
+ }
678
+ let res;
679
+ try {
680
+ res = await this.#fetch(`${this.baseUrl}${path}`, init);
681
+ } catch (err) {
682
+ emit?.({
683
+ type: "http:error",
684
+ id: requestId,
685
+ method,
686
+ path,
687
+ message: err instanceof Error ? err.message : String(err),
688
+ durationMs: performance.now() - startedAt,
689
+ timestamp: Date.now()
690
+ });
691
+ throw err;
692
+ }
693
+ emit?.({
694
+ type: "http:response",
695
+ id: requestId,
696
+ method,
697
+ path,
698
+ status: res.status,
699
+ ok: res.ok,
700
+ durationMs: performance.now() - startedAt,
701
+ timestamp: Date.now()
702
+ });
578
703
  if (!res.ok) {
579
704
  const errObj = await this.#json(res) ?? {};
580
705
  throw new CoderApiError({
@@ -742,7 +867,8 @@ var CoderChatClient = class {
742
867
  chatId,
743
868
  afterId: opts?.afterId,
744
869
  signal: opts?.signal,
745
- webSocketFactory: this.#webSocketFactory
870
+ webSocketFactory: this.#webSocketFactory,
871
+ onTransportEvent: this.#onTransportEvent
746
872
  });
747
873
  }
748
874
  /**
@@ -1162,12 +1288,21 @@ function accumulateUsage(byMessageId) {
1162
1288
  function jsonResult(value) {
1163
1289
  return value ?? {};
1164
1290
  }
1291
+ function joinContent(content, kind) {
1292
+ let full = "";
1293
+ for (const p of content) if (p.type === kind) full += p.text ?? "";
1294
+ return full;
1295
+ }
1165
1296
  var TurnTranslator = class {
1166
1297
  #dynamicToolNames;
1167
1298
  #submittedToolCallIds;
1168
1299
  #seq = 0;
1169
- #text = { id: void 0, len: 0, sawDelta: false };
1170
- #reasoning = { id: void 0, len: 0, sawDelta: false };
1300
+ // `pending` accumulates delta content that has been emitted but not yet
1301
+ // attributed to a message id (deltas carry none); the next assistant
1302
+ // snapshot claims it. It survives block closes — a closed block loses its
1303
+ // stream id, never the record of what was emitted.
1304
+ #text = { id: void 0, pending: "" };
1305
+ #reasoning = { id: void 0, pending: "" };
1171
1306
  #currentAssistantId;
1172
1307
  // Whether text/reasoning deltas arrived since the last assistant `message`
1173
1308
  // snapshot — i.e. the next assistant snapshot is the trailing snapshot of the
@@ -1182,11 +1317,22 @@ var TurnTranslator = class {
1182
1317
  // the same message replaces its earlier entry instead of double-counting
1183
1318
  // (chatd re-sends full snapshots on revision bumps and history resets).
1184
1319
  #usageByMessageId = /* @__PURE__ */ new Map();
1185
- // Assistant snapshots already processed once. A redial replays the turn
1186
- // from its original cursor, so earlier messages arrive again — without this,
1187
- // re-running the boundary logic on them would reset the emitted-length
1188
- // cursors and re-emit their entire text (see #ingestMessage).
1189
- #snapshotSeen = /* @__PURE__ */ new Set();
1320
+ // The emitted-content ledger: the text/reasoning this translator has emitted
1321
+ // for each processed assistant message id. A redial replays the turn from
1322
+ // its original cursor and chatd re-sends full snapshots on revision bumps,
1323
+ // so already-processed messages arrive again — the ledger is what lets a
1324
+ // byte-identical replay reconcile to a no-op while an APPENDING revision (or
1325
+ // a commit-during-disconnect snapshot) yields exactly its missing suffix
1326
+ // (see #ingestMessage). `substantive` records whether any processed snapshot
1327
+ // of the message carried content (text, reasoning, tools, or sources) —
1328
+ // false only for announce-style empty snapshots, whose in-flight deltas are
1329
+ // still claimable by a same-id commit.
1330
+ #emittedByMessageId = /* @__PURE__ */ new Map();
1331
+ // Same-id revision snapshots that arrived while the NEXT step's deltas were
1332
+ // open (latest wins). chatd sends each revision once on a healthy
1333
+ // connection, so the suffix cannot wait for a replay — it is reconciled as
1334
+ // soon as the next step's snapshot settles ownership of the pending deltas.
1335
+ #deferredRevisions = /* @__PURE__ */ new Map();
1190
1336
  #error;
1191
1337
  #terminalStatus;
1192
1338
  #maxMessageId = 0;
@@ -1221,7 +1367,6 @@ var TurnTranslator = class {
1221
1367
  if (this.#reasoning.id) this.#closeReasoning(out);
1222
1368
  if (!this.#text.id) {
1223
1369
  this.#text.id = `text-${++this.#seq}`;
1224
- this.#text.len = 0;
1225
1370
  out.push({ type: "text-start", id: this.#text.id });
1226
1371
  }
1227
1372
  }
@@ -1229,14 +1374,12 @@ var TurnTranslator = class {
1229
1374
  if (this.#text.id) {
1230
1375
  out.push({ type: "text-end", id: this.#text.id });
1231
1376
  this.#text.id = void 0;
1232
- this.#text.len = 0;
1233
1377
  }
1234
1378
  }
1235
1379
  #openReasoning(out) {
1236
1380
  if (this.#text.id) this.#closeText(out);
1237
1381
  if (!this.#reasoning.id) {
1238
1382
  this.#reasoning.id = `reasoning-${++this.#seq}`;
1239
- this.#reasoning.len = 0;
1240
1383
  out.push({ type: "reasoning-start", id: this.#reasoning.id });
1241
1384
  }
1242
1385
  }
@@ -1244,31 +1387,40 @@ var TurnTranslator = class {
1244
1387
  if (this.#reasoning.id) {
1245
1388
  out.push({ type: "reasoning-end", id: this.#reasoning.id });
1246
1389
  this.#reasoning.id = void 0;
1247
- this.#reasoning.len = 0;
1248
1390
  }
1249
1391
  }
1250
- #emitTextUpTo(out, full) {
1251
- if (full.length <= this.#text.len && this.#text.id) return;
1392
+ #emitTextDelta(out, delta) {
1252
1393
  this.#openText(out);
1253
- if (full.length > this.#text.len) {
1254
- out.push({
1255
- type: "text-delta",
1256
- id: this.#text.id,
1257
- delta: full.slice(this.#text.len)
1258
- });
1259
- this.#text.len = full.length;
1260
- }
1394
+ out.push({ type: "text-delta", id: this.#text.id, delta });
1261
1395
  }
1262
- #emitReasoningUpTo(out, full) {
1263
- if (full.length <= this.#reasoning.len && this.#reasoning.id) return;
1396
+ #emitReasoningDelta(out, delta) {
1264
1397
  this.#openReasoning(out);
1265
- if (full.length > this.#reasoning.len) {
1266
- out.push({
1267
- type: "reasoning-delta",
1268
- id: this.#reasoning.id,
1269
- delta: full.slice(this.#reasoning.len)
1270
- });
1271
- this.#reasoning.len = full.length;
1398
+ out.push({ type: "reasoning-delta", id: this.#reasoning.id, delta });
1399
+ }
1400
+ /**
1401
+ * Reconciles a replayed/revised snapshot of an EARLIER (non-current)
1402
+ * assistant message against its ledger entry: an APPENDING revision emits
1403
+ * its suffix, anything else is a no-op. The suffix is bracketed in its own
1404
+ * block(s) — an open block belongs to the CURRENT step, and splicing another
1405
+ * message's content into it would corrupt both — and the interrupted step's
1406
+ * next delta simply opens a fresh block (its pending content is tracked
1407
+ * independently of blocks).
1408
+ */
1409
+ #reconcileRevision(out, rec, fullText, fullReasoning) {
1410
+ const textSuffix = fullText.length > rec.text.length && fullText.startsWith(rec.text) ? fullText.slice(rec.text.length) : "";
1411
+ const reasoningSuffix = fullReasoning.length > rec.reasoning.length && fullReasoning.startsWith(rec.reasoning) ? fullReasoning.slice(rec.reasoning.length) : "";
1412
+ if (!textSuffix && !reasoningSuffix) return;
1413
+ this.#closeText(out);
1414
+ this.#closeReasoning(out);
1415
+ if (reasoningSuffix) {
1416
+ this.#emitReasoningDelta(out, reasoningSuffix);
1417
+ rec.reasoning = fullReasoning;
1418
+ this.#closeReasoning(out);
1419
+ }
1420
+ if (textSuffix) {
1421
+ this.#emitTextDelta(out, textSuffix);
1422
+ rec.text = fullText;
1423
+ this.#closeText(out);
1272
1424
  }
1273
1425
  }
1274
1426
  // --- tool helpers ---------------------------------------------------------
@@ -1392,21 +1544,19 @@ var TurnTranslator = class {
1392
1544
  const part = mp.part;
1393
1545
  switch (part.type) {
1394
1546
  case "text":
1395
- this.#text.sawDelta = true;
1396
1547
  this.#deltasSinceSnapshot = true;
1397
1548
  this.#openText(out);
1398
1549
  if (part.text) {
1399
1550
  out.push({ type: "text-delta", id: this.#text.id, delta: part.text });
1400
- this.#text.len += part.text.length;
1551
+ this.#text.pending += part.text;
1401
1552
  }
1402
1553
  break;
1403
1554
  case "reasoning":
1404
- this.#reasoning.sawDelta = true;
1405
1555
  this.#deltasSinceSnapshot = true;
1406
1556
  this.#openReasoning(out);
1407
1557
  if (part.text) {
1408
1558
  out.push({ type: "reasoning-delta", id: this.#reasoning.id, delta: part.text });
1409
- this.#reasoning.len += part.text.length;
1559
+ this.#reasoning.pending += part.text;
1410
1560
  }
1411
1561
  break;
1412
1562
  case "tool-call":
@@ -1432,42 +1582,56 @@ var TurnTranslator = class {
1432
1582
  }
1433
1583
  const content = message.content ?? [];
1434
1584
  if (message.role === "assistant") {
1435
- if (this.#snapshotSeen.has(message.id) && (message.id !== this.#currentAssistantId || this.#deltasSinceSnapshot)) {
1436
- for (const part of content) {
1437
- if (part.type === "tool-call" && !this.#isClientTool(part.tool_name))
1438
- this.#emitServerToolCall(out, part);
1439
- else if (part.type === "tool-result" && !this.#isClientTool(part.tool_name))
1440
- this.#emitServerToolResult(out, part);
1441
- else if (part.type === "source") this.#emitSource(out, part);
1585
+ const fullText = joinContent(content, "text");
1586
+ const fullReasoning = joinContent(content, "reasoning");
1587
+ const known = this.#emittedByMessageId.get(message.id);
1588
+ if (known && message.id !== this.#currentAssistantId) {
1589
+ this.#reconcileRevision(out, known, fullText, fullReasoning);
1590
+ } else if (known && this.#deltasSinceSnapshot && known.substantive) {
1591
+ this.#deferredRevisions.set(message.id, { text: fullText, reasoning: fullReasoning });
1592
+ } else {
1593
+ if (this.#currentAssistantId !== void 0 && this.#currentAssistantId !== message.id && !this.#deltasSinceSnapshot) {
1594
+ this.#closeText(out);
1595
+ this.#closeReasoning(out);
1442
1596
  }
1443
- return;
1444
- }
1445
- this.#snapshotSeen.add(message.id);
1446
- if (this.#currentAssistantId !== void 0 && this.#currentAssistantId !== message.id && !this.#deltasSinceSnapshot) {
1447
- this.#closeText(out);
1448
- this.#closeReasoning(out);
1449
- this.#text.sawDelta = false;
1450
- this.#reasoning.sawDelta = false;
1451
- }
1452
- this.#currentAssistantId = message.id;
1453
- const emitSnapshotText = () => {
1454
- if (!this.#text.sawDelta || this.#text.id) {
1455
- const full = content.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
1456
- if (full.length > 0) this.#emitTextUpTo(out, full);
1597
+ this.#currentAssistantId = message.id;
1598
+ const rec = known ?? { text: "", reasoning: "", substantive: false };
1599
+ const textAttributed = rec.text + this.#text.pending;
1600
+ const reasoningAttributed = rec.reasoning + this.#reasoning.pending;
1601
+ const textOk = fullText.startsWith(textAttributed);
1602
+ const reasoningOk = fullReasoning.startsWith(reasoningAttributed);
1603
+ let seenText = 0;
1604
+ let seenReasoning = 0;
1605
+ for (const p of content) {
1606
+ if (p.type === "text") {
1607
+ const end = seenText + (p.text ?? "").length;
1608
+ const from = Math.max(seenText, textAttributed.length);
1609
+ if (textOk && end > from) this.#emitTextDelta(out, fullText.slice(from, end));
1610
+ seenText = end;
1611
+ } else if (p.type === "reasoning") {
1612
+ const end = seenReasoning + (p.text ?? "").length;
1613
+ const from = Math.max(seenReasoning, reasoningAttributed.length);
1614
+ if (reasoningOk && end > from)
1615
+ this.#emitReasoningDelta(out, fullReasoning.slice(from, end));
1616
+ seenReasoning = end;
1617
+ }
1457
1618
  }
1458
- };
1459
- const emitSnapshotReasoning = () => {
1460
- if (!this.#reasoning.sawDelta || this.#reasoning.id) {
1461
- const full = content.filter((p) => p.type === "reasoning").map((p) => p.text ?? "").join("");
1462
- if (full.length > 0) this.#emitReasoningUpTo(out, full);
1619
+ if (textOk) rec.text = fullText;
1620
+ if (reasoningOk) rec.reasoning = fullReasoning;
1621
+ this.#text.pending = "";
1622
+ this.#reasoning.pending = "";
1623
+ rec.substantive ||= content.some(
1624
+ (p) => p.type === "text" || p.type === "reasoning" ? (p.text ?? "").length > 0 : true
1625
+ );
1626
+ this.#emittedByMessageId.set(message.id, rec);
1627
+ this.#deltasSinceSnapshot = false;
1628
+ for (const [id, deferred] of this.#deferredRevisions) {
1629
+ if (id === message.id) continue;
1630
+ const deferredRec = this.#emittedByMessageId.get(id);
1631
+ if (deferredRec)
1632
+ this.#reconcileRevision(out, deferredRec, deferred.text, deferred.reasoning);
1633
+ this.#deferredRevisions.delete(id);
1463
1634
  }
1464
- };
1465
- if (this.#reasoning.id) {
1466
- emitSnapshotReasoning();
1467
- emitSnapshotText();
1468
- } else {
1469
- emitSnapshotText();
1470
- emitSnapshotReasoning();
1471
1635
  }
1472
1636
  for (const part of content) {
1473
1637
  if (part.type === "tool-call" && !this.#isClientTool(part.tool_name))
@@ -1476,7 +1640,6 @@ var TurnTranslator = class {
1476
1640
  this.#emitServerToolResult(out, part);
1477
1641
  else if (part.type === "source") this.#emitSource(out, part);
1478
1642
  }
1479
- this.#deltasSinceSnapshot = false;
1480
1643
  } else if (message.role === "tool") {
1481
1644
  for (const part of content) {
1482
1645
  if (part.type === "tool-result" && !this.#isClientTool(part.tool_name))
@@ -1543,11 +1706,15 @@ var CoderLanguageModel = class {
1543
1706
  #inFlight = false;
1544
1707
  /** The turn-lifetime event stream, retained across client-tool pauses. */
1545
1708
  #retainedStream;
1709
+ /** Exception-isolated observability emitter for `segment:*` events. */
1710
+ #emitTransportEvent;
1711
+ #segmentSeq = 0;
1546
1712
  constructor(config) {
1547
1713
  assertSupportedAiVersion();
1548
1714
  this.#config = config;
1549
1715
  this.modelId = config.model ?? "chatd";
1550
1716
  this.#chatId = config.chatId;
1717
+ this.#emitTransportEvent = safeTransportEmitter(config.onTransportEvent);
1551
1718
  }
1552
1719
  get chatId() {
1553
1720
  return this.#chatId;
@@ -1707,7 +1874,100 @@ var CoderLanguageModel = class {
1707
1874
  });
1708
1875
  return events;
1709
1876
  }
1710
- async *#runTurn(options) {
1877
+ /**
1878
+ * Wraps {@link #runTurnInner} with `segment:start`/`segment:settle`
1879
+ * observability events. A pure pass-through without a subscriber — no
1880
+ * counters, clocks, or event objects on the unobserved path. The settle
1881
+ * event is emitted in a `finally`, so every started segment settles exactly
1882
+ * once: cleanly (with the terminal status carried by the `finish` part that
1883
+ * streamed through), with an `error` summary, or — when the consumer tears
1884
+ * the stream down mid-segment — with neither. Consumer teardown has two
1885
+ * shapes: a plain generator return (no catch runs), and a
1886
+ * `ReadableStream.cancel()` that {@link doStream} routes through an abort to
1887
+ * unblock a pending socket read — the latter surfaces here as an abort
1888
+ * throw, so `isConsumerCancel` (supplied by `doStream`) reclassifies it as
1889
+ * teardown rather than a failed segment.
1890
+ *
1891
+ * Two more classification wrinkles (review findings):
1892
+ * - a streamed server `error` part is captured BEFORE being yielded —
1893
+ * `doGenerate()` throws on receiving it and closes this generator without
1894
+ * another pull, so the catch below never sees that failure;
1895
+ * - the segment's chat id is tracked in `segmentChat` (written by
1896
+ * {@link #runTurnInner} once known) — a retry-safe stream failure on a
1897
+ * freshly created chat discards the session (clearing `this.#chatId`)
1898
+ * before the finally runs, and the settle must still name the chat that
1899
+ * the preceding `ws:*`/`http:*` events identified. The same out-param
1900
+ * carries the translator's terminal status and the internal-timeout
1901
+ * marker for generator-return exits (see {@link SegmentChatRef}).
1902
+ */
1903
+ async *#runTurn(options, isConsumerCancel) {
1904
+ const emit = this.#emitTransportEvent;
1905
+ if (!emit) {
1906
+ yield* this.#runTurnInner(options);
1907
+ return;
1908
+ }
1909
+ const segment = ++this.#segmentSeq;
1910
+ const startedAt = performance.now();
1911
+ const startEvent = {
1912
+ type: "segment:start",
1913
+ segment,
1914
+ timestamp: Date.now()
1915
+ };
1916
+ if (this.#chatId !== void 0) startEvent.chatId = this.#chatId;
1917
+ emit(startEvent);
1918
+ const segmentChat = {};
1919
+ let finishReason;
1920
+ let failure;
1921
+ let failed = false;
1922
+ try {
1923
+ for await (const part of this.#runTurnInner(options, segmentChat)) {
1924
+ if (part.type === "finish") finishReason = part.finishReason;
1925
+ else if (part.type === "error" && !failed) {
1926
+ failure = part.error;
1927
+ failed = true;
1928
+ }
1929
+ yield part;
1930
+ }
1931
+ } catch (err) {
1932
+ if (!isConsumerCancel?.()) {
1933
+ failure = err;
1934
+ failed = true;
1935
+ }
1936
+ throw err;
1937
+ } finally {
1938
+ const settleEvent = {
1939
+ type: "segment:settle",
1940
+ segment,
1941
+ durationMs: performance.now() - startedAt,
1942
+ timestamp: Date.now()
1943
+ };
1944
+ const chatId = segmentChat.chatId ?? this.#chatId;
1945
+ if (chatId !== void 0) settleEvent.chatId = chatId;
1946
+ if (!failed && finishReason === void 0) {
1947
+ if (segmentChat.timeoutError) {
1948
+ failure = segmentChat.timeoutError;
1949
+ failed = true;
1950
+ } else if (options.abortSignal?.aborted && !isConsumerCancel?.()) {
1951
+ failure = options.abortSignal.reason;
1952
+ failed = true;
1953
+ }
1954
+ }
1955
+ if (failed) {
1956
+ settleEvent.error = {
1957
+ name: failure instanceof Error ? failure.name : "Error",
1958
+ message: failure instanceof Error ? failure.message : String(failure)
1959
+ };
1960
+ }
1961
+ if (finishReason !== void 0) {
1962
+ if (finishReason.raw !== void 0) settleEvent.status = finishReason.raw;
1963
+ if (!failed) settleEvent.finishReason = finishReason.unified;
1964
+ } else if (segmentChat.terminalStatus !== void 0) {
1965
+ settleEvent.status = segmentChat.terminalStatus;
1966
+ }
1967
+ emit(settleEvent);
1968
+ }
1969
+ }
1970
+ async *#runTurnInner(options, segmentChat) {
1711
1971
  if (this.#inFlight) {
1712
1972
  throw new CoderAgentError(
1713
1973
  "A generation is already in flight on this CoderAgent (single-flight). Use a separate CoderAgent instance for concurrent sessions."
@@ -1734,13 +1994,14 @@ var CoderLanguageModel = class {
1734
1994
  });
1735
1995
  };
1736
1996
  signal?.addEventListener("abort", interrupt, { once: true });
1997
+ const timeoutError = () => new CoderChatError({
1998
+ message: `Coder Agent turn exceeded its ${timeoutMs}ms requestTimeoutMs budget.`,
1999
+ kind: "timeout",
2000
+ retryable: true
2001
+ });
1737
2002
  const throwIfAborted = () => {
1738
2003
  if (timeoutSignal?.aborted) {
1739
- throw new CoderChatError({
1740
- message: `Coder Agent turn exceeded its ${timeoutMs}ms requestTimeoutMs budget.`,
1741
- kind: "timeout",
1742
- retryable: true
1743
- });
2004
+ throw timeoutError();
1744
2005
  }
1745
2006
  if (signal?.aborted) {
1746
2007
  throw externalSignal?.reason ?? new DOMException("The operation was aborted.", "AbortError");
@@ -1767,6 +2028,7 @@ var CoderLanguageModel = class {
1767
2028
  let turnCreatedChat = false;
1768
2029
  let uploadedAttachment = false;
1769
2030
  let chatHasWorkspace = Boolean(this.#config.workspaceId);
2031
+ let chatHasMcpServers = Boolean(this.#config.mcpServerIds?.length);
1770
2032
  if (this.#chatId && this.#lastSeenMessageId === 0) {
1771
2033
  const { messages } = await this.#config.client.getMessages(
1772
2034
  this.#chatId,
@@ -1800,6 +2062,7 @@ var CoderLanguageModel = class {
1800
2062
  this.#chatId = chat.id;
1801
2063
  turnCreatedChat = true;
1802
2064
  if (chat.workspace_id) chatHasWorkspace = true;
2065
+ if (chat.mcp_server_ids?.length) chatHasMcpServers = true;
1803
2066
  afterId = this.#lastSeenMessageId > 0 ? this.#lastSeenMessageId : void 0;
1804
2067
  } else {
1805
2068
  const resp = await this.#config.client.createChatMessage(
@@ -1826,6 +2089,7 @@ var CoderLanguageModel = class {
1826
2089
  }
1827
2090
  const chatId = this.#chatId;
1828
2091
  turnChatId = chatId;
2092
+ if (segmentChat) segmentChat.chatId = chatId;
1829
2093
  const dynamicNames = dynamicToolNames(options.tools);
1830
2094
  translator = new TurnTranslator({
1831
2095
  dynamicToolNames: dynamicNames,
@@ -1897,7 +2161,20 @@ var CoderLanguageModel = class {
1897
2161
  }
1898
2162
  stream.consumed(next);
1899
2163
  if (raced.done) break;
1900
- for (const part of translator.ingest(raced.value)) yield part;
2164
+ const parts = translator.ingest(raced.value);
2165
+ if (raced.value.type === "error" && translator.error && !translator.terminalStatus) {
2166
+ const peek = stream.read();
2167
+ const bound = startGraceTimer(0);
2168
+ const peeked = await raceSegment(Promise.race([peek, bound.expired])).catch(
2169
+ () => GRACE_EXPIRED
2170
+ );
2171
+ bound.cancel();
2172
+ if (peeked !== GRACE_EXPIRED && peeked !== SEGMENT_ABORTED && !peeked.done) {
2173
+ stream.consumed(peek);
2174
+ parts.push(...translator.ingest(peeked.value));
2175
+ }
2176
+ }
2177
+ for (const part of parts) yield part;
1901
2178
  const status = translator.terminalStatus;
1902
2179
  if (status) {
1903
2180
  if (status !== "requires_action") break;
@@ -1912,7 +2189,7 @@ var CoderLanguageModel = class {
1912
2189
  if (turnCreatedChat) {
1913
2190
  discardSession = true;
1914
2191
  }
1915
- const effectful = chatHasWorkspace || Boolean(this.#config.mcpServerIds?.length) || uploadedAttachment;
2192
+ const effectful = chatHasWorkspace || chatHasMcpServers || uploadedAttachment;
1916
2193
  if (turnCreatedChat && !effectful) throw err;
1917
2194
  throw new CoderStreamError({
1918
2195
  message: `${err.message}; automatic retry is disabled because ${turnCreatedChat ? "the turn has server-side effects a replay would repeat (workspace/MCP tools that may have executed, or freshly uploaded attachments)" : "the chat has prior state (a retry would resubmit this turn's prompt as a new user turn)"}`,
@@ -1954,6 +2231,12 @@ var CoderLanguageModel = class {
1954
2231
  } finally {
1955
2232
  if (translator && translator.maxMessageId > this.#lastSeenMessageId)
1956
2233
  this.#lastSeenMessageId = translator.maxMessageId;
2234
+ if (segmentChat) {
2235
+ if (translator?.terminalStatus !== void 0) {
2236
+ segmentChat.terminalStatus = translator.terminalStatus;
2237
+ }
2238
+ if (timeoutSignal?.aborted) segmentChat.timeoutError = timeoutError();
2239
+ }
1957
2240
  signal?.removeEventListener("abort", interrupt);
1958
2241
  if (!translator?.terminalStatus) interrupt();
1959
2242
  if (!streamAcquired && this.#retainedStream) {
@@ -1968,7 +2251,8 @@ var CoderLanguageModel = class {
1968
2251
  async doStream(options) {
1969
2252
  const cancelController = new AbortController();
1970
2253
  const abortSignal = options.abortSignal ? AbortSignal.any([options.abortSignal, cancelController.signal]) : cancelController.signal;
1971
- const gen = this.#runTurn({ ...options, abortSignal });
2254
+ let consumerCancelled = false;
2255
+ const gen = this.#runTurn({ ...options, abortSignal }, () => consumerCancelled);
1972
2256
  const stream = new ReadableStream({
1973
2257
  async pull(controller) {
1974
2258
  try {
@@ -1981,6 +2265,7 @@ var CoderLanguageModel = class {
1981
2265
  }
1982
2266
  },
1983
2267
  async cancel() {
2268
+ consumerCancelled = !options.abortSignal?.aborted;
1984
2269
  cancelController.abort();
1985
2270
  await gen.return().catch(() => {
1986
2271
  });
@@ -2131,7 +2416,8 @@ var CoderAgent = class {
2131
2416
  baseUrl: settings.baseUrl,
2132
2417
  token: settings.token,
2133
2418
  fetch: settings.fetch,
2134
- webSocketFactory: settings.webSocketFactory
2419
+ webSocketFactory: settings.webSocketFactory,
2420
+ onTransportEvent: settings.onTransportEvent
2135
2421
  });
2136
2422
  } else {
2137
2423
  throw new CoderAgentError(
@@ -2157,7 +2443,8 @@ var CoderAgent = class {
2157
2443
  mcpServerIds: settings.mcpServerIds,
2158
2444
  planMode: settings.planMode,
2159
2445
  chatId: settings.chatId,
2160
- requestTimeoutMs: settings.requestTimeoutMs
2446
+ requestTimeoutMs: settings.requestTimeoutMs,
2447
+ onTransportEvent: settings.onTransportEvent
2161
2448
  });
2162
2449
  this.#inner = new ToolLoopAgent({
2163
2450
  model: this.#model,