@coder/ai-sdk-agent 0.4.3 → 0.5.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.d.ts CHANGED
@@ -873,6 +873,15 @@ interface CoderLanguageModelConfig {
873
873
  *
874
874
  * NOTE: a single model instance is single-flight — do not run concurrent
875
875
  * generations against the same instance/session.
876
+ *
877
+ * The session retains ONE event stream per turn (issue #44): a segment that
878
+ * pauses for client tools leaves the WebSocket open, and the tool-result
879
+ * resume segment keeps reading it — no re-dial per tool round trip. The
880
+ * socket closes when its segment ends any other way (terminal settle, error,
881
+ * abort, timeout), when the next turn replaces it, or on session teardown
882
+ * (`resetSession()` / `[Symbol.asyncDispose]()`). A turn that pauses for
883
+ * client tools but never resumes leaves the socket open until teardown, so
884
+ * dispose or reset sessions you abandon mid-turn.
876
885
  */
877
886
  declare class CoderLanguageModel implements LanguageModelV4 {
878
887
  #private;
@@ -882,8 +891,32 @@ declare class CoderLanguageModel implements LanguageModelV4 {
882
891
  readonly supportedUrls: Record<string, RegExp[]>;
883
892
  constructor(config: CoderLanguageModelConfig);
884
893
  get chatId(): string | undefined;
885
- /** Drops the current session so the next turn creates a fresh chat. */
894
+ /**
895
+ * Drops the current session so the next turn creates a fresh chat. Also
896
+ * closes the retained event stream, if any (fire-and-forget — a sync reset
897
+ * must not wait on socket teardown, and `close()` never rejects).
898
+ */
886
899
  resetSession(): void;
900
+ /**
901
+ * Closes the retained event stream, if any. The stream otherwise closes
902
+ * when its turn settles or fails, or when the next turn replaces it — but a
903
+ * turn that pauses for client tools and never resumes (e.g. the caller
904
+ * stopped the tool loop, or aborted between segments) leaves the socket
905
+ * open, so dispose (`await using`) or {@link resetSession} sessions you
906
+ * abandon mid-turn.
907
+ */
908
+ [Symbol.asyncDispose](): Promise<void>;
909
+ /**
910
+ * Closes a stream retained by a client-tool pause. Called on explicit
911
+ * interrupts ({@link CoderAgent.interrupt}): an interrupted pause is dead —
912
+ * its buffer would otherwise collect the interrupt's own settle events,
913
+ * which a later tool-result resume would consume as if they were the
914
+ * resumed generation's (truncating the turn), and if the tool loop never
915
+ * resumes the socket would outlive the explicit interrupt. A stream a
916
+ * segment is actively reading is left alone: that segment observes the
917
+ * settle the interrupt produces and closes the stream itself.
918
+ */
919
+ closePausedStream(): Promise<void>;
887
920
  doStream(options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult>;
888
921
  doGenerate(options: LanguageModelV4CallOptions): Promise<LanguageModelV4GenerateResult>;
889
922
  }
@@ -911,6 +944,15 @@ declare class TurnTranslator {
911
944
  constructor(opts: {
912
945
  dynamicToolNames: ReadonlySet<string>;
913
946
  turnCursor?: number;
947
+ /**
948
+ * Tool-call ids whose results the session already submitted. With the
949
+ * stream retained across segments (and redialed with the turn's original
950
+ * cursor), a reconnect can replay a previous segment's `action_required`
951
+ * snapshot; re-emitting an already-answered call would make the AI SDK
952
+ * execute the tool a second time. A live reference — the set grows as
953
+ * later segments submit results.
954
+ */
955
+ submittedToolCallIds?: ReadonlySet<string>;
914
956
  });
915
957
  get terminalStatus(): ChatStatus | undefined;
916
958
  /** Whether a client (custom) tool call has been emitted this turn. */
package/dist/index.js CHANGED
@@ -1016,6 +1016,95 @@ function dynamicToolNames(tools) {
1016
1016
  return names;
1017
1017
  }
1018
1018
 
1019
+ // src/model/session-stream.ts
1020
+ var SessionChatStream = class {
1021
+ chatId;
1022
+ #events;
1023
+ #controller = new AbortController();
1024
+ #pending;
1025
+ #attached = true;
1026
+ #reusable = false;
1027
+ #closePromise;
1028
+ constructor(opts) {
1029
+ this.chatId = opts.chatId;
1030
+ this.#events = opts.open(this.#controller.signal);
1031
+ }
1032
+ get closed() {
1033
+ return this.#closePromise !== void 0;
1034
+ }
1035
+ /** Whether a segment is currently consuming this stream. */
1036
+ get attached() {
1037
+ return this.#attached;
1038
+ }
1039
+ /** Whether a resume segment for `chatId` may {@link attach}. */
1040
+ canAttach(chatId) {
1041
+ return !this.closed && this.#reusable && this.chatId === chatId;
1042
+ }
1043
+ /**
1044
+ * The next event, memoized: repeated calls return the SAME promise until
1045
+ * {@link consumed} acknowledges delivery, so a read abandoned by one segment
1046
+ * is handed to the next instead of losing its event.
1047
+ */
1048
+ read() {
1049
+ if (!this.#pending) {
1050
+ if (!this.#attached && !this.closed) {
1051
+ throw new CoderAgentError("SessionChatStream.read() without an attached segment");
1052
+ }
1053
+ this.#pending = this.#events.next();
1054
+ this.#pending.catch(() => {
1055
+ });
1056
+ }
1057
+ return this.#pending;
1058
+ }
1059
+ /**
1060
+ * Acknowledge that the segment received `p`'s result and the next
1061
+ * {@link read} should issue a fresh read. A no-op unless `p` is the
1062
+ * outstanding read — a raced-out segment never consumes.
1063
+ */
1064
+ consumed(p) {
1065
+ if (this.#pending === p) this.#pending = void 0;
1066
+ }
1067
+ /** Attach the resume segment to a stream paused at `requires_action`. */
1068
+ attach() {
1069
+ if (!this.canAttach(this.chatId) || this.#attached) {
1070
+ throw new CoderAgentError("SessionChatStream.attach() on a non-reusable stream");
1071
+ }
1072
+ this.#attached = true;
1073
+ this.#reusable = false;
1074
+ }
1075
+ /**
1076
+ * Segment settled at a healthy `requires_action` pause: keep the stream for
1077
+ * the tool-result resume, with a read outstanding so the reader's redial
1078
+ * machinery stays live while the client tools execute.
1079
+ */
1080
+ pause() {
1081
+ this.#attached = false;
1082
+ if (this.closed) return;
1083
+ this.#reusable = true;
1084
+ if (!this.#pending) {
1085
+ this.#pending = this.#events.next();
1086
+ this.#pending.catch(() => {
1087
+ });
1088
+ }
1089
+ }
1090
+ /**
1091
+ * Close the underlying reader (and socket). Idempotent; safe on a stream
1092
+ * whose generator already ended or threw.
1093
+ */
1094
+ close() {
1095
+ this.#closePromise ??= (async () => {
1096
+ this.#attached = false;
1097
+ this.#reusable = false;
1098
+ this.#controller.abort();
1099
+ try {
1100
+ await this.#events.return(void 0);
1101
+ } catch {
1102
+ }
1103
+ })();
1104
+ return this.#closePromise;
1105
+ }
1106
+ };
1107
+
1019
1108
  // src/model/translate.ts
1020
1109
  function addDefined(a, b) {
1021
1110
  if (b === void 0) return a;
@@ -1075,6 +1164,7 @@ function jsonResult(value) {
1075
1164
  }
1076
1165
  var TurnTranslator = class {
1077
1166
  #dynamicToolNames;
1167
+ #submittedToolCallIds;
1078
1168
  #seq = 0;
1079
1169
  #text = { id: void 0, len: 0, sawDelta: false };
1080
1170
  #reasoning = { id: void 0, len: 0, sawDelta: false };
@@ -1111,6 +1201,7 @@ var TurnTranslator = class {
1111
1201
  constructor(opts) {
1112
1202
  this.#dynamicToolNames = opts.dynamicToolNames;
1113
1203
  this.#turnCursor = opts.turnCursor ?? 0;
1204
+ this.#submittedToolCallIds = opts.submittedToolCallIds ?? /* @__PURE__ */ new Set();
1114
1205
  }
1115
1206
  get terminalStatus() {
1116
1207
  return this.#terminalStatus;
@@ -1260,6 +1351,7 @@ var TurnTranslator = class {
1260
1351
  case "action_required":
1261
1352
  for (const tc of ev.action_required?.tool_calls ?? []) {
1262
1353
  if (this.#clientToolCalls.has(tc.tool_call_id)) continue;
1354
+ if (this.#submittedToolCallIds.has(tc.tool_call_id)) continue;
1263
1355
  this.#closeText(out);
1264
1356
  this.#closeReasoning(out);
1265
1357
  this.#clientToolCalls.add(tc.tool_call_id);
@@ -1281,8 +1373,12 @@ var TurnTranslator = class {
1281
1373
  }
1282
1374
  break;
1283
1375
  case "status":
1284
- if (ev.status && TERMINAL_STATUSES.has(ev.status.status)) {
1285
- this.#terminalStatus = ev.status.status;
1376
+ if (ev.status) {
1377
+ if (TERMINAL_STATUSES.has(ev.status.status)) {
1378
+ this.#terminalStatus = ev.status.status;
1379
+ } else {
1380
+ this.#terminalStatus = void 0;
1381
+ }
1286
1382
  }
1287
1383
  break;
1288
1384
  default:
@@ -1416,6 +1512,7 @@ var TurnTranslator = class {
1416
1512
  // src/model/language-model.ts
1417
1513
  var ACTION_REQUIRED_GRACE_MS = 2e3;
1418
1514
  var GRACE_EXPIRED = /* @__PURE__ */ Symbol("action-required-grace-expired");
1515
+ var SEGMENT_ABORTED = /* @__PURE__ */ Symbol("segment-aborted");
1419
1516
  function startGraceTimer(ms) {
1420
1517
  let timer;
1421
1518
  const expired = new Promise((resolve) => {
@@ -1444,6 +1541,8 @@ var CoderLanguageModel = class {
1444
1541
  #modelResolved = false;
1445
1542
  #submittedToolCallIds = /* @__PURE__ */ new Set();
1446
1543
  #inFlight = false;
1544
+ /** The turn-lifetime event stream, retained across client-tool pauses. */
1545
+ #retainedStream;
1447
1546
  constructor(config) {
1448
1547
  assertSupportedAiVersion();
1449
1548
  this.#config = config;
@@ -1453,11 +1552,75 @@ var CoderLanguageModel = class {
1453
1552
  get chatId() {
1454
1553
  return this.#chatId;
1455
1554
  }
1456
- /** Drops the current session so the next turn creates a fresh chat. */
1555
+ /**
1556
+ * Drops the current session so the next turn creates a fresh chat. Also
1557
+ * closes the retained event stream, if any (fire-and-forget — a sync reset
1558
+ * must not wait on socket teardown, and `close()` never rejects).
1559
+ */
1457
1560
  resetSession() {
1458
1561
  this.#chatId = void 0;
1459
1562
  this.#lastSeenMessageId = 0;
1460
1563
  this.#submittedToolCallIds.clear();
1564
+ const stream = this.#retainedStream;
1565
+ this.#retainedStream = void 0;
1566
+ if (stream) void stream.close();
1567
+ }
1568
+ /**
1569
+ * Closes the retained event stream, if any. The stream otherwise closes
1570
+ * when its turn settles or fails, or when the next turn replaces it — but a
1571
+ * turn that pauses for client tools and never resumes (e.g. the caller
1572
+ * stopped the tool loop, or aborted between segments) leaves the socket
1573
+ * open, so dispose (`await using`) or {@link resetSession} sessions you
1574
+ * abandon mid-turn.
1575
+ */
1576
+ async [Symbol.asyncDispose]() {
1577
+ const stream = this.#retainedStream;
1578
+ this.#retainedStream = void 0;
1579
+ if (stream) await stream.close();
1580
+ }
1581
+ /**
1582
+ * Closes a stream retained by a client-tool pause. Called on explicit
1583
+ * interrupts ({@link CoderAgent.interrupt}): an interrupted pause is dead —
1584
+ * its buffer would otherwise collect the interrupt's own settle events,
1585
+ * which a later tool-result resume would consume as if they were the
1586
+ * resumed generation's (truncating the turn), and if the tool loop never
1587
+ * resumes the socket would outlive the explicit interrupt. A stream a
1588
+ * segment is actively reading is left alone: that segment observes the
1589
+ * settle the interrupt produces and closes the stream itself.
1590
+ */
1591
+ async closePausedStream() {
1592
+ const stream = this.#retainedStream;
1593
+ if (!stream || stream.attached) return;
1594
+ this.#retainedStream = void 0;
1595
+ await stream.close();
1596
+ }
1597
+ /**
1598
+ * The stream a segment reads: a resume segment re-attaches to the stream
1599
+ * its predecessor left paused at `requires_action` (the whole point of
1600
+ * retention — no re-dial per tool round trip); everything else — a new
1601
+ * turn, or a pause the caller abandoned — replaces it with a fresh dial,
1602
+ * because a stale buffer from a settle this client never observed must not
1603
+ * leak into the new turn.
1604
+ */
1605
+ #acquireStream(resume, chatId, afterId) {
1606
+ const retained = this.#retainedStream;
1607
+ if (resume && retained?.canAttach(chatId)) {
1608
+ retained.attach();
1609
+ return retained;
1610
+ }
1611
+ this.#retainedStream = void 0;
1612
+ if (retained) void retained.close();
1613
+ const created = new SessionChatStream({
1614
+ chatId,
1615
+ open: (streamSignal) => this.#config.client.streamEvents(chatId, { afterId, signal: streamSignal })
1616
+ });
1617
+ this.#retainedStream = created;
1618
+ return created;
1619
+ }
1620
+ /** Close `stream` and drop the retention slot if it still holds it. */
1621
+ async #closeStream(stream) {
1622
+ if (this.#retainedStream === stream) this.#retainedStream = void 0;
1623
+ await stream.close();
1461
1624
  }
1462
1625
  async #resolveModelConfigId(signal) {
1463
1626
  if (this.#modelResolved) return this.#resolvedModelConfigId;
@@ -1559,10 +1722,12 @@ var CoderLanguageModel = class {
1559
1722
  );
1560
1723
  const signal = sources.length > 0 ? AbortSignal.any(sources) : void 0;
1561
1724
  let translator;
1725
+ let streamAcquired = false;
1562
1726
  let discardSession = false;
1563
1727
  let interruptSent = false;
1728
+ let turnChatId;
1564
1729
  const interrupt = () => {
1565
- const id = this.#chatId;
1730
+ const id = turnChatId ?? this.#chatId;
1566
1731
  if (interruptSent || !id) return;
1567
1732
  interruptSent = true;
1568
1733
  void this.#config.client.interruptChat(id).catch(() => {
@@ -1660,25 +1825,48 @@ var CoderLanguageModel = class {
1660
1825
  afterId = this.#lastSeenMessageId;
1661
1826
  }
1662
1827
  const chatId = this.#chatId;
1828
+ turnChatId = chatId;
1663
1829
  const dynamicNames = dynamicToolNames(options.tools);
1664
1830
  translator = new TurnTranslator({
1665
1831
  dynamicToolNames: dynamicNames,
1666
- turnCursor: afterId ?? 0
1832
+ turnCursor: afterId ?? 0,
1833
+ // The retained stream redials with the TURN's original cursor, so a
1834
+ // reconnect can replay an earlier segment's `action_required`
1835
+ // snapshot; calls this session already answered must not run again.
1836
+ submittedToolCallIds: this.#submittedToolCallIds
1667
1837
  });
1668
1838
  let sinceRequiresAction = 0;
1669
1839
  let grace;
1670
1840
  let recovery;
1671
1841
  let recoveryAbort;
1672
1842
  let recoveryAttempted = false;
1673
- const stream = this.#config.client.streamEvents(chatId, { afterId, signal });
1843
+ const stream = this.#acquireStream(action.kind === "resume", chatId, afterId);
1844
+ streamAcquired = true;
1845
+ let onSegmentAbort;
1846
+ const segmentAborted = signal ? new Promise((resolve) => {
1847
+ onSegmentAbort = () => resolve(SEGMENT_ABORTED);
1848
+ if (signal.aborted) onSegmentAbort();
1849
+ else signal.addEventListener("abort", onSegmentAbort, { once: true });
1850
+ }) : void 0;
1851
+ const raceSegment = (p) => segmentAborted ? Promise.race([p, segmentAborted]) : p;
1852
+ let retainStream = false;
1674
1853
  try {
1675
- let next = stream.next();
1676
1854
  for (; ; ) {
1677
- let result;
1855
+ const next = stream.read();
1678
1856
  const awaitingActionRequired = translator.terminalStatus === "requires_action" && !translator.clientToolCallSeen;
1857
+ if (!awaitingActionRequired && (grace !== void 0 || recoveryAttempted)) {
1858
+ grace?.cancel();
1859
+ grace = void 0;
1860
+ recoveryAbort?.abort();
1861
+ recoveryAbort = void 0;
1862
+ recovery = void 0;
1863
+ recoveryAttempted = false;
1864
+ sinceRequiresAction = 0;
1865
+ }
1866
+ let raced;
1679
1867
  if (awaitingActionRequired && !recoveryAttempted) {
1680
1868
  grace ??= startGraceTimer(ACTION_REQUIRED_GRACE_MS);
1681
- const raced = await Promise.race([next, grace.expired]);
1869
+ raced = await raceSegment(Promise.race([next, grace.expired]));
1682
1870
  if (raced === GRACE_EXPIRED) {
1683
1871
  recoveryAttempted = true;
1684
1872
  throwIfAborted();
@@ -1691,27 +1879,25 @@ var CoderLanguageModel = class {
1691
1879
  ).catch(() => []);
1692
1880
  continue;
1693
1881
  }
1694
- result = raced;
1695
1882
  } else if (awaitingActionRequired && recovery) {
1696
- const raced = await Promise.race([next, recovery]);
1883
+ raced = await raceSegment(Promise.race([next, recovery]));
1697
1884
  if (Array.isArray(raced)) {
1698
1885
  recovery = void 0;
1699
1886
  throwIfAborted();
1700
1887
  for (const ev of raced) for (const part of translator.ingest(ev)) yield part;
1701
- if (translator.clientToolCallSeen) {
1702
- void next.catch(() => {
1703
- });
1704
- break;
1705
- }
1888
+ if (translator.clientToolCallSeen) break;
1706
1889
  continue;
1707
1890
  }
1708
- result = raced;
1709
1891
  } else {
1710
- result = await next;
1892
+ raced = await raceSegment(next);
1893
+ }
1894
+ if (raced === SEGMENT_ABORTED) {
1895
+ throwIfAborted();
1896
+ throw new CoderAgentError("segment abort sentinel resolved without an abort");
1711
1897
  }
1712
- if (result.done) break;
1713
- next = stream.next();
1714
- for (const part of translator.ingest(result.value)) yield part;
1898
+ stream.consumed(next);
1899
+ if (raced.done) break;
1900
+ for (const part of translator.ingest(raced.value)) yield part;
1715
1901
  const status = translator.terminalStatus;
1716
1902
  if (status) {
1717
1903
  if (status !== "requires_action") break;
@@ -1719,6 +1905,7 @@ var CoderLanguageModel = class {
1719
1905
  if (++sinceRequiresAction > 200) break;
1720
1906
  }
1721
1907
  }
1908
+ retainStream = !signal?.aborted && translator.terminalStatus === "requires_action" && translator.clientToolCallSeen && !translator.error;
1722
1909
  } catch (err) {
1723
1910
  throwIfAborted();
1724
1911
  if (err instanceof CoderStreamError) {
@@ -1745,8 +1932,12 @@ var CoderLanguageModel = class {
1745
1932
  } finally {
1746
1933
  grace?.cancel();
1747
1934
  recoveryAbort?.abort();
1748
- await stream.return(void 0).catch(() => {
1749
- });
1935
+ if (signal && onSegmentAbort) signal.removeEventListener("abort", onSegmentAbort);
1936
+ if (retainStream && !signal?.aborted && !stream.closed) {
1937
+ stream.pause();
1938
+ } else {
1939
+ await this.#closeStream(stream);
1940
+ }
1750
1941
  }
1751
1942
  throwIfAborted();
1752
1943
  if (!translator.terminalStatus && !translator.error) {
@@ -1765,6 +1956,11 @@ var CoderLanguageModel = class {
1765
1956
  this.#lastSeenMessageId = translator.maxMessageId;
1766
1957
  signal?.removeEventListener("abort", interrupt);
1767
1958
  if (!translator?.terminalStatus) interrupt();
1959
+ if (!streamAcquired && this.#retainedStream) {
1960
+ const stale = this.#retainedStream;
1961
+ this.#retainedStream = void 0;
1962
+ void stale.close();
1963
+ }
1768
1964
  if (discardSession) this.resetSession();
1769
1965
  this.#inFlight = false;
1770
1966
  }
@@ -2014,7 +2210,9 @@ var CoderAgent = class {
2014
2210
  */
2015
2211
  async interrupt(opts) {
2016
2212
  const id = this.#model.chatId;
2017
- if (id) await this.#client.interruptChat(id, opts?.signal);
2213
+ if (!id) return;
2214
+ await this.#model.closePausedStream();
2215
+ await this.#client.interruptChat(id, opts?.signal);
2018
2216
  }
2019
2217
  /**
2020
2218
  * Archive the underlying chat (safe cleanup; hides it from listings).
@@ -2028,6 +2226,7 @@ var CoderAgent = class {
2028
2226
  async archive(opts) {
2029
2227
  const id = this.#model.chatId;
2030
2228
  if (!id) return;
2229
+ await this.#model.closePausedStream();
2031
2230
  await archiveWhenSettled(this.#client, id, {
2032
2231
  deadlineMs: this.#settleDeadlineMs,
2033
2232
  retryDelayMs: this.#settleRetryDelayMs,
@@ -2051,6 +2250,10 @@ var CoderAgent = class {
2051
2250
  * ```
2052
2251
  */
2053
2252
  async [Symbol.asyncDispose]() {
2253
+ try {
2254
+ await this.#model[Symbol.asyncDispose]();
2255
+ } catch {
2256
+ }
2054
2257
  const deadline = AbortSignal.timeout(this.#settleDeadlineMs);
2055
2258
  try {
2056
2259
  await this.interrupt({ signal: deadline });