@coder/ai-sdk-agent 0.8.0 → 0.9.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
@@ -30,6 +30,15 @@ var CoderApiError = class extends CoderAgentError {
30
30
  };
31
31
  var CoderStreamError = class extends APICallError {
32
32
  name = "CoderStreamError";
33
+ /**
34
+ * The chatd chat id the failed turn had created or attached to, when known.
35
+ * A fresh-chat stream failure discards the session so a retry starts on a
36
+ * fresh chat (`CoderAgent.chatId` becomes `undefined`), but the failed chat
37
+ * still exists server-side — this field names it so callers can clean it up
38
+ * (`CoderAgent.archive()` also retains it as the last-known cleanup target).
39
+ * `undefined` when the failure happened before any chat existed.
40
+ */
41
+ chatId;
33
42
  constructor(args) {
34
43
  super({
35
44
  message: args.message,
@@ -42,6 +51,7 @@ var CoderStreamError = class extends APICallError {
42
51
  isRetryable: args.isRetryable ?? true,
43
52
  cause: args.cause
44
53
  });
54
+ this.chatId = args.chatId;
45
55
  }
46
56
  };
47
57
  var CoderChatError = class extends CoderAgentError {
@@ -303,6 +313,52 @@ async function* streamChatEventsLoop(options) {
303
313
  abortListenerAdded = true;
304
314
  }
305
315
  }
316
+ const ingest = (next) => {
317
+ let progress = false;
318
+ let forwarded = true;
319
+ if (next.type === "message_part") {
320
+ const mp = next.message_part;
321
+ const hv = mp?.history_version;
322
+ const ga = mp?.generation_attempt;
323
+ const seq = mp?.seq;
324
+ if (hv !== void 0 && ga !== void 0 && seq !== void 0) {
325
+ if (hv === lastHistoryVersion && ga === lastGenerationAttempt && lastSeq !== void 0 && seq <= lastSeq) {
326
+ forwarded = false;
327
+ } else {
328
+ lastHistoryVersion = hv;
329
+ lastGenerationAttempt = ga;
330
+ lastSeq = seq;
331
+ progress = true;
332
+ }
333
+ } else {
334
+ sawUntrackableDelta = true;
335
+ }
336
+ } else if (next.type === "message" && next.message) {
337
+ const serialized = JSON.stringify(next.message);
338
+ if (lastMessageJson.get(next.message.id) !== serialized) {
339
+ lastMessageJson.set(next.message.id, serialized);
340
+ progress = true;
341
+ }
342
+ if (maxCommittedId === void 0 || next.message.id > maxCommittedId) {
343
+ maxCommittedId = next.message.id;
344
+ sawUntrackableDelta = false;
345
+ }
346
+ }
347
+ if (progress) {
348
+ failures = 0;
349
+ backoffMs = STREAM_BACKOFF_INITIAL_MS;
350
+ }
351
+ emit?.({
352
+ type: "ws:event",
353
+ chatId,
354
+ reader,
355
+ attempt,
356
+ event: next,
357
+ forwarded,
358
+ timestamp: Date.now()
359
+ });
360
+ if (forwarded) queue.push(next);
361
+ };
306
362
  const onMessage = (ev) => {
307
363
  if (finished) return;
308
364
  let batch;
@@ -325,27 +381,9 @@ async function* streamChatEventsLoop(options) {
325
381
  return;
326
382
  }
327
383
  if (Array.isArray(batch)) {
328
- for (const e of batch) {
329
- queue.push(e);
330
- emit?.({
331
- type: "ws:event",
332
- chatId,
333
- reader,
334
- attempt,
335
- event: e,
336
- timestamp: Date.now()
337
- });
338
- }
384
+ for (const e of batch) ingest(e);
339
385
  } else if (batch && typeof batch === "object") {
340
- queue.push(batch);
341
- emit?.({
342
- type: "ws:event",
343
- chatId,
344
- reader,
345
- attempt,
346
- event: batch,
347
- timestamp: Date.now()
348
- });
386
+ ingest(batch);
349
387
  }
350
388
  wake();
351
389
  };
@@ -383,40 +421,7 @@ async function* streamChatEventsLoop(options) {
383
421
  try {
384
422
  while (true) {
385
423
  while (queue.length > 0) {
386
- const next = queue.shift();
387
- let progress = false;
388
- if (next.type === "message_part") {
389
- const mp = next.message_part;
390
- const hv = mp?.history_version;
391
- const ga = mp?.generation_attempt;
392
- const seq = mp?.seq;
393
- if (hv !== void 0 && ga !== void 0 && seq !== void 0) {
394
- if (hv === lastHistoryVersion && ga === lastGenerationAttempt && lastSeq !== void 0 && seq <= lastSeq) {
395
- continue;
396
- }
397
- lastHistoryVersion = hv;
398
- lastGenerationAttempt = ga;
399
- lastSeq = seq;
400
- progress = true;
401
- } else {
402
- sawUntrackableDelta = true;
403
- }
404
- } else if (next.type === "message" && next.message) {
405
- const serialized = JSON.stringify(next.message);
406
- if (lastMessageJson.get(next.message.id) !== serialized) {
407
- lastMessageJson.set(next.message.id, serialized);
408
- progress = true;
409
- }
410
- if (maxCommittedId === void 0 || next.message.id > maxCommittedId) {
411
- maxCommittedId = next.message.id;
412
- sawUntrackableDelta = false;
413
- }
414
- }
415
- if (progress) {
416
- failures = 0;
417
- backoffMs = STREAM_BACKOFF_INITIAL_MS;
418
- }
419
- yield next;
424
+ yield queue.shift();
420
425
  }
421
426
  if (finished) break;
422
427
  await new Promise((resolve) => {
@@ -446,7 +451,8 @@ async function* streamChatEventsLoop(options) {
446
451
  throw new CoderStreamError({
447
452
  message: "chat stream dropped mid-turn and cannot be resumed: this server does not stamp deltas with history_version/generation_attempt/seq, so a redial would replay them",
448
453
  url,
449
- cause: failure
454
+ cause: failure,
455
+ chatId
450
456
  });
451
457
  }
452
458
  if (failure) lastFailure = failure;
@@ -455,7 +461,8 @@ async function* streamChatEventsLoop(options) {
455
461
  throw new CoderStreamError({
456
462
  message: `Coder chat stream ${path} could not be (re-)established after ${failures} consecutive connection failures without progress`,
457
463
  url,
458
- cause: lastFailure
464
+ cause: lastFailure,
465
+ chatId
459
466
  });
460
467
  }
461
468
  emit?.({
@@ -667,9 +674,11 @@ var CoderChatClient = class {
667
674
  /**
668
675
  * Issue a request and return the raw `Response` on success (body unread). On a
669
676
  * non-2xx status the body is consumed to build a {@link CoderApiError}. Shared
670
- * by JSON requests and the raw upload/download endpoints.
677
+ * by JSON requests and the raw upload/download endpoints. `op` names the
678
+ * public client method on the exchange's `http:*` events (issue #112) — the
679
+ * emit site is shared, so each call site threads its own literal.
671
680
  */
672
- async #send(method, path, opts) {
681
+ async #send(op, method, path, opts) {
673
682
  const headers = {
674
683
  "Coder-Session-Token": this.#token,
675
684
  ...opts?.headers
@@ -684,7 +693,7 @@ var CoderChatClient = class {
684
693
  if (emit) {
685
694
  requestId = ++this.#httpSeq;
686
695
  startedAt = performance.now();
687
- emit({ type: "http:request", id: requestId, method, path, timestamp: Date.now() });
696
+ emit({ type: "http:request", id: requestId, op, method, path, timestamp: Date.now() });
688
697
  }
689
698
  let res;
690
699
  try {
@@ -693,6 +702,7 @@ var CoderChatClient = class {
693
702
  emit?.({
694
703
  type: "http:error",
695
704
  id: requestId,
705
+ op,
696
706
  method,
697
707
  path,
698
708
  message: err instanceof Error ? err.message : String(err),
@@ -704,6 +714,7 @@ var CoderChatClient = class {
704
714
  emit?.({
705
715
  type: "http:response",
706
716
  id: requestId,
717
+ op,
707
718
  method,
708
719
  path,
709
720
  status: res.status,
@@ -733,8 +744,8 @@ var CoderChatClient = class {
733
744
  return void 0;
734
745
  }
735
746
  }
736
- async #request(method, path, body, signal) {
737
- const res = await this.#send(method, path, {
747
+ async #request(op, method, path, body, signal) {
748
+ const res = await this.#send(op, method, path, {
738
749
  body: body === void 0 ? void 0 : JSON.stringify(body),
739
750
  headers: body === void 0 ? void 0 : { "Content-Type": "application/json" },
740
751
  signal
@@ -744,6 +755,7 @@ var CoderChatClient = class {
744
755
  // --- REST -----------------------------------------------------------------
745
756
  listModelConfigs(signal) {
746
757
  return this.#request(
758
+ "listModelConfigs",
747
759
  "GET",
748
760
  `${API_PREFIX}/model-configs`,
749
761
  void 0,
@@ -751,13 +763,14 @@ var CoderChatClient = class {
751
763
  );
752
764
  }
753
765
  createChat(req, signal) {
754
- return this.#request("POST", API_PREFIX, req, signal);
766
+ return this.#request("createChat", "POST", API_PREFIX, req, signal);
755
767
  }
756
768
  getChat(chatId, signal) {
757
- return this.#request("GET", `${API_PREFIX}/${chatId}`, void 0, signal);
769
+ return this.#request("getChat", "GET", `${API_PREFIX}/${chatId}`, void 0, signal);
758
770
  }
759
771
  createChatMessage(chatId, req, signal) {
760
772
  return this.#request(
773
+ "createChatMessage",
761
774
  "POST",
762
775
  `${API_PREFIX}/${chatId}/messages`,
763
776
  req,
@@ -771,6 +784,7 @@ var CoderChatClient = class {
771
784
  if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
772
785
  const q = params.toString();
773
786
  return this.#request(
787
+ "getMessages",
774
788
  "GET",
775
789
  `${API_PREFIX}/${chatId}/messages${q ? `?${q}` : ""}`,
776
790
  void 0,
@@ -778,7 +792,13 @@ var CoderChatClient = class {
778
792
  );
779
793
  }
780
794
  submitToolResults(chatId, req, signal) {
781
- return this.#request("POST", `${API_PREFIX}/${chatId}/tool-results`, req, signal);
795
+ return this.#request(
796
+ "submitToolResults",
797
+ "POST",
798
+ `${API_PREFIX}/${chatId}/tool-results`,
799
+ req,
800
+ signal
801
+ );
782
802
  }
783
803
  /**
784
804
  * Interrupt the chat's in-flight run (`POST /interrupt`). The server flips
@@ -797,6 +817,7 @@ var CoderChatClient = class {
797
817
  const opts = optsOrSignal instanceof AbortSignal ? { signal: optsOrSignal } : optsOrSignal;
798
818
  const query = opts?.wait ? "?wait=true" : "";
799
819
  return this.#request(
820
+ "interruptChat",
800
821
  "POST",
801
822
  `${API_PREFIX}/${chatId}/interrupt${query}`,
802
823
  void 0,
@@ -804,11 +825,17 @@ var CoderChatClient = class {
804
825
  );
805
826
  }
806
827
  updateChat(chatId, req, signal) {
807
- return this.#request("PATCH", `${API_PREFIX}/${chatId}`, req, signal);
828
+ return this.#request("updateChat", "PATCH", `${API_PREFIX}/${chatId}`, req, signal);
808
829
  }
809
830
  /** Convenience: archive a chat (soft-hide; safe for cleanup). */
810
831
  archiveChat(chatId, signal) {
811
- return this.updateChat(chatId, { archived: true }, signal);
832
+ return this.#request(
833
+ "archiveChat",
834
+ "PATCH",
835
+ `${API_PREFIX}/${chatId}`,
836
+ { archived: true },
837
+ signal
838
+ );
812
839
  }
813
840
  // --- Files ----------------------------------------------------------------
814
841
  /**
@@ -846,6 +873,7 @@ var CoderChatClient = class {
846
873
  headers["Content-Disposition"] = `attachment; filename="${ascii}"; filename*=UTF-8''${utf8}`;
847
874
  }
848
875
  const res = await this.#send(
876
+ "uploadChatFile",
849
877
  "POST",
850
878
  `${API_PREFIX}/files?organization=${encodeURIComponent(organizationId)}`,
851
879
  { body: resolved.body, headers, signal }
@@ -863,7 +891,9 @@ var CoderChatClient = class {
863
891
  }
864
892
  /** Download a chat file's bytes and media type by id. */
865
893
  async getChatFile(fileId, signal) {
866
- const res = await this.#send("GET", `${API_PREFIX}/files/${fileId}`, { signal });
894
+ const res = await this.#send("getChatFile", "GET", `${API_PREFIX}/files/${fileId}`, {
895
+ signal
896
+ });
867
897
  const buf = await res.arrayBuffer();
868
898
  return {
869
899
  bytes: new Uint8Array(buf),
@@ -917,7 +947,13 @@ var CoderChatClient = class {
917
947
  async resolveModelConfigId(hint, signal) {
918
948
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
919
949
  if (UUID_RE.test(hint)) return hint;
920
- const raw = await this.listModelConfigs(signal);
950
+ const raw = await this.#request(
951
+ "resolveModelConfigId",
952
+ "GET",
953
+ `${API_PREFIX}/model-configs`,
954
+ void 0,
955
+ signal
956
+ );
921
957
  const configs = (Array.isArray(raw) ? raw : []).filter(
922
958
  (c) => typeof c === "object" && c !== null
923
959
  );
@@ -1752,6 +1788,25 @@ var CoderLanguageModel = class {
1752
1788
  supportedUrls = {};
1753
1789
  #config;
1754
1790
  #chatId;
1791
+ /**
1792
+ * The last chat id this model created or attached to, retained as a CLEANUP
1793
+ * target — see {@link lastKnownChatId}. Unlike `#chatId` it survives
1794
+ * {@link resetSession}, and is only replaced when a later turn's chat
1795
+ * supersedes it or cleared by {@link noteChatCleanedUp} once cleanup
1796
+ * actually succeeded against it.
1797
+ */
1798
+ #lastKnownChatId;
1799
+ /**
1800
+ * Every chat id stranded by an AUTOMATIC session discard (a stream failure
1801
+ * on a chat the failed turn itself created) and not yet cleaned up. A
1802
+ * single `#lastKnownChatId` slot is not enough here: each AI-SDK
1803
+ * `maxRetries` attempt strands its own fresh chat and only the final
1804
+ * attempt's error reaches the caller, so without this ledger every attempt
1805
+ * before the last would leak unnamed. Drained by {@link noteChatCleanedUp}.
1806
+ * Deliberate abandonment ({@link resetSession}) does NOT record here — the
1807
+ * caller chose to walk away and may want the chat kept.
1808
+ */
1809
+ #strandedChatIds = /* @__PURE__ */ new Set();
1755
1810
  #lastSeenMessageId = 0;
1756
1811
  #resolvedModelConfigId;
1757
1812
  #modelResolved = false;
@@ -1767,15 +1822,55 @@ var CoderLanguageModel = class {
1767
1822
  this.#config = config;
1768
1823
  this.modelId = config.model ?? "chatd";
1769
1824
  this.#chatId = config.chatId;
1825
+ this.#lastKnownChatId = config.chatId;
1770
1826
  this.#emitTransportEvent = safeTransportEmitter(config.onTransportEvent);
1771
1827
  }
1772
1828
  get chatId() {
1773
1829
  return this.#chatId;
1774
1830
  }
1831
+ /**
1832
+ * The last chat id this model created or attached to, for CLEANUP targeting
1833
+ * only — generation always targets {@link chatId}. It survives
1834
+ * {@link resetSession} (including the automatic session discard after a
1835
+ * fresh-chat stream failure, which clears {@link chatId} so a retry starts
1836
+ * fresh while the failed chat still exists server-side), is superseded when
1837
+ * a later turn creates or attaches to another chat, and is cleared via
1838
+ * {@link noteChatCleanedUp} once cleanup actually succeeded against it.
1839
+ */
1840
+ get lastKnownChatId() {
1841
+ return this.#lastKnownChatId;
1842
+ }
1843
+ /**
1844
+ * Chat ids stranded by automatic session discards and not yet cleaned up,
1845
+ * oldest first. Each retryable fresh-chat stream failure strands one chat;
1846
+ * under AI-SDK `maxRetries` several can accumulate while only the final
1847
+ * attempt's error (and {@link lastKnownChatId}) names the newest —
1848
+ * `CoderAgent.archive()` retires all of them. Ids leave the list only via
1849
+ * {@link noteChatCleanedUp}.
1850
+ */
1851
+ get strandedChatIds() {
1852
+ return [...this.#strandedChatIds];
1853
+ }
1854
+ /**
1855
+ * Forgets `chatId` as a cleanup target — call it only after cleanup
1856
+ * actually succeeded against that chat (e.g. `CoderAgent.archive()`
1857
+ * archived it), so later cleanup calls don't re-target an already-archived
1858
+ * chat: removes it from {@link strandedChatIds} and, when it is the
1859
+ * last-known id, clears {@link lastKnownChatId} (match-guarded: a stale
1860
+ * completion must not erase a newer chat's id). The session field
1861
+ * ({@link chatId}) is deliberately untouched — archiving does not reset the
1862
+ * session.
1863
+ */
1864
+ noteChatCleanedUp(chatId) {
1865
+ this.#strandedChatIds.delete(chatId);
1866
+ if (this.#lastKnownChatId === chatId) this.#lastKnownChatId = void 0;
1867
+ }
1775
1868
  /**
1776
1869
  * Drops the current session so the next turn creates a fresh chat. Also
1777
1870
  * closes the retained event stream, if any (fire-and-forget — a sync reset
1778
1871
  * must not wait on socket teardown, and `close()` never rejects).
1872
+ * {@link lastKnownChatId} deliberately survives the reset: the dropped chat
1873
+ * still exists server-side and cleanup must still be able to target it.
1779
1874
  */
1780
1875
  resetSession() {
1781
1876
  this.#chatId = void 0;
@@ -2145,6 +2240,7 @@ var CoderLanguageModel = class {
2145
2240
  }
2146
2241
  const chatId = this.#chatId;
2147
2242
  turnChatId = chatId;
2243
+ this.#lastKnownChatId = chatId;
2148
2244
  if (segmentChat) segmentChat.chatId = chatId;
2149
2245
  const dynamicNames = dynamicToolNames(options.tools);
2150
2246
  translator = new TurnTranslator({
@@ -2243,7 +2339,11 @@ var CoderLanguageModel = class {
2243
2339
  } catch (err) {
2244
2340
  throwIfAborted();
2245
2341
  if (err instanceof CoderStreamError) {
2342
+ if (err.chatId === void 0 && turnChatId !== void 0) {
2343
+ err.chatId = turnChatId;
2344
+ }
2246
2345
  if (turnCreatedChat) {
2346
+ if (turnChatId) this.#strandedChatIds.add(turnChatId);
2247
2347
  discardSession = true;
2248
2348
  }
2249
2349
  const effectful = chatHasWorkspace || chatHasMcpServers || uploadedAttachment;
@@ -2252,7 +2352,8 @@ var CoderLanguageModel = class {
2252
2352
  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)"}`,
2253
2353
  url: err.url,
2254
2354
  cause: err,
2255
- isRetryable: false
2355
+ isRetryable: false,
2356
+ chatId: err.chatId
2256
2357
  });
2257
2358
  }
2258
2359
  if (err instanceof CoderAgentError && !(err instanceof CoderApiError) && !(err instanceof CoderChatError)) {
@@ -2531,6 +2632,29 @@ var CoderAgent = class {
2531
2632
  get chatId() {
2532
2633
  return this.#model.chatId;
2533
2634
  }
2635
+ /**
2636
+ * The last chat id this agent created or attached to, retained for CLEANUP
2637
+ * even after the session was dropped — a fresh-chat stream failure discards
2638
+ * the session ({@link CoderAgent.chatId} becomes `undefined`, so a retry
2639
+ * starts fresh), but the failed chat still exists server-side.
2640
+ * {@link CoderAgent.archive} and {@link CoderAgent.interrupt} fall back to
2641
+ * this id; generation never does. Superseded when a later turn creates or
2642
+ * attaches to another chat; cleared once {@link CoderAgent.archive}
2643
+ * succeeds against it.
2644
+ */
2645
+ get lastKnownChatId() {
2646
+ return this.#model.lastKnownChatId;
2647
+ }
2648
+ /**
2649
+ * Chat ids stranded by automatic session discards and not yet cleaned up,
2650
+ * oldest first. Each retryable fresh-chat stream failure strands one chat,
2651
+ * so with `maxRetries` > 0 several can accumulate while only the final
2652
+ * attempt's error (and {@link CoderAgent.lastKnownChatId}) names the
2653
+ * newest. {@link CoderAgent.archive} retires all of them.
2654
+ */
2655
+ get strandedChatIds() {
2656
+ return this.#model.strandedChatIds;
2657
+ }
2534
2658
  generate(options) {
2535
2659
  return this.#inner.generate(options);
2536
2660
  }
@@ -2546,7 +2670,12 @@ var CoderAgent = class {
2546
2670
  listModels(signal) {
2547
2671
  return this.#client.listModelConfigs(signal);
2548
2672
  }
2549
- /** Start a fresh chatd chat on the next turn. */
2673
+ /**
2674
+ * Start a fresh chatd chat on the next turn. The dropped chat still exists
2675
+ * server-side: {@link CoderAgent.archive} / {@link CoderAgent.interrupt}
2676
+ * keep targeting it (via {@link CoderAgent.lastKnownChatId}) until a new
2677
+ * chat supersedes it or an archive succeeds against it.
2678
+ */
2550
2679
  resetSession() {
2551
2680
  this.#model.resetSession();
2552
2681
  }
@@ -2554,37 +2683,67 @@ var CoderAgent = class {
2554
2683
  * Interrupt the in-flight generation, if any. Resolves as soon as the server
2555
2684
  * acknowledges the interrupt — the run keeps winding down asynchronously for
2556
2685
  * a few seconds afterwards (see {@link CoderAgent.archive}).
2686
+ *
2687
+ * Targets the session's chat, or — after the session was dropped (a
2688
+ * fresh-chat stream failure, or {@link CoderAgent.resetSession}) — the
2689
+ * {@link CoderAgent.lastKnownChatId}, whose run may still be winding down
2690
+ * server-side. With neither, there is nothing to interrupt and it resolves
2691
+ * `{ interrupted: false }` without a server call (inspect the result rather
2692
+ * than relying on a silent no-op).
2557
2693
  */
2558
2694
  async interrupt(opts) {
2559
- const id = this.#model.chatId;
2560
- if (!id) return;
2695
+ const id = this.#model.chatId ?? this.#model.lastKnownChatId;
2696
+ if (!id) return { interrupted: false };
2561
2697
  await this.#model.closePausedStream();
2562
2698
  await this.#client.interruptChat(id, opts?.signal);
2699
+ return { interrupted: true, chatId: id };
2563
2700
  }
2564
2701
  /**
2565
2702
  * Archive the underlying chat (safe cleanup; hides it from listings).
2566
2703
  *
2704
+ * Targets the session's chat, or — after the session was dropped (a
2705
+ * fresh-chat stream failure, or {@link CoderAgent.resetSession}) — the
2706
+ * {@link CoderAgent.lastKnownChatId}, so the stranded chat is still cleaned
2707
+ * up; chats stranded by EARLIER automatic discards
2708
+ * ({@link CoderAgent.strandedChatIds} — `maxRetries` can accumulate several)
2709
+ * are retired in the same call. On success each archived id is cleared as a
2710
+ * cleanup target; with no id at all, there is nothing to archive and it
2711
+ * resolves `{ archived: false }` without a server call (inspect the result
2712
+ * rather than relying on a silent no-op).
2713
+ *
2567
2714
  * A freshly interrupted/settled chat can keep winding down server-side for a
2568
2715
  * few seconds, during which the server rejects archiving with a 409. Those
2569
- * 409s are retried (~1s apart, ~15s overall — see `settleDeadlineMs` /
2570
- * `settleRetryDelayMs`) and the last one is rethrown if the chat never
2571
- * settles; any other failure, including a caller abort, rethrows immediately.
2716
+ * 409s are retried (~1s apart — see `settleRetryDelayMs`) inside ONE
2717
+ * `settleDeadlineMs` window (~15s) shared by the whole call — however many
2718
+ * chats it archives — and the last 409 is rethrown if the window closes
2719
+ * first; any other failure, including a caller abort, rethrows immediately.
2720
+ * Chats are archived oldest first, each id cleared as its archive succeeds —
2721
+ * a mid-list failure rethrows and leaves the remaining ids targetable by a
2722
+ * later retry.
2572
2723
  */
2573
2724
  async archive(opts) {
2574
- const id = this.#model.chatId;
2575
- if (!id) return;
2725
+ const primary = this.#model.chatId ?? this.#model.lastKnownChatId;
2726
+ if (!primary) return { archived: false };
2576
2727
  await this.#model.closePausedStream();
2577
- await archiveWhenSettled(this.#client, id, {
2578
- deadlineMs: this.#settleDeadlineMs,
2579
- retryDelayMs: this.#settleRetryDelayMs,
2580
- signal: opts?.signal
2581
- });
2728
+ const targets = [.../* @__PURE__ */ new Set([...this.#model.strandedChatIds, primary])];
2729
+ const deadline = Date.now() + this.#settleDeadlineMs;
2730
+ for (const id of targets) {
2731
+ await archiveWhenSettled(this.#client, id, {
2732
+ deadlineMs: Math.max(deadline - Date.now(), 1),
2733
+ retryDelayMs: this.#settleRetryDelayMs,
2734
+ signal: opts?.signal
2735
+ });
2736
+ this.#model.noteChatCleanedUp(id);
2737
+ }
2738
+ return { archived: true, chatId: primary, archivedChatIds: targets };
2582
2739
  }
2583
2740
  /**
2584
2741
  * Clean up the chat when the agent leaves an `await using` scope, so cleanup
2585
2742
  * rides scope exit instead of a separate call you have to remember in a
2586
2743
  * `finally`. Interrupts any in-flight server run, then archives the chat
2587
- * (retrying while the interrupted run settles). Best-effort and bounded
2744
+ * (retrying while the interrupted run settles) — including a chat stranded
2745
+ * by a session discard (see {@link CoderAgent.lastKnownChatId}), which a
2746
+ * failed `generate()` would otherwise leak. Best-effort and bounded
2588
2747
  * (~15s overall): disposal errors are swallowed after the bounded attempts
2589
2748
  * so they can't mask the scope's own error — call {@link CoderAgent.archive}
2590
2749
  * directly when you need guaranteed cleanup.