@coder/ai-sdk-agent 0.4.1 → 0.4.3

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";
@@ -883,11 +1017,29 @@ function dynamicToolNames(tools) {
883
1017
  }
884
1018
 
885
1019
  // src/model/translate.ts
1020
+ function addDefined(a, b) {
1021
+ if (b === void 0) return a;
1022
+ return (a ?? 0) + b;
1023
+ }
1024
+ var USAGE_FIELD_KINDS = {
1025
+ input_tokens: "sum",
1026
+ output_tokens: "sum",
1027
+ total_tokens: "sum",
1028
+ reasoning_tokens: "sum",
1029
+ cache_creation_tokens: "sum",
1030
+ cache_read_tokens: "sum",
1031
+ total_cost_micros: "sum",
1032
+ total_runtime_ms: "sum",
1033
+ context_limit: "newest"
1034
+ };
886
1035
  function mapUsage(u) {
887
1036
  return {
888
1037
  inputTokens: {
889
- total: u?.input_tokens,
890
- noCache: void 0,
1038
+ total: addDefined(
1039
+ addDefined(u?.input_tokens, u?.cache_read_tokens),
1040
+ u?.cache_creation_tokens
1041
+ ),
1042
+ noCache: u?.input_tokens,
891
1043
  cacheRead: u?.cache_read_tokens,
892
1044
  cacheWrite: u?.cache_creation_tokens
893
1045
  },
@@ -896,11 +1048,28 @@ function mapUsage(u) {
896
1048
  text: void 0,
897
1049
  reasoning: u?.reasoning_tokens
898
1050
  },
899
- // Preserve the verbatim wire usage (snake_case) so callers can reach fields
1051
+ // Preserve the turn's wire usage (snake_case) so callers can reach fields
900
1052
  // the normalized shape has no slot for (context_limit, cost, runtime, …).
901
1053
  ...u ? { raw: u } : {}
902
1054
  };
903
1055
  }
1056
+ function accumulateUsage(byMessageId) {
1057
+ if (byMessageId.size === 0) return void 0;
1058
+ const total = {};
1059
+ const newestIdByKey = /* @__PURE__ */ new Map();
1060
+ for (const [id, u] of byMessageId) {
1061
+ for (const [key, value] of Object.entries(u)) {
1062
+ if (value === void 0) continue;
1063
+ if (USAGE_FIELD_KINDS[key] === "sum" && typeof value === "number") {
1064
+ total[key] = (total[key] ?? 0) + value;
1065
+ } else if (id > (newestIdByKey.get(key) ?? -1)) {
1066
+ newestIdByKey.set(key, id);
1067
+ total[key] = value;
1068
+ }
1069
+ }
1070
+ }
1071
+ return total;
1072
+ }
904
1073
  function jsonResult(value) {
905
1074
  return value ?? {};
906
1075
  }
@@ -919,12 +1088,29 @@ var TurnTranslator = class {
919
1088
  #clientToolCalls = /* @__PURE__ */ new Set();
920
1089
  #clientToolCallSeen = false;
921
1090
  #sources = /* @__PURE__ */ new Set();
922
- #usage;
1091
+ // Per-message wire usage, keyed by message id so a re-streamed revision of
1092
+ // the same message replaces its earlier entry instead of double-counting
1093
+ // (chatd re-sends full snapshots on revision bumps and history resets).
1094
+ #usageByMessageId = /* @__PURE__ */ new Map();
1095
+ // Assistant snapshots already processed once. A redial replays the turn
1096
+ // from its original cursor, so earlier messages arrive again — without this,
1097
+ // re-running the boundary logic on them would reset the emitted-length
1098
+ // cursors and re-emit their entire text (see #ingestMessage).
1099
+ #snapshotSeen = /* @__PURE__ */ new Set();
923
1100
  #error;
924
1101
  #terminalStatus;
925
1102
  #maxMessageId = 0;
1103
+ /**
1104
+ * Messages with an id at or below this cursor belong to earlier turns (or
1105
+ * to a segment that already streamed) — they arrive only as replays: the
1106
+ * initial sync when resuming a chat, or a mid-turn `history_reset` re-send
1107
+ * of the full history. Ingest skips them entirely so a replay can neither
1108
+ * re-emit earlier turns' content nor inflate this turn's usage.
1109
+ */
1110
+ #turnCursor;
926
1111
  constructor(opts) {
927
1112
  this.#dynamicToolNames = opts.dynamicToolNames;
1113
+ this.#turnCursor = opts.turnCursor ?? 0;
928
1114
  }
929
1115
  get terminalStatus() {
930
1116
  return this.#terminalStatus;
@@ -1144,11 +1330,23 @@ var TurnTranslator = class {
1144
1330
  }
1145
1331
  #ingestMessage(out, message) {
1146
1332
  if (message.id > this.#maxMessageId) this.#maxMessageId = message.id;
1147
- if (message.usage && (message.role === "assistant" || message.role === "tool")) {
1148
- this.#usage = message.usage;
1333
+ if (message.id <= this.#turnCursor) return;
1334
+ if (message.usage && message.role === "assistant") {
1335
+ this.#usageByMessageId.set(message.id, message.usage);
1149
1336
  }
1150
1337
  const content = message.content ?? [];
1151
1338
  if (message.role === "assistant") {
1339
+ if (this.#snapshotSeen.has(message.id) && (message.id !== this.#currentAssistantId || this.#deltasSinceSnapshot)) {
1340
+ for (const part of content) {
1341
+ if (part.type === "tool-call" && !this.#isClientTool(part.tool_name))
1342
+ this.#emitServerToolCall(out, part);
1343
+ else if (part.type === "tool-result" && !this.#isClientTool(part.tool_name))
1344
+ this.#emitServerToolResult(out, part);
1345
+ else if (part.type === "source") this.#emitSource(out, part);
1346
+ }
1347
+ return;
1348
+ }
1349
+ this.#snapshotSeen.add(message.id);
1152
1350
  if (this.#currentAssistantId !== void 0 && this.#currentAssistantId !== message.id && !this.#deltasSinceSnapshot) {
1153
1351
  this.#closeText(out);
1154
1352
  this.#closeReasoning(out);
@@ -1156,13 +1354,24 @@ var TurnTranslator = class {
1156
1354
  this.#reasoning.sawDelta = false;
1157
1355
  }
1158
1356
  this.#currentAssistantId = message.id;
1159
- if (!this.#text.sawDelta) {
1160
- const full = content.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
1161
- if (full.length > 0) this.#emitTextUpTo(out, full);
1162
- }
1163
- if (!this.#reasoning.sawDelta) {
1164
- const full = content.filter((p) => p.type === "reasoning").map((p) => p.text ?? "").join("");
1165
- if (full.length > 0) this.#emitReasoningUpTo(out, full);
1357
+ const emitSnapshotText = () => {
1358
+ if (!this.#text.sawDelta || this.#text.id) {
1359
+ const full = content.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
1360
+ if (full.length > 0) this.#emitTextUpTo(out, full);
1361
+ }
1362
+ };
1363
+ const emitSnapshotReasoning = () => {
1364
+ if (!this.#reasoning.sawDelta || this.#reasoning.id) {
1365
+ const full = content.filter((p) => p.type === "reasoning").map((p) => p.text ?? "").join("");
1366
+ if (full.length > 0) this.#emitReasoningUpTo(out, full);
1367
+ }
1368
+ };
1369
+ if (this.#reasoning.id) {
1370
+ emitSnapshotReasoning();
1371
+ emitSnapshotText();
1372
+ } else {
1373
+ emitSnapshotText();
1374
+ emitSnapshotReasoning();
1166
1375
  }
1167
1376
  for (const part of content) {
1168
1377
  if (part.type === "tool-call" && !this.#isClientTool(part.tool_name))
@@ -1190,14 +1399,13 @@ var TurnTranslator = class {
1190
1399
  else if (this.#clientToolCallSeen || this.#terminalStatus === "requires_action")
1191
1400
  unified = "tool-calls";
1192
1401
  else unified = "stop";
1402
+ const usage = accumulateUsage(this.#usageByMessageId);
1193
1403
  const coder = {};
1194
- if (this.#usage?.total_cost_micros !== void 0)
1195
- coder.total_cost_micros = this.#usage.total_cost_micros;
1196
- if (this.#usage?.total_runtime_ms !== void 0)
1197
- coder.total_runtime_ms = this.#usage.total_runtime_ms;
1404
+ if (usage?.total_cost_micros !== void 0) coder.total_cost_micros = usage.total_cost_micros;
1405
+ if (usage?.total_runtime_ms !== void 0) coder.total_runtime_ms = usage.total_runtime_ms;
1198
1406
  out.push({
1199
1407
  type: "finish",
1200
- usage: mapUsage(this.#usage),
1408
+ usage: mapUsage(usage),
1201
1409
  finishReason: { unified, raw: this.#terminalStatus },
1202
1410
  ...Object.keys(coder).length > 0 ? { providerMetadata: { coder } } : {}
1203
1411
  });
@@ -1206,6 +1414,15 @@ var TurnTranslator = class {
1206
1414
  };
1207
1415
 
1208
1416
  // src/model/language-model.ts
1417
+ var ACTION_REQUIRED_GRACE_MS = 2e3;
1418
+ var GRACE_EXPIRED = /* @__PURE__ */ Symbol("action-required-grace-expired");
1419
+ function startGraceTimer(ms) {
1420
+ let timer;
1421
+ const expired = new Promise((resolve) => {
1422
+ timer = setTimeout(() => resolve(GRACE_EXPIRED), ms);
1423
+ });
1424
+ return { expired, cancel: () => clearTimeout(timer) };
1425
+ }
1209
1426
  var EMPTY_USAGE = {
1210
1427
  inputTokens: {
1211
1428
  total: void 0,
@@ -1259,17 +1476,74 @@ var CoderLanguageModel = class {
1259
1476
  * id, so this runs before the chat exists). Pre-uploaded files carried via
1260
1477
  * `providerOptions.coder.fileId` are referenced without re-uploading.
1261
1478
  */
1262
- #buildContent(content, signal) {
1479
+ #buildContent(content, signal, onUpload) {
1263
1480
  const uploadFile = async (f) => {
1264
1481
  const uploaded = await this.#config.client.uploadChatFile(
1265
1482
  this.#config.organizationId,
1266
1483
  { content: dataContentToFileContent(f.data), mediaType: f.mediaType, name: f.filename },
1267
1484
  signal
1268
1485
  );
1486
+ onUpload?.();
1269
1487
  return uploaded.id;
1270
1488
  };
1271
1489
  return userContentToInputParts(content, uploadFile);
1272
1490
  }
1491
+ /**
1492
+ * REST fallback for a `requires_action` segment whose `action_required`
1493
+ * event never arrived: recover the turn's pending client tool calls from
1494
+ * committed chat history (`GET /chats/{id}/messages`) — the same history
1495
+ * chatd itself derives `action_required` from server-side. Returns synthetic
1496
+ * stream events for the translator: the turn's message snapshots first (so
1497
+ * text, usage, and the message cursor stay consistent even if the stream
1498
+ * also missed a commit — the translator's id-keyed dedup makes re-ingesting
1499
+ * already-delivered ones a no-op), then one `action_required` event carrying
1500
+ * the unresolved dynamic tool calls. Returns `[]` when history shows no
1501
+ * pending calls — nothing to recover, the caller keeps waiting on the
1502
+ * stream, bounded by `requestTimeoutMs`/abort.
1503
+ */
1504
+ async #recoverRequiresAction(chatId, dynamicNames, afterId, signal) {
1505
+ const { messages } = await this.#config.client.getMessages(chatId, { limit: 200 }, signal);
1506
+ const turnMessages = messages.filter((m) => m.id > (afterId ?? 0)).sort((a, b) => a.id - b.id);
1507
+ const lastAssistant = turnMessages.findLast((m) => m.role === "assistant");
1508
+ if (!lastAssistant) return [];
1509
+ const handled = /* @__PURE__ */ new Set();
1510
+ for (const message of turnMessages) {
1511
+ if (message.id <= lastAssistant.id) continue;
1512
+ for (const part of message.content ?? []) {
1513
+ if (part.type === "tool-result" && part.tool_call_id) handled.add(part.tool_call_id);
1514
+ }
1515
+ }
1516
+ const toolCalls = [];
1517
+ for (const part of lastAssistant.content ?? []) {
1518
+ if (part.type !== "tool-call" || part.provider_executed) continue;
1519
+ if (!part.tool_call_id || !part.tool_name) continue;
1520
+ if (!dynamicNames.has(part.tool_name)) continue;
1521
+ if (handled.has(part.tool_call_id)) continue;
1522
+ if (this.#submittedToolCallIds.has(part.tool_call_id)) continue;
1523
+ toolCalls.push({
1524
+ tool_call_id: part.tool_call_id,
1525
+ tool_name: part.tool_name,
1526
+ // `action_required` carries args as their raw JSON text (chatd sends
1527
+ // `string(part.Args)` of the history part's json.RawMessage);
1528
+ // re-encoding the snapshot's parsed JSON value reconstructs it. A
1529
+ // JSON `null` is a legitimate argument value and must survive; only
1530
+ // an ABSENT args field falls back to empty-object args.
1531
+ args: JSON.stringify(part.args === void 0 ? {} : part.args)
1532
+ });
1533
+ }
1534
+ if (toolCalls.length === 0) return [];
1535
+ const events = turnMessages.map((message) => ({
1536
+ type: "message",
1537
+ chat_id: chatId,
1538
+ message
1539
+ }));
1540
+ events.push({
1541
+ type: "action_required",
1542
+ chat_id: chatId,
1543
+ action_required: { tool_calls: toolCalls }
1544
+ });
1545
+ return events;
1546
+ }
1273
1547
  async *#runTurn(options) {
1274
1548
  if (this.#inFlight) {
1275
1549
  throw new CoderAgentError(
@@ -1284,7 +1558,8 @@ var CoderLanguageModel = class {
1284
1558
  (s) => s !== void 0
1285
1559
  );
1286
1560
  const signal = sources.length > 0 ? AbortSignal.any(sources) : void 0;
1287
- const translator = new TurnTranslator({ dynamicToolNames: dynamicToolNames(options.tools) });
1561
+ let translator;
1562
+ let discardSession = false;
1288
1563
  let interruptSent = false;
1289
1564
  const interrupt = () => {
1290
1565
  const id = this.#chatId;
@@ -1324,10 +1599,23 @@ var CoderLanguageModel = class {
1324
1599
  );
1325
1600
  }
1326
1601
  let afterId;
1602
+ let turnCreatedChat = false;
1603
+ let uploadedAttachment = false;
1604
+ let chatHasWorkspace = Boolean(this.#config.workspaceId);
1605
+ if (this.#chatId && this.#lastSeenMessageId === 0) {
1606
+ const { messages } = await this.#config.client.getMessages(
1607
+ this.#chatId,
1608
+ { limit: 1 },
1609
+ signal
1610
+ );
1611
+ this.#lastSeenMessageId = messages[0]?.id ?? 0;
1612
+ }
1327
1613
  if (action.kind === "new-turn") {
1328
1614
  const [modelConfigId, content] = await Promise.all([
1329
1615
  this.#resolveModelConfigId(signal),
1330
- this.#buildContent(action.content, signal)
1616
+ this.#buildContent(action.content, signal, () => {
1617
+ uploadedAttachment = true;
1618
+ })
1331
1619
  ]);
1332
1620
  if (!this.#chatId) {
1333
1621
  const req = {
@@ -1345,6 +1633,8 @@ var CoderLanguageModel = class {
1345
1633
  if (this.#config.planMode) req.plan_mode = this.#config.planMode;
1346
1634
  const chat = await this.#config.client.createChat(req, signal);
1347
1635
  this.#chatId = chat.id;
1636
+ turnCreatedChat = true;
1637
+ if (chat.workspace_id) chatHasWorkspace = true;
1348
1638
  afterId = this.#lastSeenMessageId > 0 ? this.#lastSeenMessageId : void 0;
1349
1639
  } else {
1350
1640
  const resp = await this.#config.client.createChatMessage(
@@ -1370,10 +1660,58 @@ var CoderLanguageModel = class {
1370
1660
  afterId = this.#lastSeenMessageId;
1371
1661
  }
1372
1662
  const chatId = this.#chatId;
1663
+ const dynamicNames = dynamicToolNames(options.tools);
1664
+ translator = new TurnTranslator({
1665
+ dynamicToolNames: dynamicNames,
1666
+ turnCursor: afterId ?? 0
1667
+ });
1373
1668
  let sinceRequiresAction = 0;
1669
+ let grace;
1670
+ let recovery;
1671
+ let recoveryAbort;
1672
+ let recoveryAttempted = false;
1673
+ const stream = this.#config.client.streamEvents(chatId, { afterId, signal });
1374
1674
  try {
1375
- for await (const ev of this.#config.client.streamEvents(chatId, { afterId, signal })) {
1376
- for (const part of translator.ingest(ev)) yield part;
1675
+ let next = stream.next();
1676
+ for (; ; ) {
1677
+ let result;
1678
+ const awaitingActionRequired = translator.terminalStatus === "requires_action" && !translator.clientToolCallSeen;
1679
+ if (awaitingActionRequired && !recoveryAttempted) {
1680
+ grace ??= startGraceTimer(ACTION_REQUIRED_GRACE_MS);
1681
+ const raced = await Promise.race([next, grace.expired]);
1682
+ if (raced === GRACE_EXPIRED) {
1683
+ recoveryAttempted = true;
1684
+ throwIfAborted();
1685
+ recoveryAbort = new AbortController();
1686
+ recovery = this.#recoverRequiresAction(
1687
+ chatId,
1688
+ dynamicNames,
1689
+ afterId,
1690
+ signal ? AbortSignal.any([signal, recoveryAbort.signal]) : recoveryAbort.signal
1691
+ ).catch(() => []);
1692
+ continue;
1693
+ }
1694
+ result = raced;
1695
+ } else if (awaitingActionRequired && recovery) {
1696
+ const raced = await Promise.race([next, recovery]);
1697
+ if (Array.isArray(raced)) {
1698
+ recovery = void 0;
1699
+ throwIfAborted();
1700
+ 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
+ }
1706
+ continue;
1707
+ }
1708
+ result = raced;
1709
+ } else {
1710
+ result = await next;
1711
+ }
1712
+ if (result.done) break;
1713
+ next = stream.next();
1714
+ for (const part of translator.ingest(result.value)) yield part;
1377
1715
  const status = translator.terminalStatus;
1378
1716
  if (status) {
1379
1717
  if (status !== "requires_action") break;
@@ -1383,6 +1721,19 @@ var CoderLanguageModel = class {
1383
1721
  }
1384
1722
  } catch (err) {
1385
1723
  throwIfAborted();
1724
+ if (err instanceof CoderStreamError) {
1725
+ if (turnCreatedChat) {
1726
+ discardSession = true;
1727
+ }
1728
+ const effectful = chatHasWorkspace || Boolean(this.#config.mcpServerIds?.length) || uploadedAttachment;
1729
+ if (turnCreatedChat && !effectful) throw err;
1730
+ throw new CoderStreamError({
1731
+ 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)"}`,
1732
+ url: err.url,
1733
+ cause: err,
1734
+ isRetryable: false
1735
+ });
1736
+ }
1386
1737
  if (err instanceof CoderAgentError && !(err instanceof CoderApiError) && !(err instanceof CoderChatError)) {
1387
1738
  throw new CoderChatError({
1388
1739
  message: `Coder chat stream failed mid-turn: ${err.message}`,
@@ -1391,6 +1742,11 @@ var CoderLanguageModel = class {
1391
1742
  });
1392
1743
  }
1393
1744
  throw err;
1745
+ } finally {
1746
+ grace?.cancel();
1747
+ recoveryAbort?.abort();
1748
+ await stream.return(void 0).catch(() => {
1749
+ });
1394
1750
  }
1395
1751
  throwIfAborted();
1396
1752
  if (!translator.terminalStatus && !translator.error) {
@@ -1405,10 +1761,11 @@ var CoderLanguageModel = class {
1405
1761
  throwIfAborted();
1406
1762
  throw err;
1407
1763
  } finally {
1408
- if (translator.maxMessageId > this.#lastSeenMessageId)
1764
+ if (translator && translator.maxMessageId > this.#lastSeenMessageId)
1409
1765
  this.#lastSeenMessageId = translator.maxMessageId;
1410
1766
  signal?.removeEventListener("abort", interrupt);
1411
- if (!translator.terminalStatus) interrupt();
1767
+ if (!translator?.terminalStatus) interrupt();
1768
+ if (discardSession) this.resetSession();
1412
1769
  this.#inFlight = false;
1413
1770
  }
1414
1771
  }
@@ -1734,7 +2091,7 @@ var CoderAgent = class {
1734
2091
  async uploadToWorkspace(file) {
1735
2092
  if (!this.#workspaceFiles) {
1736
2093
  throw new CoderAgentError(
1737
- "uploadToWorkspace requires a `workspaceFiles` adapter. Construct the agent with `workspaceFiles` (e.g. backed by a @coder/ai-sdk-eve-sandbox session) to enable it."
2094
+ "uploadToWorkspace requires a `workspaceFiles` adapter. Construct the agent with `workspaceFiles` (e.g. backed by a @coder/ai-sdk-sandbox workspace session) to enable it."
1738
2095
  );
1739
2096
  }
1740
2097
  const { path } = await this.#workspaceFiles.write({
@@ -1905,7 +2262,9 @@ export {
1905
2262
  CoderChatClient,
1906
2263
  CoderChatError,
1907
2264
  CoderLanguageModel,
2265
+ CoderStreamError,
1908
2266
  MAX_CHAT_FILE_SIZE_BYTES,
2267
+ TERMINAL_STATUSES,
1909
2268
  TurnTranslator,
1910
2269
  chatMessagesToUIMessages,
1911
2270
  classifyTurnAction,