@coder/ai-sdk-agent 0.9.2 → 0.9.4

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/README.md CHANGED
@@ -1067,7 +1067,16 @@ What resuming does (and doesn't do):
1067
1067
  `requestTimeoutMs` waiting — it attributes nothing of the live run's output
1068
1068
  to itself (its window opens only when its own queued prompt materializes),
1069
1069
  and if it times out first, it withdraws its queued prompt rather than
1070
- leaving it to run unattended.
1070
+ leaving it to run unattended. The live run's turn is guarded symmetrically:
1071
+ the server starts the queued prompt's run **without ever emitting a
1072
+ terminal settle for the finished one**, so the SDK settles the finishing
1073
+ turn the moment the promoted prompt appears on its stream — nothing of the
1074
+ next turn's output or usage leaks into it. That settle reports
1075
+ `finishReason: "stop"` as a documented approximation: the wire doesn't
1076
+ distinguish a run that completed into a promotion from one interrupted by
1077
+ `busy_behavior: "interrupt"`, so an interrupted turn also settles `"stop"`
1078
+ (with whatever partial output it committed) rather than surfacing an
1079
+ interruption.
1071
1080
 
1072
1081
  ### Let the SDK absorb drops — and handle what it re‑throws
1073
1082
 
package/dist/index.d.ts CHANGED
@@ -1404,6 +1404,17 @@ interface TurnTranslatorOptions {
1404
1404
  * the queue entry materializes as this turn's own user message, every event
1405
1405
  * belongs to the chat's concurrent run and is withheld (see
1406
1406
  * {@link QueuedSubmissionGate} and {@link TurnTranslatorOptions.queuedSubmission}).
1407
+ *
1408
+ * The mirror direction is the PROMOTION SETTLE BOUNDARY: a user-role
1409
+ * `message` snapshot past the turn's effective cursor settles this turn
1410
+ * mid-run and freezes the translator, because chatd starts the chat's next
1411
+ * run without ever emitting a terminal status for the finished one (see
1412
+ * {@link settledAtPromotionBoundary}, issue #120). Both behaviors key off
1413
+ * the SAME promoted user snapshot — it is the submitting turn's anchor
1414
+ * (turn start) and the finishing turn's boundary (turn end) — and stay
1415
+ * independent because each keys strictly off its OWN turn's cursor: the
1416
+ * anchor advances the submitter's cursor TO the snapshot's id, while the
1417
+ * boundary requires an id strictly PAST the finisher's cursor.
1407
1418
  */
1408
1419
  declare class TurnTranslator {
1409
1420
  #private;
@@ -1415,9 +1426,10 @@ declare class TurnTranslator {
1415
1426
  get error(): ChatErrorPayload | undefined;
1416
1427
  /**
1417
1428
  * The turn's EFFECTIVE attribution cursor: the constructor's `turnCursor`
1418
- * until a queued submission anchors, the materialized user message's id
1419
- * after. Distinct from the stream's redial cursor, which deliberately stays
1420
- * the turn's original `after_id` (see `streamChatEvents`).
1429
+ * until a queued submission anchors (the materialized user message's id
1430
+ * after) or a zero-cursor turn adopts its own prompt's id (see
1431
+ * #ingestMessage). Distinct from the stream's redial cursor, which
1432
+ * deliberately stays the turn's original `after_id` (see `streamChatEvents`).
1421
1433
  */
1422
1434
  get turnCursor(): number;
1423
1435
  /**
@@ -1433,6 +1445,21 @@ declare class TurnTranslator {
1433
1445
  * fails fast instead of hanging until timeout/abort.
1434
1446
  */
1435
1447
  get queuedSubmissionDeadEnd(): ChatErrorPayload | undefined;
1448
+ /**
1449
+ * Whether the turn settled at a PROMOTION BOUNDARY (issue #120): a
1450
+ * user-role `message` snapshot with an id past the turn's effective cursor
1451
+ * arrived mid-run. chatd commits a mid-run user message only when it starts
1452
+ * the chat's NEXT run — `FinishTurn`/`FinishInterruption` promote the queue
1453
+ * head atomically with the previous run's finish (the chat transitions
1454
+ * `running → running` and NO terminal status is ever emitted for the
1455
+ * finished run), and interrupting a `requires_action` pause queues and then
1456
+ * promotes the same way — so the snapshot is proof this turn's own run is
1457
+ * over even though no terminal status was (or ever will be) sent for it.
1458
+ * The segment loop settles the turn here instead of absorbing the promoted
1459
+ * run's content, usage, and settle events; the successor run stays alive
1460
+ * and belongs to whoever submitted it, so teardown must NOT interrupt it.
1461
+ */
1462
+ get settledAtPromotionBoundary(): boolean;
1436
1463
  ingest(ev: ChatStreamEvent): LanguageModelV4StreamPart[];
1437
1464
  finish(): LanguageModelV4StreamPart[];
1438
1465
  }
package/dist/index.js CHANGED
@@ -1426,15 +1426,27 @@ var TurnTranslator = class {
1426
1426
  * initial sync when resuming a chat, or a mid-turn `history_reset` re-send
1427
1427
  * of the full history. Ingest skips them entirely so a replay can neither
1428
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
1429
+ * exactly two transitions, both of which pin the turn's OWN user message as
1430
+ * the effective floor: a QUEUED submission starts at the pre-turn cursor
1430
1431
  * 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.
1432
+ * (see {@link QueuedSubmissionGate}), and a ZERO cursor (this turn created
1433
+ * the chat) adopts the replay's first user snapshot — this turn's own
1434
+ * prompt — as the cursor (see #ingestMessage). Either way, replayed
1435
+ * pre-cursor tail messages then stay filtered on every redial, which reuses
1436
+ * the turn's ORIGINAL `after_id` and therefore re-delivers them.
1434
1437
  */
1435
1438
  #turnCursor;
1436
1439
  /** The queued-submission attribution gate; undefined once anchored (#114). */
1437
1440
  #queuedGate;
1441
+ /**
1442
+ * Set when the turn settled at a PROMOTION BOUNDARY (issue #120): a
1443
+ * user-role `message` snapshot with an id past the turn's effective cursor
1444
+ * arrived mid-run. Once set, the translator is FROZEN — every later event
1445
+ * is the successor run's traffic and ingests as a no-op — so nothing past
1446
+ * the boundary can leak into this turn's content, usage, or settle state.
1447
+ * See {@link settledAtPromotionBoundary} for why the snapshot is reliable.
1448
+ */
1449
+ #promotionBoundary = false;
1438
1450
  constructor(opts) {
1439
1451
  this.#dynamicToolNames = opts.dynamicToolNames;
1440
1452
  this.#turnCursor = opts.turnCursor ?? 0;
@@ -1465,9 +1477,10 @@ var TurnTranslator = class {
1465
1477
  }
1466
1478
  /**
1467
1479
  * 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`).
1480
+ * until a queued submission anchors (the materialized user message's id
1481
+ * after) or a zero-cursor turn adopts its own prompt's id (see
1482
+ * #ingestMessage). Distinct from the stream's redial cursor, which
1483
+ * deliberately stays the turn's original `after_id` (see `streamChatEvents`).
1471
1484
  */
1472
1485
  get turnCursor() {
1473
1486
  return this.#turnCursor;
@@ -1489,6 +1502,23 @@ var TurnTranslator = class {
1489
1502
  get queuedSubmissionDeadEnd() {
1490
1503
  return this.#queuedGate?.deadEnd;
1491
1504
  }
1505
+ /**
1506
+ * Whether the turn settled at a PROMOTION BOUNDARY (issue #120): a
1507
+ * user-role `message` snapshot with an id past the turn's effective cursor
1508
+ * arrived mid-run. chatd commits a mid-run user message only when it starts
1509
+ * the chat's NEXT run — `FinishTurn`/`FinishInterruption` promote the queue
1510
+ * head atomically with the previous run's finish (the chat transitions
1511
+ * `running → running` and NO terminal status is ever emitted for the
1512
+ * finished run), and interrupting a `requires_action` pause queues and then
1513
+ * promotes the same way — so the snapshot is proof this turn's own run is
1514
+ * over even though no terminal status was (or ever will be) sent for it.
1515
+ * The segment loop settles the turn here instead of absorbing the promoted
1516
+ * run's content, usage, and settle events; the successor run stays alive
1517
+ * and belongs to whoever submitted it, so teardown must NOT interrupt it.
1518
+ */
1519
+ get settledAtPromotionBoundary() {
1520
+ return this.#promotionBoundary;
1521
+ }
1492
1522
  // --- block helpers --------------------------------------------------------
1493
1523
  #openText(out) {
1494
1524
  if (this.#reasoning.id) this.#closeReasoning(out);
@@ -1631,6 +1661,7 @@ var TurnTranslator = class {
1631
1661
  }
1632
1662
  // --- ingest ---------------------------------------------------------------
1633
1663
  ingest(ev) {
1664
+ if (this.#promotionBoundary) return [];
1634
1665
  const gate = this.#queuedGate;
1635
1666
  if (gate) return this.#ingestGated(gate, ev);
1636
1667
  const out = [];
@@ -1794,8 +1825,18 @@ var TurnTranslator = class {
1794
1825
  }
1795
1826
  }
1796
1827
  #ingestMessage(out, message) {
1828
+ if (this.#promotionBoundary) return;
1797
1829
  if (message.id > this.#maxMessageId) this.#maxMessageId = message.id;
1798
1830
  if (message.id <= this.#turnCursor) return;
1831
+ if (message.role === "user") {
1832
+ if (this.#turnCursor === 0) {
1833
+ this.#turnCursor = message.id;
1834
+ return;
1835
+ }
1836
+ this.#promotionBoundary = true;
1837
+ this.#terminalStatus = void 0;
1838
+ return;
1839
+ }
1799
1840
  if (message.usage && message.role === "assistant") {
1800
1841
  this.#usageByMessageId.set(message.id, message.usage);
1801
1842
  }
@@ -1892,7 +1933,9 @@ var TurnTranslator = class {
1892
1933
  this.#closeReasoning(out);
1893
1934
  let unified;
1894
1935
  if (this.#error || this.#terminalStatus === "error") unified = "error";
1895
- else if (this.#clientToolCallSeen || this.#terminalStatus === "requires_action")
1936
+ else if (this.#promotionBoundary) {
1937
+ unified = "stop";
1938
+ } else if (this.#clientToolCallSeen || this.#terminalStatus === "requires_action")
1896
1939
  unified = "tool-calls";
1897
1940
  else unified = "stop";
1898
1941
  const usage = accumulateUsage(this.#usageByMessageId);
@@ -2315,6 +2358,7 @@ var CoderLanguageModel = class {
2315
2358
  const interrupt = () => {
2316
2359
  const id = turnChatId ?? this.#chatId;
2317
2360
  if (interruptSent || !id) return;
2361
+ if (translator?.settledAtPromotionBoundary) return;
2318
2362
  interruptSent = true;
2319
2363
  if (queuedTurn && (translator === void 0 || translator.awaitingQueuedSubmission)) {
2320
2364
  const entryId = queuedEntry?.id;
@@ -2532,6 +2576,7 @@ var CoderLanguageModel = class {
2532
2576
  }
2533
2577
  }
2534
2578
  for (const part of parts) yield part;
2579
+ if (translator.settledAtPromotionBoundary) break;
2535
2580
  const status = translator.terminalStatus;
2536
2581
  if (status) {
2537
2582
  if (status !== "requires_action") break;
@@ -2579,7 +2624,7 @@ var CoderLanguageModel = class {
2579
2624
  }
2580
2625
  }
2581
2626
  throwIfAborted();
2582
- if (!translator.terminalStatus && !translator.error) {
2627
+ if (!translator.terminalStatus && !translator.error && !translator.settledAtPromotionBoundary) {
2583
2628
  throw new CoderChatError({
2584
2629
  message: "Coder chat stream ended before the turn settled (connection closed or the server ended the stream without a terminal status).",
2585
2630
  kind: "stream_closed",
@@ -2600,7 +2645,7 @@ var CoderLanguageModel = class {
2600
2645
  if (timeoutSignal?.aborted) segmentChat.timeoutError = timeoutError();
2601
2646
  }
2602
2647
  signal?.removeEventListener("abort", interrupt);
2603
- if (!translator?.terminalStatus) interrupt();
2648
+ if (!translator?.terminalStatus && !translator?.settledAtPromotionBoundary) interrupt();
2604
2649
  if (!streamAcquired && this.#retainedStream) {
2605
2650
  const stale = this.#retainedStream;
2606
2651
  this.#retainedStream = void 0;