@coder/ai-sdk-agent 0.8.0 → 0.9.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
@@ -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,18 +817,42 @@ 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,
803
824
  opts?.signal
804
825
  );
805
826
  }
827
+ /**
828
+ * Withdraw a QUEUED (not yet materialized) message from the chat's
829
+ * submission queue (`DELETE /queue/{id}`). `queuedMessageId` is the
830
+ * queue-entry id from `CreateChatMessageResponse.queued_message` — a
831
+ * different id space from committed chat message ids. Rejects with a
832
+ * {@link CoderApiError}: 404 when the entry no longer exists (already
833
+ * promoted into history, or deleted), 409 when the chat has no queue.
834
+ */
835
+ deleteQueuedMessage(chatId, queuedMessageId, signal) {
836
+ return this.#request(
837
+ "deleteQueuedMessage",
838
+ "DELETE",
839
+ `${API_PREFIX}/${chatId}/queue/${queuedMessageId}`,
840
+ void 0,
841
+ signal
842
+ );
843
+ }
806
844
  updateChat(chatId, req, signal) {
807
- return this.#request("PATCH", `${API_PREFIX}/${chatId}`, req, signal);
845
+ return this.#request("updateChat", "PATCH", `${API_PREFIX}/${chatId}`, req, signal);
808
846
  }
809
847
  /** Convenience: archive a chat (soft-hide; safe for cleanup). */
810
848
  archiveChat(chatId, signal) {
811
- return this.updateChat(chatId, { archived: true }, signal);
849
+ return this.#request(
850
+ "archiveChat",
851
+ "PATCH",
852
+ `${API_PREFIX}/${chatId}`,
853
+ { archived: true },
854
+ signal
855
+ );
812
856
  }
813
857
  // --- Files ----------------------------------------------------------------
814
858
  /**
@@ -846,6 +890,7 @@ var CoderChatClient = class {
846
890
  headers["Content-Disposition"] = `attachment; filename="${ascii}"; filename*=UTF-8''${utf8}`;
847
891
  }
848
892
  const res = await this.#send(
893
+ "uploadChatFile",
849
894
  "POST",
850
895
  `${API_PREFIX}/files?organization=${encodeURIComponent(organizationId)}`,
851
896
  { body: resolved.body, headers, signal }
@@ -863,7 +908,9 @@ var CoderChatClient = class {
863
908
  }
864
909
  /** Download a chat file's bytes and media type by id. */
865
910
  async getChatFile(fileId, signal) {
866
- const res = await this.#send("GET", `${API_PREFIX}/files/${fileId}`, { signal });
911
+ const res = await this.#send("getChatFile", "GET", `${API_PREFIX}/files/${fileId}`, {
912
+ signal
913
+ });
867
914
  const buf = await res.arrayBuffer();
868
915
  return {
869
916
  bytes: new Uint8Array(buf),
@@ -917,7 +964,13 @@ var CoderChatClient = class {
917
964
  async resolveModelConfigId(hint, signal) {
918
965
  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
966
  if (UUID_RE.test(hint)) return hint;
920
- const raw = await this.listModelConfigs(signal);
967
+ const raw = await this.#request(
968
+ "resolveModelConfigId",
969
+ "GET",
970
+ `${API_PREFIX}/model-configs`,
971
+ void 0,
972
+ signal
973
+ );
921
974
  const configs = (Array.isArray(raw) ? raw : []).filter(
922
975
  (c) => typeof c === "object" && c !== null
923
976
  );
@@ -1312,6 +1365,16 @@ function joinContent(content, kind) {
1312
1365
  for (const p of content) if (p.type === kind) full += p.text ?? "";
1313
1366
  return full;
1314
1367
  }
1368
+ function canonicalJson(value) {
1369
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
1370
+ if (value !== null && typeof value === "object") {
1371
+ const entries = Object.entries(value);
1372
+ entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1373
+ const body = entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
1374
+ return `{${body.join(",")}}`;
1375
+ }
1376
+ return JSON.stringify(value) ?? "null";
1377
+ }
1315
1378
  var TurnTranslator = class {
1316
1379
  #dynamicToolNames;
1317
1380
  #submittedToolCallIds;
@@ -1362,13 +1425,30 @@ var TurnTranslator = class {
1362
1425
  * to a segment that already streamed) — they arrive only as replays: the
1363
1426
  * initial sync when resuming a chat, or a mid-turn `history_reset` re-send
1364
1427
  * of the full history. Ingest skips them entirely so a replay can neither
1365
- * re-emit earlier turns' content nor inflate this turn's usage.
1428
+ * re-emit earlier turns' content nor inflate this turn's usage. Mutable for
1429
+ * exactly one transition: a QUEUED submission starts at the pre-turn cursor
1430
+ * and advances to the materialized user message's id when the gate anchors
1431
+ * (see {@link QueuedSubmissionGate}) — replayed pre-anchor tail messages
1432
+ * then stay filtered on every redial, which reuses the turn's ORIGINAL
1433
+ * `after_id` and therefore re-delivers them.
1366
1434
  */
1367
1435
  #turnCursor;
1436
+ /** The queued-submission attribution gate; undefined once anchored (#114). */
1437
+ #queuedGate;
1368
1438
  constructor(opts) {
1369
1439
  this.#dynamicToolNames = opts.dynamicToolNames;
1370
1440
  this.#turnCursor = opts.turnCursor ?? 0;
1371
1441
  this.#submittedToolCallIds = opts.submittedToolCallIds ?? /* @__PURE__ */ new Set();
1442
+ const queued = opts.queuedSubmission;
1443
+ if (queued) {
1444
+ this.#queuedGate = {
1445
+ queuedId: queued.id,
1446
+ expected: queued.content !== void 0 ? canonicalJson(queued.content) : void 0,
1447
+ candidateId: void 0,
1448
+ buffered: [],
1449
+ deadEnd: void 0
1450
+ };
1451
+ }
1372
1452
  }
1373
1453
  get terminalStatus() {
1374
1454
  return this.#terminalStatus;
@@ -1383,6 +1463,32 @@ var TurnTranslator = class {
1383
1463
  get error() {
1384
1464
  return this.#error;
1385
1465
  }
1466
+ /**
1467
+ * The turn's EFFECTIVE attribution cursor: the constructor's `turnCursor`
1468
+ * until a queued submission anchors, the materialized user message's id
1469
+ * after. Distinct from the stream's redial cursor, which deliberately stays
1470
+ * the turn's original `after_id` (see `streamChatEvents`).
1471
+ */
1472
+ get turnCursor() {
1473
+ return this.#turnCursor;
1474
+ }
1475
+ /**
1476
+ * Whether a queued submission is still awaiting its own user-message
1477
+ * snapshot (#114). While true, nothing is attributed to this turn — no
1478
+ * content, usage, tool calls, or settle statuses — because the stream
1479
+ * window still belongs to the chat's concurrent run.
1480
+ */
1481
+ get awaitingQueuedSubmission() {
1482
+ return this.#queuedGate !== void 0;
1483
+ }
1484
+ /**
1485
+ * Set when a queued submission's wait can never complete (see
1486
+ * {@link QueuedSubmissionGate}); the model layer throws it so the turn
1487
+ * fails fast instead of hanging until timeout/abort.
1488
+ */
1489
+ get queuedSubmissionDeadEnd() {
1490
+ return this.#queuedGate?.deadEnd;
1491
+ }
1386
1492
  // --- block helpers --------------------------------------------------------
1387
1493
  #openText(out) {
1388
1494
  if (this.#reasoning.id) this.#closeReasoning(out);
@@ -1525,6 +1631,8 @@ var TurnTranslator = class {
1525
1631
  }
1526
1632
  // --- ingest ---------------------------------------------------------------
1527
1633
  ingest(ev) {
1634
+ const gate = this.#queuedGate;
1635
+ if (gate) return this.#ingestGated(gate, ev);
1528
1636
  const out = [];
1529
1637
  switch (ev.type) {
1530
1638
  case "message_part":
@@ -1573,6 +1681,82 @@ var TurnTranslator = class {
1573
1681
  }
1574
1682
  return out;
1575
1683
  }
1684
+ /**
1685
+ * Ingest while the queued-submission gate is up (#114): the stream window
1686
+ * still belongs to the chat's concurrent run, so nothing is attributed —
1687
+ * this only advances cursor bookkeeping, runs the anchor correlation
1688
+ * protocol (see {@link QueuedSubmissionGate}), and detects dead ends.
1689
+ * Returns parts only at the anchoring transition, when buffered snapshots
1690
+ * of this turn's own already-committed messages are replayed through the
1691
+ * normal path.
1692
+ */
1693
+ #ingestGated(gate, ev) {
1694
+ switch (ev.type) {
1695
+ case "message": {
1696
+ const message = ev.message;
1697
+ if (!message) break;
1698
+ if (message.id > this.#maxMessageId) this.#maxMessageId = message.id;
1699
+ if (message.id <= this.#turnCursor) break;
1700
+ if (message.role === "user" && this.#matchesQueuedContent(gate, message)) {
1701
+ if (gate.queuedId === void 0) return this.#anchorQueuedTurn(message.id);
1702
+ gate.candidateId = message.id;
1703
+ gate.buffered = [];
1704
+ } else if (gate.candidateId !== void 0) {
1705
+ gate.buffered.push(message);
1706
+ }
1707
+ break;
1708
+ }
1709
+ case "queue_update": {
1710
+ if (gate.queuedId === void 0) break;
1711
+ const queue = ev.queued_messages ?? [];
1712
+ if (queue.some((entry) => entry.id === gate.queuedId)) {
1713
+ gate.candidateId = void 0;
1714
+ gate.buffered = [];
1715
+ break;
1716
+ }
1717
+ if (gate.candidateId !== void 0) {
1718
+ const buffered = gate.buffered;
1719
+ const out = this.#anchorQueuedTurn(gate.candidateId);
1720
+ for (const message of buffered) this.#ingestMessage(out, message);
1721
+ return out;
1722
+ }
1723
+ gate.deadEnd = {
1724
+ message: "The queued submission left the chat's queue without materializing as this turn's user message (deleted externally, or its materialized form could not be correlated); this turn never started.",
1725
+ kind: "queued_submission_lost",
1726
+ retryable: false
1727
+ };
1728
+ break;
1729
+ }
1730
+ case "error": {
1731
+ if (!ev.error) break;
1732
+ gate.deadEnd = {
1733
+ message: "The chat's active run failed while this turn's submission was queued behind it; the queued message never materialized, so this turn never started.",
1734
+ detail: ev.error.detail ? `${ev.error.message}: ${ev.error.detail}` : ev.error.message,
1735
+ kind: "queued_submission_lost",
1736
+ retryable: false
1737
+ };
1738
+ break;
1739
+ }
1740
+ default:
1741
+ break;
1742
+ }
1743
+ return [];
1744
+ }
1745
+ /** Whether a user-message snapshot's content matches the queued entry's. */
1746
+ #matchesQueuedContent(gate, message) {
1747
+ if (gate.expected === void 0) return true;
1748
+ return canonicalJson(message.content ?? []) === gate.expected;
1749
+ }
1750
+ /**
1751
+ * Opens the turn's attribution window at `anchorId` — this turn's own user
1752
+ * message — and drops the gate. Replays of the pre-anchor tail (redials
1753
+ * reuse the turn's ORIGINAL cursor) stay filtered by the advanced cursor.
1754
+ */
1755
+ #anchorQueuedTurn(anchorId) {
1756
+ this.#turnCursor = anchorId;
1757
+ this.#queuedGate = void 0;
1758
+ return [];
1759
+ }
1576
1760
  #ingestMessagePart(out, ev) {
1577
1761
  const mp = ev.message_part;
1578
1762
  if (!mp || mp.role !== "assistant" && mp.role !== "tool") return;
@@ -1752,6 +1936,25 @@ var CoderLanguageModel = class {
1752
1936
  supportedUrls = {};
1753
1937
  #config;
1754
1938
  #chatId;
1939
+ /**
1940
+ * The last chat id this model created or attached to, retained as a CLEANUP
1941
+ * target — see {@link lastKnownChatId}. Unlike `#chatId` it survives
1942
+ * {@link resetSession}, and is only replaced when a later turn's chat
1943
+ * supersedes it or cleared by {@link noteChatCleanedUp} once cleanup
1944
+ * actually succeeded against it.
1945
+ */
1946
+ #lastKnownChatId;
1947
+ /**
1948
+ * Every chat id stranded by an AUTOMATIC session discard (a stream failure
1949
+ * on a chat the failed turn itself created) and not yet cleaned up. A
1950
+ * single `#lastKnownChatId` slot is not enough here: each AI-SDK
1951
+ * `maxRetries` attempt strands its own fresh chat and only the final
1952
+ * attempt's error reaches the caller, so without this ledger every attempt
1953
+ * before the last would leak unnamed. Drained by {@link noteChatCleanedUp}.
1954
+ * Deliberate abandonment ({@link resetSession}) does NOT record here — the
1955
+ * caller chose to walk away and may want the chat kept.
1956
+ */
1957
+ #strandedChatIds = /* @__PURE__ */ new Set();
1755
1958
  #lastSeenMessageId = 0;
1756
1959
  #resolvedModelConfigId;
1757
1960
  #modelResolved = false;
@@ -1767,15 +1970,80 @@ var CoderLanguageModel = class {
1767
1970
  this.#config = config;
1768
1971
  this.modelId = config.model ?? "chatd";
1769
1972
  this.#chatId = config.chatId;
1973
+ this.#lastKnownChatId = config.chatId;
1974
+ if (config.lastSeenMessageId !== void 0) {
1975
+ if (!Number.isSafeInteger(config.lastSeenMessageId) || config.lastSeenMessageId <= 0) {
1976
+ throw new CoderAgentError(
1977
+ `lastSeenMessageId must be a positive integer (got ${config.lastSeenMessageId}); persist the value of the lastSeenMessageId getter and supply it verbatim.`
1978
+ );
1979
+ }
1980
+ if (!config.chatId) {
1981
+ throw new CoderAgentError(
1982
+ "lastSeenMessageId requires chatId: a resume cursor is meaningless without the chat it belongs to."
1983
+ );
1984
+ }
1985
+ this.#lastSeenMessageId = config.lastSeenMessageId;
1986
+ }
1770
1987
  this.#emitTransportEvent = safeTransportEmitter(config.onTransportEvent);
1771
1988
  }
1772
1989
  get chatId() {
1773
1990
  return this.#chatId;
1774
1991
  }
1992
+ /**
1993
+ * The resume cursor: the newest chat message id this session has observed,
1994
+ * or `undefined` before any turn has streamed (nothing to persist then —
1995
+ * a fresh instance seeds from history instead). Persist it alongside
1996
+ * {@link chatId} once a turn settles and supply both to a future instance
1997
+ * ({@link CoderLanguageModelConfig.lastSeenMessageId}) to skip the resumed
1998
+ * turn's pre-prompt seed GET. Read it between turns, not mid-generation:
1999
+ * the cursor advances when a segment ends.
2000
+ */
2001
+ get lastSeenMessageId() {
2002
+ return this.#lastSeenMessageId > 0 ? this.#lastSeenMessageId : void 0;
2003
+ }
2004
+ /**
2005
+ * The last chat id this model created or attached to, for CLEANUP targeting
2006
+ * only — generation always targets {@link chatId}. It survives
2007
+ * {@link resetSession} (including the automatic session discard after a
2008
+ * fresh-chat stream failure, which clears {@link chatId} so a retry starts
2009
+ * fresh while the failed chat still exists server-side), is superseded when
2010
+ * a later turn creates or attaches to another chat, and is cleared via
2011
+ * {@link noteChatCleanedUp} once cleanup actually succeeded against it.
2012
+ */
2013
+ get lastKnownChatId() {
2014
+ return this.#lastKnownChatId;
2015
+ }
2016
+ /**
2017
+ * Chat ids stranded by automatic session discards and not yet cleaned up,
2018
+ * oldest first. Each retryable fresh-chat stream failure strands one chat;
2019
+ * under AI-SDK `maxRetries` several can accumulate while only the final
2020
+ * attempt's error (and {@link lastKnownChatId}) names the newest —
2021
+ * `CoderAgent.archive()` retires all of them. Ids leave the list only via
2022
+ * {@link noteChatCleanedUp}.
2023
+ */
2024
+ get strandedChatIds() {
2025
+ return [...this.#strandedChatIds];
2026
+ }
2027
+ /**
2028
+ * Forgets `chatId` as a cleanup target — call it only after cleanup
2029
+ * actually succeeded against that chat (e.g. `CoderAgent.archive()`
2030
+ * archived it), so later cleanup calls don't re-target an already-archived
2031
+ * chat: removes it from {@link strandedChatIds} and, when it is the
2032
+ * last-known id, clears {@link lastKnownChatId} (match-guarded: a stale
2033
+ * completion must not erase a newer chat's id). The session field
2034
+ * ({@link chatId}) is deliberately untouched — archiving does not reset the
2035
+ * session.
2036
+ */
2037
+ noteChatCleanedUp(chatId) {
2038
+ this.#strandedChatIds.delete(chatId);
2039
+ if (this.#lastKnownChatId === chatId) this.#lastKnownChatId = void 0;
2040
+ }
1775
2041
  /**
1776
2042
  * Drops the current session so the next turn creates a fresh chat. Also
1777
2043
  * closes the retained event stream, if any (fire-and-forget — a sync reset
1778
2044
  * must not wait on socket teardown, and `close()` never rejects).
2045
+ * {@link lastKnownChatId} deliberately survives the reset: the dropped chat
2046
+ * still exists server-side and cleanup must still be able to target it.
1779
2047
  */
1780
2048
  resetSession() {
1781
2049
  this.#chatId = void 0;
@@ -1886,9 +2154,9 @@ var CoderLanguageModel = class {
1886
2154
  * pending calls — nothing to recover, the caller keeps waiting on the
1887
2155
  * stream, bounded by `requestTimeoutMs`/abort.
1888
2156
  */
1889
- async #recoverRequiresAction(chatId, dynamicNames, afterId, signal) {
2157
+ async #recoverRequiresAction(chatId, dynamicNames, turnCursor, signal) {
1890
2158
  const { messages } = await this.#config.client.getMessages(chatId, { limit: 200 }, signal);
1891
- const turnMessages = messages.filter((m) => m.id > (afterId ?? 0)).sort((a, b) => a.id - b.id);
2159
+ const turnMessages = messages.filter((m) => m.id > turnCursor).sort((a, b) => a.id - b.id);
1892
2160
  const lastAssistant = turnMessages.findLast((m) => m.role === "assistant");
1893
2161
  if (!lastAssistant) return [];
1894
2162
  const handled = /* @__PURE__ */ new Set();
@@ -2042,10 +2310,20 @@ var CoderLanguageModel = class {
2042
2310
  let discardSession = false;
2043
2311
  let interruptSent = false;
2044
2312
  let turnChatId;
2313
+ let queuedTurn = false;
2314
+ let queuedEntry;
2045
2315
  const interrupt = () => {
2046
2316
  const id = turnChatId ?? this.#chatId;
2047
2317
  if (interruptSent || !id) return;
2048
2318
  interruptSent = true;
2319
+ if (queuedTurn && (translator === void 0 || translator.awaitingQueuedSubmission)) {
2320
+ const entryId = queuedEntry?.id;
2321
+ if (entryId !== void 0) {
2322
+ void this.#config.client.deleteQueuedMessage(id, entryId).catch(() => {
2323
+ });
2324
+ }
2325
+ return;
2326
+ }
2049
2327
  void this.#config.client.interruptChat(id).catch(() => {
2050
2328
  });
2051
2329
  };
@@ -2129,7 +2407,15 @@ var CoderLanguageModel = class {
2129
2407
  },
2130
2408
  signal
2131
2409
  );
2132
- afterId = resp.message?.id ?? this.#lastSeenMessageId;
2410
+ if (resp.message) {
2411
+ afterId = resp.message.id;
2412
+ } else if (resp.queued) {
2413
+ afterId = this.#lastSeenMessageId;
2414
+ queuedTurn = true;
2415
+ queuedEntry = resp.queued_message;
2416
+ } else {
2417
+ afterId = this.#lastSeenMessageId;
2418
+ }
2133
2419
  }
2134
2420
  } else {
2135
2421
  if (!this.#chatId)
@@ -2145,16 +2431,24 @@ var CoderLanguageModel = class {
2145
2431
  }
2146
2432
  const chatId = this.#chatId;
2147
2433
  turnChatId = chatId;
2434
+ this.#lastKnownChatId = chatId;
2148
2435
  if (segmentChat) segmentChat.chatId = chatId;
2149
2436
  const dynamicNames = dynamicToolNames(options.tools);
2150
- translator = new TurnTranslator({
2437
+ const translatorOptions = {
2151
2438
  dynamicToolNames: dynamicNames,
2152
2439
  turnCursor: afterId ?? 0,
2153
2440
  // The retained stream redials with the TURN's original cursor, so a
2154
2441
  // reconnect can replay an earlier segment's `action_required`
2155
2442
  // snapshot; calls this session already answered must not run again.
2156
2443
  submittedToolCallIds: this.#submittedToolCallIds
2157
- });
2444
+ };
2445
+ if (queuedTurn) {
2446
+ const queuedSubmission = {};
2447
+ if (queuedEntry?.id !== void 0) queuedSubmission.id = queuedEntry.id;
2448
+ if (queuedEntry?.content !== void 0) queuedSubmission.content = queuedEntry.content;
2449
+ translatorOptions.queuedSubmission = queuedSubmission;
2450
+ }
2451
+ translator = new TurnTranslator(translatorOptions);
2158
2452
  let sinceRequiresAction = 0;
2159
2453
  let grace;
2160
2454
  let recovery;
@@ -2195,7 +2489,11 @@ var CoderLanguageModel = class {
2195
2489
  recovery = this.#recoverRequiresAction(
2196
2490
  chatId,
2197
2491
  dynamicNames,
2198
- afterId,
2492
+ // The turn's EFFECTIVE cursor: for a queued submission it has
2493
+ // advanced past the materialized user message (a pause can
2494
+ // only arm after the gate anchored), so the derivation never
2495
+ // sees the concurrent predecessor run's tail (#114).
2496
+ translator.turnCursor,
2199
2497
  signal ? AbortSignal.any([signal, recoveryAbort.signal]) : recoveryAbort.signal
2200
2498
  ).catch(() => []);
2201
2499
  continue;
@@ -2219,6 +2517,8 @@ var CoderLanguageModel = class {
2219
2517
  stream.consumed(next);
2220
2518
  if (raced.done) break;
2221
2519
  const parts = translator.ingest(raced.value);
2520
+ const deadEnd = translator.queuedSubmissionDeadEnd;
2521
+ if (deadEnd) throw new CoderChatError(deadEnd);
2222
2522
  if (raced.value.type === "error" && translator.error && !translator.terminalStatus) {
2223
2523
  const peek = stream.read();
2224
2524
  const bound = startGraceTimer(0);
@@ -2243,7 +2543,11 @@ var CoderLanguageModel = class {
2243
2543
  } catch (err) {
2244
2544
  throwIfAborted();
2245
2545
  if (err instanceof CoderStreamError) {
2546
+ if (err.chatId === void 0 && turnChatId !== void 0) {
2547
+ err.chatId = turnChatId;
2548
+ }
2246
2549
  if (turnCreatedChat) {
2550
+ if (turnChatId) this.#strandedChatIds.add(turnChatId);
2247
2551
  discardSession = true;
2248
2552
  }
2249
2553
  const effectful = chatHasWorkspace || chatHasMcpServers || uploadedAttachment;
@@ -2252,7 +2556,8 @@ var CoderLanguageModel = class {
2252
2556
  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
2557
  url: err.url,
2254
2558
  cause: err,
2255
- isRetryable: false
2559
+ isRetryable: false,
2560
+ chatId: err.chatId
2256
2561
  });
2257
2562
  }
2258
2563
  if (err instanceof CoderAgentError && !(err instanceof CoderApiError) && !(err instanceof CoderChatError)) {
@@ -2503,6 +2808,7 @@ var CoderAgent = class {
2503
2808
  mcpServerIds: settings.mcpServerIds,
2504
2809
  planMode: settings.planMode,
2505
2810
  chatId: settings.chatId,
2811
+ lastSeenMessageId: settings.lastSeenMessageId,
2506
2812
  requestTimeoutMs: settings.requestTimeoutMs,
2507
2813
  onTransportEvent: settings.onTransportEvent
2508
2814
  });
@@ -2531,6 +2837,39 @@ var CoderAgent = class {
2531
2837
  get chatId() {
2532
2838
  return this.#model.chatId;
2533
2839
  }
2840
+ /**
2841
+ * The session's resume cursor: the newest chat message id observed, or
2842
+ * `undefined` before any turn has streamed. Persist it alongside
2843
+ * {@link chatId} when a turn ends and supply both to a future instance
2844
+ * ({@link CoderAgentSettings.lastSeenMessageId}) to skip the resumed turn's
2845
+ * pre-prompt seed GET. Read it between turns, not mid-generation.
2846
+ */
2847
+ get lastSeenMessageId() {
2848
+ return this.#model.lastSeenMessageId;
2849
+ }
2850
+ /**
2851
+ * The last chat id this agent created or attached to, retained for CLEANUP
2852
+ * even after the session was dropped — a fresh-chat stream failure discards
2853
+ * the session ({@link CoderAgent.chatId} becomes `undefined`, so a retry
2854
+ * starts fresh), but the failed chat still exists server-side.
2855
+ * {@link CoderAgent.archive} and {@link CoderAgent.interrupt} fall back to
2856
+ * this id; generation never does. Superseded when a later turn creates or
2857
+ * attaches to another chat; cleared once {@link CoderAgent.archive}
2858
+ * succeeds against it.
2859
+ */
2860
+ get lastKnownChatId() {
2861
+ return this.#model.lastKnownChatId;
2862
+ }
2863
+ /**
2864
+ * Chat ids stranded by automatic session discards and not yet cleaned up,
2865
+ * oldest first. Each retryable fresh-chat stream failure strands one chat,
2866
+ * so with `maxRetries` > 0 several can accumulate while only the final
2867
+ * attempt's error (and {@link CoderAgent.lastKnownChatId}) names the
2868
+ * newest. {@link CoderAgent.archive} retires all of them.
2869
+ */
2870
+ get strandedChatIds() {
2871
+ return this.#model.strandedChatIds;
2872
+ }
2534
2873
  generate(options) {
2535
2874
  return this.#inner.generate(options);
2536
2875
  }
@@ -2546,7 +2885,12 @@ var CoderAgent = class {
2546
2885
  listModels(signal) {
2547
2886
  return this.#client.listModelConfigs(signal);
2548
2887
  }
2549
- /** Start a fresh chatd chat on the next turn. */
2888
+ /**
2889
+ * Start a fresh chatd chat on the next turn. The dropped chat still exists
2890
+ * server-side: {@link CoderAgent.archive} / {@link CoderAgent.interrupt}
2891
+ * keep targeting it (via {@link CoderAgent.lastKnownChatId}) until a new
2892
+ * chat supersedes it or an archive succeeds against it.
2893
+ */
2550
2894
  resetSession() {
2551
2895
  this.#model.resetSession();
2552
2896
  }
@@ -2554,37 +2898,67 @@ var CoderAgent = class {
2554
2898
  * Interrupt the in-flight generation, if any. Resolves as soon as the server
2555
2899
  * acknowledges the interrupt — the run keeps winding down asynchronously for
2556
2900
  * a few seconds afterwards (see {@link CoderAgent.archive}).
2901
+ *
2902
+ * Targets the session's chat, or — after the session was dropped (a
2903
+ * fresh-chat stream failure, or {@link CoderAgent.resetSession}) — the
2904
+ * {@link CoderAgent.lastKnownChatId}, whose run may still be winding down
2905
+ * server-side. With neither, there is nothing to interrupt and it resolves
2906
+ * `{ interrupted: false }` without a server call (inspect the result rather
2907
+ * than relying on a silent no-op).
2557
2908
  */
2558
2909
  async interrupt(opts) {
2559
- const id = this.#model.chatId;
2560
- if (!id) return;
2910
+ const id = this.#model.chatId ?? this.#model.lastKnownChatId;
2911
+ if (!id) return { interrupted: false };
2561
2912
  await this.#model.closePausedStream();
2562
2913
  await this.#client.interruptChat(id, opts?.signal);
2914
+ return { interrupted: true, chatId: id };
2563
2915
  }
2564
2916
  /**
2565
2917
  * Archive the underlying chat (safe cleanup; hides it from listings).
2566
2918
  *
2919
+ * Targets the session's chat, or — after the session was dropped (a
2920
+ * fresh-chat stream failure, or {@link CoderAgent.resetSession}) — the
2921
+ * {@link CoderAgent.lastKnownChatId}, so the stranded chat is still cleaned
2922
+ * up; chats stranded by EARLIER automatic discards
2923
+ * ({@link CoderAgent.strandedChatIds} — `maxRetries` can accumulate several)
2924
+ * are retired in the same call. On success each archived id is cleared as a
2925
+ * cleanup target; with no id at all, there is nothing to archive and it
2926
+ * resolves `{ archived: false }` without a server call (inspect the result
2927
+ * rather than relying on a silent no-op).
2928
+ *
2567
2929
  * A freshly interrupted/settled chat can keep winding down server-side for a
2568
2930
  * 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.
2931
+ * 409s are retried (~1s apart — see `settleRetryDelayMs`) inside ONE
2932
+ * `settleDeadlineMs` window (~15s) shared by the whole call — however many
2933
+ * chats it archives — and the last 409 is rethrown if the window closes
2934
+ * first; any other failure, including a caller abort, rethrows immediately.
2935
+ * Chats are archived oldest first, each id cleared as its archive succeeds —
2936
+ * a mid-list failure rethrows and leaves the remaining ids targetable by a
2937
+ * later retry.
2572
2938
  */
2573
2939
  async archive(opts) {
2574
- const id = this.#model.chatId;
2575
- if (!id) return;
2940
+ const primary = this.#model.chatId ?? this.#model.lastKnownChatId;
2941
+ if (!primary) return { archived: false };
2576
2942
  await this.#model.closePausedStream();
2577
- await archiveWhenSettled(this.#client, id, {
2578
- deadlineMs: this.#settleDeadlineMs,
2579
- retryDelayMs: this.#settleRetryDelayMs,
2580
- signal: opts?.signal
2581
- });
2943
+ const targets = [.../* @__PURE__ */ new Set([...this.#model.strandedChatIds, primary])];
2944
+ const deadline = Date.now() + this.#settleDeadlineMs;
2945
+ for (const id of targets) {
2946
+ await archiveWhenSettled(this.#client, id, {
2947
+ deadlineMs: Math.max(deadline - Date.now(), 1),
2948
+ retryDelayMs: this.#settleRetryDelayMs,
2949
+ signal: opts?.signal
2950
+ });
2951
+ this.#model.noteChatCleanedUp(id);
2952
+ }
2953
+ return { archived: true, chatId: primary, archivedChatIds: targets };
2582
2954
  }
2583
2955
  /**
2584
2956
  * Clean up the chat when the agent leaves an `await using` scope, so cleanup
2585
2957
  * rides scope exit instead of a separate call you have to remember in a
2586
2958
  * `finally`. Interrupts any in-flight server run, then archives the chat
2587
- * (retrying while the interrupted run settles). Best-effort and bounded
2959
+ * (retrying while the interrupted run settles) — including a chat stranded
2960
+ * by a session discard (see {@link CoderAgent.lastKnownChatId}), which a
2961
+ * failed `generate()` would otherwise leak. Best-effort and bounded
2588
2962
  * (~15s overall): disposal errors are swallowed after the bounded attempts
2589
2963
  * so they can't mask the scope's own error — call {@link CoderAgent.archive}
2590
2964
  * directly when you need guaranteed cleanup.