@coder/ai-sdk-agent 0.4.2 → 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.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  } from "ai";
6
6
 
7
7
  // src/errors.ts
8
+ import { APICallError } from "@ai-sdk/provider";
8
9
  var CoderAgentError = class extends Error {
9
10
  name = "CoderAgentError";
10
11
  constructor(message, options) {
@@ -27,6 +28,22 @@ var CoderApiError = class extends CoderAgentError {
27
28
  this.path = args.path;
28
29
  }
29
30
  };
31
+ var CoderStreamError = class extends APICallError {
32
+ name = "CoderStreamError";
33
+ constructor(args) {
34
+ super({
35
+ message: args.message,
36
+ url: args.url,
37
+ requestBodyValues: void 0,
38
+ // Retries re-invoke the model with the SAME prompt, so the model layer
39
+ // downgrades this to false whenever the failed chat has prior state a
40
+ // re-submission would corrupt, or server-side tooling (workspace/MCP)
41
+ // whose effects a replay would duplicate (see #runTurn).
42
+ isRetryable: args.isRetryable ?? true,
43
+ cause: args.cause
44
+ });
45
+ }
46
+ };
30
47
  var CoderChatError = class extends CoderAgentError {
31
48
  name = "CoderChatError";
32
49
  kind;
@@ -154,92 +171,209 @@ function httpToWs(baseUrl) {
154
171
  if (baseUrl.startsWith("http://")) return `ws://${baseUrl.slice("http://".length)}`;
155
172
  return baseUrl;
156
173
  }
157
- async function* streamChatEvents(options) {
158
- const { baseUrl, token, chatId, afterId, signal } = options;
159
- const factory = options.webSocketFactory ?? defaultFactory;
160
- const wsBase = httpToWs(baseUrl);
161
- const query = afterId !== void 0 ? `?after_id=${afterId}` : "";
162
- const url = `${wsBase}/api/experimental/chats/${chatId}/stream${query}`;
163
- const queue = [];
164
- let resolveNext;
165
- let finished = false;
166
- let failure;
167
- const wake = () => {
168
- resolveNext?.();
169
- resolveNext = void 0;
170
- };
171
- const ws = factory(url, { headers: { "Coder-Session-Token": token } });
172
- const onAbort = () => {
173
- finished = true;
174
+ var STREAM_BACKOFF_INITIAL_MS = 1e3;
175
+ var STREAM_BACKOFF_CAP_MS = 3e4;
176
+ var STREAM_MAX_CONSECUTIVE_FAILURES = 5;
177
+ var TRANSIENT_UPGRADE_STATUSES = /* @__PURE__ */ new Set([408, 425, 429]);
178
+ function streamChatEvents(options) {
179
+ const controller = new AbortController();
180
+ const external = options.signal;
181
+ const chain = () => controller.abort(external?.reason);
182
+ if (external) {
183
+ if (external.aborted) chain();
184
+ else external.addEventListener("abort", chain, { once: true });
185
+ }
186
+ const inner = streamChatEventsLoop({ ...options, signal: controller.signal });
187
+ const detach = () => external?.removeEventListener("abort", chain);
188
+ const finish = async () => {
189
+ controller.abort();
190
+ detach();
174
191
  try {
175
- ws.close(1e3);
192
+ await inner.return();
176
193
  } catch {
177
194
  }
178
- wake();
179
195
  };
180
- let abortListenerAdded = false;
181
- if (signal) {
182
- if (signal.aborted) onAbort();
183
- else {
184
- signal.addEventListener("abort", onAbort, { once: true });
185
- abortListenerAdded = true;
196
+ return {
197
+ // Detach the signal chain once the loop settles for good (done, or a
198
+ // terminal failure) — the iteration protocol never calls return() on a
199
+ // failed iterator, and the {once} listener would otherwise pile up on a
200
+ // long-lived signal across re-created readers.
201
+ async next() {
202
+ try {
203
+ const result = await inner.next();
204
+ if (result.done) detach();
205
+ return result;
206
+ } catch (err) {
207
+ detach();
208
+ throw err;
209
+ }
210
+ },
211
+ async return() {
212
+ await finish();
213
+ return { done: true, value: void 0 };
214
+ },
215
+ async throw(err) {
216
+ await finish();
217
+ throw err;
218
+ },
219
+ [Symbol.asyncIterator]() {
220
+ return this;
221
+ },
222
+ // Native async generators are async-disposable; the wrapper must be too.
223
+ async [Symbol.asyncDispose]() {
224
+ await finish();
186
225
  }
187
- }
188
- const onMessage = (ev) => {
189
- if (finished) return;
190
- let batch;
191
- try {
192
- const data = typeof ev.data === "string" ? ev.data : String(ev.data);
193
- batch = JSON.parse(data);
194
- } catch (err) {
195
- failure = new CoderAgentError("failed to parse chat stream frame", { cause: err });
226
+ };
227
+ }
228
+ async function* streamChatEventsLoop(options) {
229
+ const { baseUrl, token, chatId, signal } = options;
230
+ const factory = options.webSocketFactory ?? defaultFactory;
231
+ const wsBase = httpToWs(baseUrl);
232
+ const path = `/api/experimental/chats/${chatId}/stream`;
233
+ const query = options.afterId !== void 0 ? `?after_id=${options.afterId}` : "";
234
+ const url = `${wsBase}${path}${query}`;
235
+ let maxCommittedId = options.afterId;
236
+ let lastHistoryVersion;
237
+ let lastGenerationAttempt;
238
+ let lastSeq;
239
+ let sawUntrackableDelta = false;
240
+ let backoffMs = STREAM_BACKOFF_INITIAL_MS;
241
+ let failures = 0;
242
+ let lastFailure;
243
+ while (!signal?.aborted) {
244
+ const queue = [];
245
+ let resolveNext;
246
+ let finished = false;
247
+ let failure;
248
+ let parseFailure = false;
249
+ const wake = () => {
250
+ resolveNext?.();
251
+ resolveNext = void 0;
252
+ };
253
+ const ws = factory(url, { headers: { "Coder-Session-Token": token } });
254
+ const onAbort = () => {
196
255
  finished = true;
256
+ try {
257
+ ws.close(1e3);
258
+ } catch {
259
+ }
197
260
  wake();
198
- return;
199
- }
200
- if (Array.isArray(batch)) {
201
- for (const e of batch) queue.push(e);
202
- } else if (batch && typeof batch === "object") {
203
- queue.push(batch);
261
+ };
262
+ let abortListenerAdded = false;
263
+ if (signal) {
264
+ if (signal.aborted) onAbort();
265
+ else {
266
+ signal.addEventListener("abort", onAbort, { once: true });
267
+ abortListenerAdded = true;
268
+ }
204
269
  }
205
- wake();
206
- };
207
- const onError = (ev) => {
208
- if (finished) return;
209
- const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "chat stream socket error";
210
- failure = new CoderAgentError(message);
211
- finished = true;
212
- wake();
213
- };
214
- const onClose = () => {
215
- finished = true;
216
- wake();
217
- };
218
- ws.addEventListener("message", onMessage);
219
- ws.addEventListener("error", onError);
220
- ws.addEventListener("close", onClose);
221
- try {
222
- while (true) {
223
- while (queue.length > 0) {
224
- const next = queue.shift();
225
- yield next;
270
+ const onMessage = (ev) => {
271
+ if (finished) return;
272
+ let batch;
273
+ try {
274
+ const data = typeof ev.data === "string" ? ev.data : String(ev.data);
275
+ batch = JSON.parse(data);
276
+ } catch (err) {
277
+ failure = new CoderAgentError("failed to parse chat stream frame", { cause: err });
278
+ parseFailure = true;
279
+ finished = true;
280
+ wake();
281
+ return;
282
+ }
283
+ if (Array.isArray(batch)) {
284
+ for (const e of batch) queue.push(e);
285
+ } else if (batch && typeof batch === "object") {
286
+ queue.push(batch);
226
287
  }
227
- if (failure) throw failure;
288
+ wake();
289
+ };
290
+ const onError = (ev) => {
228
291
  if (finished) return;
229
- await new Promise((resolve) => {
230
- resolveNext = resolve;
292
+ const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "chat stream socket error";
293
+ const status = upgradeStatus(message);
294
+ failure = status !== void 0 && status >= 400 && status < 500 && !TRANSIENT_UPGRADE_STATUSES.has(status) ? new CoderApiError({ status, method: "GET", path, message }) : new CoderAgentError(message);
295
+ finished = true;
296
+ wake();
297
+ };
298
+ const onClose = () => {
299
+ finished = true;
300
+ wake();
301
+ };
302
+ ws.addEventListener("message", onMessage);
303
+ ws.addEventListener("error", onError);
304
+ ws.addEventListener("close", onClose);
305
+ try {
306
+ while (true) {
307
+ while (queue.length > 0) {
308
+ const next = queue.shift();
309
+ let progress = false;
310
+ if (next.type === "message_part") {
311
+ const mp = next.message_part;
312
+ const hv = mp?.history_version;
313
+ const ga = mp?.generation_attempt;
314
+ const seq = mp?.seq;
315
+ if (hv !== void 0 && ga !== void 0 && seq !== void 0) {
316
+ if (hv === lastHistoryVersion && ga === lastGenerationAttempt && lastSeq !== void 0 && seq <= lastSeq) {
317
+ continue;
318
+ }
319
+ lastHistoryVersion = hv;
320
+ lastGenerationAttempt = ga;
321
+ lastSeq = seq;
322
+ progress = true;
323
+ } else {
324
+ sawUntrackableDelta = true;
325
+ }
326
+ } else if (next.type === "message" && next.message) {
327
+ if (maxCommittedId === void 0 || next.message.id > maxCommittedId) {
328
+ maxCommittedId = next.message.id;
329
+ progress = true;
330
+ sawUntrackableDelta = false;
331
+ }
332
+ }
333
+ if (progress) {
334
+ failures = 0;
335
+ backoffMs = STREAM_BACKOFF_INITIAL_MS;
336
+ }
337
+ yield next;
338
+ }
339
+ if (finished) break;
340
+ await new Promise((resolve) => {
341
+ resolveNext = resolve;
342
+ });
343
+ }
344
+ } finally {
345
+ finished = true;
346
+ if (signal && abortListenerAdded) signal.removeEventListener("abort", onAbort);
347
+ ws.removeEventListener("message", onMessage);
348
+ ws.removeEventListener("error", onError);
349
+ ws.removeEventListener("close", onClose);
350
+ try {
351
+ ws.close(1e3);
352
+ } catch {
353
+ }
354
+ }
355
+ if (signal?.aborted) return;
356
+ if (failure instanceof CoderApiError) throw failure;
357
+ if (parseFailure && failure) throw failure;
358
+ if (sawUntrackableDelta) {
359
+ throw new CoderStreamError({
360
+ 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",
361
+ url,
362
+ cause: failure
231
363
  });
232
364
  }
233
- } finally {
234
- finished = true;
235
- if (signal && abortListenerAdded) signal.removeEventListener("abort", onAbort);
236
- ws.removeEventListener("message", onMessage);
237
- ws.removeEventListener("error", onError);
238
- ws.removeEventListener("close", onClose);
239
- try {
240
- ws.close(1e3);
241
- } catch {
365
+ if (failure) lastFailure = failure;
366
+ failures += 1;
367
+ if (failures >= STREAM_MAX_CONSECUTIVE_FAILURES) {
368
+ throw new CoderStreamError({
369
+ message: `Coder chat stream ${path} could not be (re-)established after ${failures} consecutive connection failures without progress`,
370
+ url,
371
+ cause: lastFailure
372
+ });
242
373
  }
374
+ await sleep(backoffMs, signal);
375
+ if (signal?.aborted) return;
376
+ backoffMs = Math.min(backoffMs * 2, STREAM_BACKOFF_CAP_MS);
243
377
  }
244
378
  }
245
379
  var WATCH_PATH = "/api/experimental/chats/watch";
@@ -882,6 +1016,95 @@ function dynamicToolNames(tools) {
882
1016
  return names;
883
1017
  }
884
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
+
885
1108
  // src/model/translate.ts
886
1109
  function addDefined(a, b) {
887
1110
  if (b === void 0) return a;
@@ -941,6 +1164,7 @@ function jsonResult(value) {
941
1164
  }
942
1165
  var TurnTranslator = class {
943
1166
  #dynamicToolNames;
1167
+ #submittedToolCallIds;
944
1168
  #seq = 0;
945
1169
  #text = { id: void 0, len: 0, sawDelta: false };
946
1170
  #reasoning = { id: void 0, len: 0, sawDelta: false };
@@ -958,6 +1182,11 @@ var TurnTranslator = class {
958
1182
  // the same message replaces its earlier entry instead of double-counting
959
1183
  // (chatd re-sends full snapshots on revision bumps and history resets).
960
1184
  #usageByMessageId = /* @__PURE__ */ new Map();
1185
+ // Assistant snapshots already processed once. A redial replays the turn
1186
+ // from its original cursor, so earlier messages arrive again — without this,
1187
+ // re-running the boundary logic on them would reset the emitted-length
1188
+ // cursors and re-emit their entire text (see #ingestMessage).
1189
+ #snapshotSeen = /* @__PURE__ */ new Set();
961
1190
  #error;
962
1191
  #terminalStatus;
963
1192
  #maxMessageId = 0;
@@ -972,6 +1201,7 @@ var TurnTranslator = class {
972
1201
  constructor(opts) {
973
1202
  this.#dynamicToolNames = opts.dynamicToolNames;
974
1203
  this.#turnCursor = opts.turnCursor ?? 0;
1204
+ this.#submittedToolCallIds = opts.submittedToolCallIds ?? /* @__PURE__ */ new Set();
975
1205
  }
976
1206
  get terminalStatus() {
977
1207
  return this.#terminalStatus;
@@ -1121,6 +1351,7 @@ var TurnTranslator = class {
1121
1351
  case "action_required":
1122
1352
  for (const tc of ev.action_required?.tool_calls ?? []) {
1123
1353
  if (this.#clientToolCalls.has(tc.tool_call_id)) continue;
1354
+ if (this.#submittedToolCallIds.has(tc.tool_call_id)) continue;
1124
1355
  this.#closeText(out);
1125
1356
  this.#closeReasoning(out);
1126
1357
  this.#clientToolCalls.add(tc.tool_call_id);
@@ -1142,8 +1373,12 @@ var TurnTranslator = class {
1142
1373
  }
1143
1374
  break;
1144
1375
  case "status":
1145
- if (ev.status && TERMINAL_STATUSES.has(ev.status.status)) {
1146
- 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
+ }
1147
1382
  }
1148
1383
  break;
1149
1384
  default:
@@ -1197,6 +1432,17 @@ var TurnTranslator = class {
1197
1432
  }
1198
1433
  const content = message.content ?? [];
1199
1434
  if (message.role === "assistant") {
1435
+ if (this.#snapshotSeen.has(message.id) && (message.id !== this.#currentAssistantId || this.#deltasSinceSnapshot)) {
1436
+ for (const part of content) {
1437
+ if (part.type === "tool-call" && !this.#isClientTool(part.tool_name))
1438
+ this.#emitServerToolCall(out, part);
1439
+ else if (part.type === "tool-result" && !this.#isClientTool(part.tool_name))
1440
+ this.#emitServerToolResult(out, part);
1441
+ else if (part.type === "source") this.#emitSource(out, part);
1442
+ }
1443
+ return;
1444
+ }
1445
+ this.#snapshotSeen.add(message.id);
1200
1446
  if (this.#currentAssistantId !== void 0 && this.#currentAssistantId !== message.id && !this.#deltasSinceSnapshot) {
1201
1447
  this.#closeText(out);
1202
1448
  this.#closeReasoning(out);
@@ -1204,13 +1450,24 @@ var TurnTranslator = class {
1204
1450
  this.#reasoning.sawDelta = false;
1205
1451
  }
1206
1452
  this.#currentAssistantId = message.id;
1207
- if (!this.#text.sawDelta) {
1208
- const full = content.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
1209
- if (full.length > 0) this.#emitTextUpTo(out, full);
1210
- }
1211
- if (!this.#reasoning.sawDelta) {
1212
- const full = content.filter((p) => p.type === "reasoning").map((p) => p.text ?? "").join("");
1213
- if (full.length > 0) this.#emitReasoningUpTo(out, full);
1453
+ const emitSnapshotText = () => {
1454
+ if (!this.#text.sawDelta || this.#text.id) {
1455
+ const full = content.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
1456
+ if (full.length > 0) this.#emitTextUpTo(out, full);
1457
+ }
1458
+ };
1459
+ const emitSnapshotReasoning = () => {
1460
+ if (!this.#reasoning.sawDelta || this.#reasoning.id) {
1461
+ const full = content.filter((p) => p.type === "reasoning").map((p) => p.text ?? "").join("");
1462
+ if (full.length > 0) this.#emitReasoningUpTo(out, full);
1463
+ }
1464
+ };
1465
+ if (this.#reasoning.id) {
1466
+ emitSnapshotReasoning();
1467
+ emitSnapshotText();
1468
+ } else {
1469
+ emitSnapshotText();
1470
+ emitSnapshotReasoning();
1214
1471
  }
1215
1472
  for (const part of content) {
1216
1473
  if (part.type === "tool-call" && !this.#isClientTool(part.tool_name))
@@ -1253,6 +1510,16 @@ var TurnTranslator = class {
1253
1510
  };
1254
1511
 
1255
1512
  // src/model/language-model.ts
1513
+ var ACTION_REQUIRED_GRACE_MS = 2e3;
1514
+ var GRACE_EXPIRED = /* @__PURE__ */ Symbol("action-required-grace-expired");
1515
+ var SEGMENT_ABORTED = /* @__PURE__ */ Symbol("segment-aborted");
1516
+ function startGraceTimer(ms) {
1517
+ let timer;
1518
+ const expired = new Promise((resolve) => {
1519
+ timer = setTimeout(() => resolve(GRACE_EXPIRED), ms);
1520
+ });
1521
+ return { expired, cancel: () => clearTimeout(timer) };
1522
+ }
1256
1523
  var EMPTY_USAGE = {
1257
1524
  inputTokens: {
1258
1525
  total: void 0,
@@ -1274,6 +1541,8 @@ var CoderLanguageModel = class {
1274
1541
  #modelResolved = false;
1275
1542
  #submittedToolCallIds = /* @__PURE__ */ new Set();
1276
1543
  #inFlight = false;
1544
+ /** The turn-lifetime event stream, retained across client-tool pauses. */
1545
+ #retainedStream;
1277
1546
  constructor(config) {
1278
1547
  assertSupportedAiVersion();
1279
1548
  this.#config = config;
@@ -1283,11 +1552,75 @@ var CoderLanguageModel = class {
1283
1552
  get chatId() {
1284
1553
  return this.#chatId;
1285
1554
  }
1286
- /** 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
+ */
1287
1560
  resetSession() {
1288
1561
  this.#chatId = void 0;
1289
1562
  this.#lastSeenMessageId = 0;
1290
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();
1291
1624
  }
1292
1625
  async #resolveModelConfigId(signal) {
1293
1626
  if (this.#modelResolved) return this.#resolvedModelConfigId;
@@ -1306,17 +1639,74 @@ var CoderLanguageModel = class {
1306
1639
  * id, so this runs before the chat exists). Pre-uploaded files carried via
1307
1640
  * `providerOptions.coder.fileId` are referenced without re-uploading.
1308
1641
  */
1309
- #buildContent(content, signal) {
1642
+ #buildContent(content, signal, onUpload) {
1310
1643
  const uploadFile = async (f) => {
1311
1644
  const uploaded = await this.#config.client.uploadChatFile(
1312
1645
  this.#config.organizationId,
1313
1646
  { content: dataContentToFileContent(f.data), mediaType: f.mediaType, name: f.filename },
1314
1647
  signal
1315
1648
  );
1649
+ onUpload?.();
1316
1650
  return uploaded.id;
1317
1651
  };
1318
1652
  return userContentToInputParts(content, uploadFile);
1319
1653
  }
1654
+ /**
1655
+ * REST fallback for a `requires_action` segment whose `action_required`
1656
+ * event never arrived: recover the turn's pending client tool calls from
1657
+ * committed chat history (`GET /chats/{id}/messages`) — the same history
1658
+ * chatd itself derives `action_required` from server-side. Returns synthetic
1659
+ * stream events for the translator: the turn's message snapshots first (so
1660
+ * text, usage, and the message cursor stay consistent even if the stream
1661
+ * also missed a commit — the translator's id-keyed dedup makes re-ingesting
1662
+ * already-delivered ones a no-op), then one `action_required` event carrying
1663
+ * the unresolved dynamic tool calls. Returns `[]` when history shows no
1664
+ * pending calls — nothing to recover, the caller keeps waiting on the
1665
+ * stream, bounded by `requestTimeoutMs`/abort.
1666
+ */
1667
+ async #recoverRequiresAction(chatId, dynamicNames, afterId, signal) {
1668
+ const { messages } = await this.#config.client.getMessages(chatId, { limit: 200 }, signal);
1669
+ const turnMessages = messages.filter((m) => m.id > (afterId ?? 0)).sort((a, b) => a.id - b.id);
1670
+ const lastAssistant = turnMessages.findLast((m) => m.role === "assistant");
1671
+ if (!lastAssistant) return [];
1672
+ const handled = /* @__PURE__ */ new Set();
1673
+ for (const message of turnMessages) {
1674
+ if (message.id <= lastAssistant.id) continue;
1675
+ for (const part of message.content ?? []) {
1676
+ if (part.type === "tool-result" && part.tool_call_id) handled.add(part.tool_call_id);
1677
+ }
1678
+ }
1679
+ const toolCalls = [];
1680
+ for (const part of lastAssistant.content ?? []) {
1681
+ if (part.type !== "tool-call" || part.provider_executed) continue;
1682
+ if (!part.tool_call_id || !part.tool_name) continue;
1683
+ if (!dynamicNames.has(part.tool_name)) continue;
1684
+ if (handled.has(part.tool_call_id)) continue;
1685
+ if (this.#submittedToolCallIds.has(part.tool_call_id)) continue;
1686
+ toolCalls.push({
1687
+ tool_call_id: part.tool_call_id,
1688
+ tool_name: part.tool_name,
1689
+ // `action_required` carries args as their raw JSON text (chatd sends
1690
+ // `string(part.Args)` of the history part's json.RawMessage);
1691
+ // re-encoding the snapshot's parsed JSON value reconstructs it. A
1692
+ // JSON `null` is a legitimate argument value and must survive; only
1693
+ // an ABSENT args field falls back to empty-object args.
1694
+ args: JSON.stringify(part.args === void 0 ? {} : part.args)
1695
+ });
1696
+ }
1697
+ if (toolCalls.length === 0) return [];
1698
+ const events = turnMessages.map((message) => ({
1699
+ type: "message",
1700
+ chat_id: chatId,
1701
+ message
1702
+ }));
1703
+ events.push({
1704
+ type: "action_required",
1705
+ chat_id: chatId,
1706
+ action_required: { tool_calls: toolCalls }
1707
+ });
1708
+ return events;
1709
+ }
1320
1710
  async *#runTurn(options) {
1321
1711
  if (this.#inFlight) {
1322
1712
  throw new CoderAgentError(
@@ -1332,9 +1722,12 @@ var CoderLanguageModel = class {
1332
1722
  );
1333
1723
  const signal = sources.length > 0 ? AbortSignal.any(sources) : void 0;
1334
1724
  let translator;
1725
+ let streamAcquired = false;
1726
+ let discardSession = false;
1335
1727
  let interruptSent = false;
1728
+ let turnChatId;
1336
1729
  const interrupt = () => {
1337
- const id = this.#chatId;
1730
+ const id = turnChatId ?? this.#chatId;
1338
1731
  if (interruptSent || !id) return;
1339
1732
  interruptSent = true;
1340
1733
  void this.#config.client.interruptChat(id).catch(() => {
@@ -1371,6 +1764,9 @@ var CoderLanguageModel = class {
1371
1764
  );
1372
1765
  }
1373
1766
  let afterId;
1767
+ let turnCreatedChat = false;
1768
+ let uploadedAttachment = false;
1769
+ let chatHasWorkspace = Boolean(this.#config.workspaceId);
1374
1770
  if (this.#chatId && this.#lastSeenMessageId === 0) {
1375
1771
  const { messages } = await this.#config.client.getMessages(
1376
1772
  this.#chatId,
@@ -1382,7 +1778,9 @@ var CoderLanguageModel = class {
1382
1778
  if (action.kind === "new-turn") {
1383
1779
  const [modelConfigId, content] = await Promise.all([
1384
1780
  this.#resolveModelConfigId(signal),
1385
- this.#buildContent(action.content, signal)
1781
+ this.#buildContent(action.content, signal, () => {
1782
+ uploadedAttachment = true;
1783
+ })
1386
1784
  ]);
1387
1785
  if (!this.#chatId) {
1388
1786
  const req = {
@@ -1400,6 +1798,8 @@ var CoderLanguageModel = class {
1400
1798
  if (this.#config.planMode) req.plan_mode = this.#config.planMode;
1401
1799
  const chat = await this.#config.client.createChat(req, signal);
1402
1800
  this.#chatId = chat.id;
1801
+ turnCreatedChat = true;
1802
+ if (chat.workspace_id) chatHasWorkspace = true;
1403
1803
  afterId = this.#lastSeenMessageId > 0 ? this.#lastSeenMessageId : void 0;
1404
1804
  } else {
1405
1805
  const resp = await this.#config.client.createChatMessage(
@@ -1425,14 +1825,79 @@ var CoderLanguageModel = class {
1425
1825
  afterId = this.#lastSeenMessageId;
1426
1826
  }
1427
1827
  const chatId = this.#chatId;
1828
+ turnChatId = chatId;
1829
+ const dynamicNames = dynamicToolNames(options.tools);
1428
1830
  translator = new TurnTranslator({
1429
- dynamicToolNames: dynamicToolNames(options.tools),
1430
- turnCursor: afterId ?? 0
1831
+ dynamicToolNames: dynamicNames,
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
1431
1837
  });
1432
1838
  let sinceRequiresAction = 0;
1839
+ let grace;
1840
+ let recovery;
1841
+ let recoveryAbort;
1842
+ let recoveryAttempted = false;
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;
1433
1853
  try {
1434
- for await (const ev of this.#config.client.streamEvents(chatId, { afterId, signal })) {
1435
- for (const part of translator.ingest(ev)) yield part;
1854
+ for (; ; ) {
1855
+ const next = stream.read();
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;
1867
+ if (awaitingActionRequired && !recoveryAttempted) {
1868
+ grace ??= startGraceTimer(ACTION_REQUIRED_GRACE_MS);
1869
+ raced = await raceSegment(Promise.race([next, grace.expired]));
1870
+ if (raced === GRACE_EXPIRED) {
1871
+ recoveryAttempted = true;
1872
+ throwIfAborted();
1873
+ recoveryAbort = new AbortController();
1874
+ recovery = this.#recoverRequiresAction(
1875
+ chatId,
1876
+ dynamicNames,
1877
+ afterId,
1878
+ signal ? AbortSignal.any([signal, recoveryAbort.signal]) : recoveryAbort.signal
1879
+ ).catch(() => []);
1880
+ continue;
1881
+ }
1882
+ } else if (awaitingActionRequired && recovery) {
1883
+ raced = await raceSegment(Promise.race([next, recovery]));
1884
+ if (Array.isArray(raced)) {
1885
+ recovery = void 0;
1886
+ throwIfAborted();
1887
+ for (const ev of raced) for (const part of translator.ingest(ev)) yield part;
1888
+ if (translator.clientToolCallSeen) break;
1889
+ continue;
1890
+ }
1891
+ } else {
1892
+ raced = await raceSegment(next);
1893
+ }
1894
+ if (raced === SEGMENT_ABORTED) {
1895
+ throwIfAborted();
1896
+ throw new CoderAgentError("segment abort sentinel resolved without an abort");
1897
+ }
1898
+ stream.consumed(next);
1899
+ if (raced.done) break;
1900
+ for (const part of translator.ingest(raced.value)) yield part;
1436
1901
  const status = translator.terminalStatus;
1437
1902
  if (status) {
1438
1903
  if (status !== "requires_action") break;
@@ -1440,8 +1905,22 @@ var CoderLanguageModel = class {
1440
1905
  if (++sinceRequiresAction > 200) break;
1441
1906
  }
1442
1907
  }
1908
+ retainStream = !signal?.aborted && translator.terminalStatus === "requires_action" && translator.clientToolCallSeen && !translator.error;
1443
1909
  } catch (err) {
1444
1910
  throwIfAborted();
1911
+ if (err instanceof CoderStreamError) {
1912
+ if (turnCreatedChat) {
1913
+ discardSession = true;
1914
+ }
1915
+ const effectful = chatHasWorkspace || Boolean(this.#config.mcpServerIds?.length) || uploadedAttachment;
1916
+ if (turnCreatedChat && !effectful) throw err;
1917
+ throw new CoderStreamError({
1918
+ 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)"}`,
1919
+ url: err.url,
1920
+ cause: err,
1921
+ isRetryable: false
1922
+ });
1923
+ }
1445
1924
  if (err instanceof CoderAgentError && !(err instanceof CoderApiError) && !(err instanceof CoderChatError)) {
1446
1925
  throw new CoderChatError({
1447
1926
  message: `Coder chat stream failed mid-turn: ${err.message}`,
@@ -1450,6 +1929,15 @@ var CoderLanguageModel = class {
1450
1929
  });
1451
1930
  }
1452
1931
  throw err;
1932
+ } finally {
1933
+ grace?.cancel();
1934
+ recoveryAbort?.abort();
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
+ }
1453
1941
  }
1454
1942
  throwIfAborted();
1455
1943
  if (!translator.terminalStatus && !translator.error) {
@@ -1468,6 +1956,12 @@ var CoderLanguageModel = class {
1468
1956
  this.#lastSeenMessageId = translator.maxMessageId;
1469
1957
  signal?.removeEventListener("abort", interrupt);
1470
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
+ }
1964
+ if (discardSession) this.resetSession();
1471
1965
  this.#inFlight = false;
1472
1966
  }
1473
1967
  }
@@ -1716,7 +2210,9 @@ var CoderAgent = class {
1716
2210
  */
1717
2211
  async interrupt(opts) {
1718
2212
  const id = this.#model.chatId;
1719
- 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);
1720
2216
  }
1721
2217
  /**
1722
2218
  * Archive the underlying chat (safe cleanup; hides it from listings).
@@ -1730,6 +2226,7 @@ var CoderAgent = class {
1730
2226
  async archive(opts) {
1731
2227
  const id = this.#model.chatId;
1732
2228
  if (!id) return;
2229
+ await this.#model.closePausedStream();
1733
2230
  await archiveWhenSettled(this.#client, id, {
1734
2231
  deadlineMs: this.#settleDeadlineMs,
1735
2232
  retryDelayMs: this.#settleRetryDelayMs,
@@ -1753,6 +2250,10 @@ var CoderAgent = class {
1753
2250
  * ```
1754
2251
  */
1755
2252
  async [Symbol.asyncDispose]() {
2253
+ try {
2254
+ await this.#model[Symbol.asyncDispose]();
2255
+ } catch {
2256
+ }
1756
2257
  const deadline = AbortSignal.timeout(this.#settleDeadlineMs);
1757
2258
  try {
1758
2259
  await this.interrupt({ signal: deadline });
@@ -1793,7 +2294,7 @@ var CoderAgent = class {
1793
2294
  async uploadToWorkspace(file) {
1794
2295
  if (!this.#workspaceFiles) {
1795
2296
  throw new CoderAgentError(
1796
- "uploadToWorkspace requires a `workspaceFiles` adapter. Construct the agent with `workspaceFiles` (e.g. backed by a @coder/ai-sdk-eve-sandbox session) to enable it."
2297
+ "uploadToWorkspace requires a `workspaceFiles` adapter. Construct the agent with `workspaceFiles` (e.g. backed by a @coder/ai-sdk-sandbox workspace session) to enable it."
1797
2298
  );
1798
2299
  }
1799
2300
  const { path } = await this.#workspaceFiles.write({
@@ -1964,7 +2465,9 @@ export {
1964
2465
  CoderChatClient,
1965
2466
  CoderChatError,
1966
2467
  CoderLanguageModel,
2468
+ CoderStreamError,
1967
2469
  MAX_CHAT_FILE_SIZE_BYTES,
2470
+ TERMINAL_STATUSES,
1968
2471
  TurnTranslator,
1969
2472
  chatMessagesToUIMessages,
1970
2473
  classifyTurnAction,