@nexrall/code-core 1.4.13 → 1.4.15

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.
@@ -6,14 +6,69 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.API_BASE = void 0;
7
7
  exports.chooseFinalContent = chooseFinalContent;
8
8
  exports.streamChat = streamChat;
9
+ exports.cancelTurn = cancelTurn;
9
10
  exports.getBalance = getBalance;
10
11
  exports.exchangeVscodeCode = exchangeVscodeCode;
11
12
  exports.login = login;
12
13
  const eventsource_parser_1 = require("eventsource-parser");
13
14
  const node_fetch_1 = __importDefault(require("node-fetch"));
15
+ const crypto_1 = require("crypto");
14
16
  const index_1 = require("../auth/index");
15
17
  // ─── Constants ────────────────────────────────────────────────────────────────
16
- exports.API_BASE = 'https://api.nexrall.com';
18
+ const DEFAULT_API_BASE = 'https://api.nexrall.com';
19
+ /**
20
+ * Resolve the backend origin, honouring NEXRALL_API_BASE.
21
+ *
22
+ * Everything sensitive in this module goes to API_BASE: the bearer token on
23
+ * `/api/code/chat`, the user's plaintext password on `/api/auth/login/email`, and the
24
+ * full source-code context of every turn.
25
+ *
26
+ * Loopback is allowed freely (it cannot exfiltrate, and the transport-failure tests need
27
+ * it). A REMOTE host requires NEXRALL_ALLOW_API_OVERRIDE=1 in addition, so redirecting a
28
+ * user's credentials is a deliberate act rather than something a stray env var in a CI
29
+ * config or an npm lifecycle script can do silently. Every override is announced on stderr,
30
+ * so it can never be in effect unnoticed. This is still not a hard security boundary —
31
+ * something that can set two env vars can set one — but it removes the ambient-risk case,
32
+ * which is the one that actually happens.
33
+ *
34
+ * An invalid value falls back to production rather than throwing: this runs at module
35
+ * load, and `@nexrall/code-core` is imported statically by the VS Code extension, so a
36
+ * throw here would abort activation and bury the reason in the extension-host log.
37
+ */
38
+ function resolveApiBase() {
39
+ const raw = process.env.NEXRALL_API_BASE?.trim();
40
+ if (!raw)
41
+ return DEFAULT_API_BASE;
42
+ const isLoopback = /^http:\/\/(127\.0\.0\.1|\[::1\]|localhost)(:\d+)?(\/|$)/i.test(raw);
43
+ if (isLoopback) {
44
+ console.error(`⚠️ Nexrall API override in effect (loopback): ${raw.replace(/\/+$/, '')}`);
45
+ return raw.replace(/\/+$/, '');
46
+ }
47
+ // A REMOTE override additionally requires an explicit opt-in flag.
48
+ //
49
+ // Loopback can't exfiltrate anything, so it stays freely available (it is what makes the
50
+ // transport-failure tests possible). A remote host is different: this variable receives the
51
+ // bearer token, the plaintext login password, and the full source context of every turn. In
52
+ // a published package, honouring it from a bare env var means anything that can set one
53
+ // variable — a CI config, a shell profile, an npm lifecycle script that spawns `nex` — can
54
+ // silently redirect all of that, with nothing visible in the UI. Requiring a second,
55
+ // purpose-named variable makes redirection a deliberate act rather than an ambient one.
56
+ if (process.env.NEXRALL_ALLOW_API_OVERRIDE !== '1') {
57
+ console.error(`⚠️ Ignoring NEXRALL_API_BASE="${raw}": redirecting to a remote host also requires ` +
58
+ `NEXRALL_ALLOW_API_OVERRIDE=1, because this variable receives your auth token and ` +
59
+ `source code. Falling back to ${DEFAULT_API_BASE}.`);
60
+ return DEFAULT_API_BASE;
61
+ }
62
+ if (!/^https:\/\//i.test(raw)) {
63
+ console.error(`⚠️ Ignoring NEXRALL_API_BASE="${raw}": a remote override must be https:// so your ` +
64
+ `token is never sent in plaintext. Falling back to ${DEFAULT_API_BASE}.`);
65
+ return DEFAULT_API_BASE;
66
+ }
67
+ const base = raw.replace(/\/+$/, '');
68
+ console.error(`⚠️ Nexrall API override in effect: ${base}`);
69
+ return base;
70
+ }
71
+ exports.API_BASE = resolveApiBase();
17
72
  // ─── Helpers ──────────────────────────────────────────────────────────────────
18
73
  /**
19
74
  * Decide the final assistant `content` array to store in history for a completed
@@ -37,7 +92,18 @@ function chooseFinalContent(rebuilt, rawContent) {
37
92
  if (!Array.isArray(rawContent))
38
93
  return rebuilt;
39
94
  const hasServerSideBlocks = rawContent.some((b) => b && b.type !== 'text' && b.type !== 'tool_use');
40
- return hasServerSideBlocks ? rawContent : rebuilt;
95
+ if (hasServerSideBlocks)
96
+ return rawContent;
97
+ // The rebuild came out EMPTY even though the authoritative message has content. That
98
+ // means the incremental events this reconstruction depends on never landed — a `text`
99
+ // frame split across a chunk boundary and dropped as malformed, or a mid-stream
100
+ // reconnect that resumed past them. Preferring `rebuilt` here would silently return an
101
+ // empty assistant turn and the agent loop would treat the whole turn as a no-op, losing
102
+ // an answer the server had already delivered in full. The raw array is authoritative,
103
+ // so fall back to it.
104
+ if (rebuilt.length === 0 && rawContent.length > 0)
105
+ return rawContent;
106
+ return rebuilt;
41
107
  }
42
108
  function authHeaders() {
43
109
  const token = (0, index_1.getToken)();
@@ -53,6 +119,27 @@ function authHeaders() {
53
119
  const MAX_RETRIES = 5;
54
120
  const RETRY_BASE_MS = 1000;
55
121
  const RETRY_MAX_MS = 30000;
122
+ /**
123
+ * Wall-clock ceiling on ALL reconnect effort for a single streamChat() call.
124
+ *
125
+ * The retry loops are nested — a connect-time loop inside an outer stream loop —
126
+ * so their attempt counts MULTIPLY: 6 × 6 = 36 network attempts, each able to
127
+ * sleep up to 30 s of backoff. In a genuine outage that meant the UI could sit
128
+ * "reconnecting…" for the better part of an hour, silently resending the full
129
+ * conversation (and paying cache-write on it) every single time, before finally
130
+ * admitting failure.
131
+ *
132
+ * A time budget is the honest limit — the user cares about "how long until you
133
+ * tell me it's broken", not how many sockets were opened. Once it's spent, the
134
+ * next failure surfaces immediately. Attempt caps stay as a secondary bound.
135
+ */
136
+ const MAX_TOTAL_RETRY_MS = (() => {
137
+ // Overridable so tests can exercise budget EXHAUSTION, which is otherwise unreachable
138
+ // in under five minutes and is exactly where the tricky control flow lives (the
139
+ // connect-time catch must break rather than fall back into its loop unthrottled).
140
+ const raw = Number(process.env.NEXRALL_MAX_RETRY_MS);
141
+ return Number.isFinite(raw) && raw > 0 ? raw : 5 * 60000;
142
+ })();
56
143
  /** Exponential backoff with full jitter, capped at RETRY_MAX_MS. The jitter spreads
57
144
  * retries out so a fleet of clients hitting the SAME overloaded API (529) don't all
58
145
  * reconnect in lockstep and re-trigger the overload (thundering herd). Returns a delay
@@ -64,30 +151,105 @@ function backoffMs(attempt) {
64
151
  function sleep(ms) {
65
152
  return new Promise((r) => setTimeout(r, ms));
66
153
  }
67
- /** Total number of network-level attempts a single streamChat() call may make
68
- * (connect-time retries × outer stream-retries), used only to report a
69
- * meaningful "attempt N of M" to the UI not a hard limit by itself. */
154
+ /** Upper bound on network-level attempts (connect-time retries × outer stream-retries),
155
+ * reported to the UI as the "of M" in "attempt N of M". It is NOT the real stopping
156
+ * condition MAX_TOTAL_RETRY_MS almost always runs out first, and deliberately so:
157
+ * the nested product (36) exists to bound pathological cases, not to be reached. */
70
158
  const MAX_TOTAL_ATTEMPTS = (MAX_RETRIES + 1) * (MAX_RETRIES + 1);
71
159
  async function streamChat(messages, options, onEvent) {
72
- const { model, env, editorContext, nexrallMd, mode, effort, clientType, abortSignal, extraTools, agents, skills } = options;
160
+ const { model, env, editorContext, nexrallMd, mode, effort, clientType, abortSignal, extraTools, agents, skills, allowRestartAfterRender } = options;
161
+ // IDEMPOTENCY KEY for this logical turn, generated ONCE and reused by every retry
162
+ // below (that reuse is the entire point — a fresh id per attempt would be useless).
163
+ //
164
+ // The failure it closes is expensive and invisible: the backend finishes the model
165
+ // call, bills it, and writes `done` — but the socket dies before the client reads it.
166
+ // The client sees a dropped connection and retries, so the backend runs the SAME turn
167
+ // through the model a SECOND time and bills it again. The first, perfectly good answer
168
+ // is discarded. The user paid twice for one question and nothing on either side could
169
+ // tell. With a stable key the backend recognises the replay and returns the stored
170
+ // result instead of re-invoking the model.
171
+ let turnId = (0, crypto_1.randomUUID)();
73
172
  // Bridge custom abort signal → native AbortController so fetch is cancelled immediately
74
173
  const controller = new AbortController();
75
174
  if (abortSignal?.aborted)
76
175
  throw Object.assign(new Error('Aborted'), { name: 'AbortError' });
77
176
  let abortPoll;
177
+ // Guards against the 50ms poll firing a fresh cancel request on every tick between the
178
+ // signal flipping and streamChat returning — each one a real POST doing Redis work, i.e.
179
+ // a self-inflicted burst on the exact path a user hits when things are already wrong.
180
+ let cancelSent = false;
78
181
  if (abortSignal) {
79
- abortPoll = setInterval(() => { if (abortSignal.aborted)
80
- controller.abort(); }, 50);
182
+ abortPoll = setInterval(() => {
183
+ if (!abortSignal.aborted)
184
+ return;
185
+ if (cancelSent)
186
+ return; // the poll keeps ticking until the outer finally clears it
187
+ cancelSent = true;
188
+ // Tell the SERVER to stop, not just this socket.
189
+ //
190
+ // Closing the socket used to be sufficient — the backend aborted the model when the
191
+ // request closed. That is exactly what changed to make streams resumable: a dropped
192
+ // connection now DETACHES and keeps generating, because a blip must not throw the
193
+ // turn away. But a deliberate Stop arrives as the very same dead socket. Without an
194
+ // explicit cancel the model would run to completion and bill in full while the user
195
+ // believed they had stopped it.
196
+ //
197
+ // Fire-and-forget, before aborting: we don't want Stop to feel slow, and if the
198
+ // request fails the server's detach grace still bounds the damage.
199
+ void cancelTurn(turnId);
200
+ controller.abort();
201
+ }, 50);
81
202
  }
82
- const fetchArgs = [
83
- `${exports.API_BASE}/api/code/chat`,
84
- {
85
- method: 'POST',
86
- headers: authHeaders(),
87
- body: JSON.stringify({ messages, model, env, editorContext, nexrallMd, mode, effort, clientType, extraTools, agents, skills }),
88
- signal: controller.signal,
89
- },
90
- ];
203
+ // ── Resume state ───────────────────────────────────────────────────────────
204
+ // Set when the backend announces (via a `resumable` frame) that this turn's SSE frames
205
+ // are numbered and buffered server-side. Once true, a dropped connection can be RESUMED
206
+ // — reattached mid-response — instead of restarting the turn.
207
+ //
208
+ // Why that matters: restarting re-sends the whole conversation, pays for prompt
209
+ // processing and time-to-first-token again, and re-bills every token already generated.
210
+ // Resuming costs one buffer read and wastes nothing. Older backends never send the frame,
211
+ // so this stays false and the existing restart path is used unchanged.
212
+ let serverResumable = false;
213
+ // Highest frame id received. The resume cursor handed back as Last-Event-ID.
214
+ let lastEventId = 0;
215
+ // Whether the CURRENT attempt is a resume (GET …/resume) rather than a fresh POST.
216
+ let resuming = false;
217
+ // Output received BEFORE the drop, carried across a resume. A resume delivers only the
218
+ // frames past `lastEventId`, so without this the rebuilt message would consist of just
219
+ // the tail and the start of the answer would be lost.
220
+ let carryText = [];
221
+ let carryToolUse = [];
222
+ // Whether ANY output has reached the caller across ALL attempts of this turn, and how
223
+ // much. `emittedToCaller` inside runOnce is per-attempt and resets, but a rollback
224
+ // decision has to consider everything the user can currently see — including output
225
+ // delivered by an earlier attempt before a resume.
226
+ let emittedAnythingAcrossAttempts = false;
227
+ let emittedCharsAcrossAttempts = 0;
228
+ // Built per attempt, because an attempt is EITHER a fresh turn (POST /chat) or a resume
229
+ // of the one already generating server-side (GET /chat/resume + Last-Event-ID). The
230
+ // resume carries no body at all — the conversation is already being processed; re-sending
231
+ // it is exactly the waste this avoids.
232
+ const buildFetchArgs = () => {
233
+ if (resuming) {
234
+ return [
235
+ `${exports.API_BASE}/api/code/chat/resume?turnId=${encodeURIComponent(turnId)}`,
236
+ {
237
+ method: 'GET',
238
+ headers: { ...authHeaders(), 'Last-Event-ID': String(lastEventId) },
239
+ signal: controller.signal,
240
+ },
241
+ ];
242
+ }
243
+ return [
244
+ `${exports.API_BASE}/api/code/chat`,
245
+ {
246
+ method: 'POST',
247
+ headers: authHeaders(),
248
+ body: JSON.stringify({ messages, model, env, editorContext, nexrallMd, mode, effort, clientType, extraTools, agents, skills, turnId }),
249
+ signal: controller.signal,
250
+ },
251
+ ];
252
+ };
91
253
  // A transient mid-stream failure (Anthropic "overloaded_error") arrives as an SSE error
92
254
  // event over an ALREADY-200 response, so the connect-time HTTP-status retry below can't
93
255
  // see it. We detect it in the parser and retry the WHOLE attempt with exponential backoff
@@ -102,7 +264,33 @@ async function streamChat(messages, options, onEvent) {
102
264
  // happened (this is the fix for that gap).
103
265
  let didRetry = false;
104
266
  let totalAttemptsMade = 0;
267
+ // Shared wall-clock budget for every reconnect across BOTH nested retry loops.
268
+ // `retryBudgetLeft()` gates each retry decision and also clamps the backoff sleep,
269
+ // so we never wait longer than the budget we have left.
270
+ //
271
+ // The clock starts at the FIRST retry, not at streamChat() entry. This matters: a
272
+ // healthy first attempt on a huge context can legitimately spend minutes in
273
+ // prompt-processing before the first token (hence FIRST_EVENT_TIMEOUT_MS = 300 s).
274
+ // Anchoring the deadline at entry would let that normal, successful work consume the
275
+ // entire reconnect budget, so the first real network blip would get zero retries —
276
+ // precisely backwards. Measuring only time spent RECONNECTING keeps the guarantee
277
+ // meaningful: "at most 5 minutes of retrying before we tell you it's broken".
278
+ let retryDeadline = 0;
279
+ const retryBudgetLeft = () => retryDeadline === 0 ? MAX_TOTAL_RETRY_MS : retryDeadline - Date.now();
280
+ // Require a MEANINGFUL remainder, not just "> 0". With a bare positive check, 1 ms of
281
+ // budget was enough to launch a whole fresh attempt — which can then sit in
282
+ // FIRST_EVENT_TIMEOUT_MS (300 s) of prompt-processing wait, blowing past the very
283
+ // deadline it was checked against. It also turned a fast-failing connect error
284
+ // (ENOTFOUND) into a tight loop of near-zero sleeps. One base backoff of headroom
285
+ // makes the budget an approximate ceiling instead of a suggestion.
286
+ const canRetry = () => retryBudgetLeft() > RETRY_BASE_MS;
287
+ /** Sleep for `ms`, but never past the shared retry deadline. */
288
+ const sleepWithinBudget = (ms) => sleep(Math.max(0, Math.min(ms, retryBudgetLeft())));
105
289
  const reportRetry = (reason) => {
290
+ // Start the reconnect budget on the first retry (see retryDeadline above). Every
291
+ // retry path funnels through here, so this is the one place it needs to happen.
292
+ if (retryDeadline === 0)
293
+ retryDeadline = Date.now() + MAX_TOTAL_RETRY_MS;
106
294
  totalAttemptsMade++;
107
295
  didRetry = true;
108
296
  onEvent({ type: 'retry', attempt: totalAttemptsMade, maxAttempts: MAX_TOTAL_ATTEMPTS, reason });
@@ -120,24 +308,149 @@ async function streamChat(messages, options, onEvent) {
120
308
  // Retry loop — handles connect-time 429 (rate limit) and 5xx (server errors, incl. 529)
121
309
  let response;
122
310
  let lastErr;
311
+ // How many times we've waited on a `turnInFlight` 409. Drives that path's own backoff,
312
+ // separately from `attempt` (a wait is not a failed attempt — see the 409 branch).
313
+ let inFlightWaits = 0;
314
+ // A 409's body, already consumed to classify it. node-fetch bodies are single-use, so
315
+ // the !response.ok handler below reads this instead of calling response.text() again.
316
+ let errorBodyOverride = null;
123
317
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
124
318
  try {
125
- response = await (0, node_fetch_1.default)(...fetchArgs);
126
- if (response.status === 429 && attempt < MAX_RETRIES) {
319
+ response = await (0, node_fetch_1.default)(...buildFetchArgs());
320
+ if (response.status === 429 && attempt < MAX_RETRIES && canRetry()) {
127
321
  reportRetry('Rate limited by the API — retrying');
128
322
  const retryAfter = parseInt(response.headers.get('retry-after') ?? '0', 10);
129
- await sleep(retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
323
+ await sleepWithinBudget(retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
130
324
  continue;
131
325
  }
132
- if (response.status >= 500 && response.status < 600 && attempt < MAX_RETRIES) {
326
+ if (response.status >= 500 && response.status < 600 && attempt < MAX_RETRIES && canRetry()) {
133
327
  reportRetry(`Server error (${response.status}) — retrying`);
134
- await sleep(backoffMs(attempt));
328
+ await sleepWithinBudget(backoffMs(attempt));
135
329
  continue;
136
330
  }
137
- // 4xx (incl. 413 Payload Too Large, 400 Bad Request, 402 Insufficient balance)
138
- // are DETERMINISTIC given the same body retrying resends the identical oversized
139
- // conversation and can never succeed. Fall through to the non-retryable !response.ok
140
- // handler below instead of burning retries (this was the 16-request "retry storm").
331
+ // 403 `{"error":{"type":"forbidden","message":"Request not allowed"}}` is
332
+ // Anthropic's own upstream response shape (reproduced verbatim through the AI
333
+ // Gateway passthrough see services/aiGatewayClient.js), not something Nexrall's
334
+ // auth layer produces (an expired/invalid Nexrall JWT is a 401/403 with a
335
+ // completely different body — see middleware/auth.js). Reports of this exact
336
+ // error on Anthropic's own CLI describe it as intermittent and gone on retry, not
337
+ // a real permissions failure — so unlike every OTHER 4xx below (which are
338
+ // deterministic given the same body and must not be retried), this one gets a
339
+ // short, capped number of attempts before being treated as terminal. Capped well
340
+ // below MAX_RETRIES so a genuinely revoked/banned key still fails fast instead of
341
+ // burning the whole reconnect budget on something that will never succeed.
342
+ const FORBIDDEN_RETRY_LIMIT = 2;
343
+ if (response.status === 403 && attempt < FORBIDDEN_RETRY_LIMIT && canRetry()) {
344
+ const bodyText = await response.text().catch(() => '');
345
+ let isUpstreamForbidden = false;
346
+ try {
347
+ const parsed = JSON.parse(bodyText);
348
+ isUpstreamForbidden = parsed?.error?.type === 'forbidden';
349
+ }
350
+ catch { /* not JSON — not this shape, fall through as terminal below */ }
351
+ if (isUpstreamForbidden) {
352
+ reportRetry('Upstream rejected the request — retrying');
353
+ await sleepWithinBudget(backoffMs(attempt));
354
+ continue;
355
+ }
356
+ // Not the known transient shape — treat as terminal, but reuse the body we
357
+ // already consumed (node-fetch bodies are single-use) instead of losing it.
358
+ errorBodyOverride = bodyText;
359
+ break;
360
+ }
361
+ // 410 Gone — a resume was refused because the buffered window no longer contains
362
+ // our position (we were away too long, or the turn produced a huge amount in the
363
+ // meantime). Continuity can't be proven, and silently skipping frames would corrupt
364
+ // the message being rebuilt. So abandon resuming and fall back to restarting the
365
+ // turn from scratch: correct, just not free.
366
+ if (response.status === 410 && resuming) {
367
+ await response.text().catch(() => '');
368
+ // A 410 means the BUFFER WINDOW moved past our cursor — it does NOT mean the turn
369
+ // ended. It is almost certainly still generating and still holding its idempotency
370
+ // claim, so re-POSTing with the same turnId would be rejected as `turnInFlight`,
371
+ // and we would wait out the entire retry budget only to fail — while the orphaned
372
+ // turn kept generating and billing.
373
+ //
374
+ // So: stop the old turn (awaited — the claim must be released BEFORE we re-POST),
375
+ // and mint a fresh id, because a restart is semantically a new turn.
376
+ await cancelTurn(turnId);
377
+ turnId = (0, crypto_1.randomUUID)();
378
+ resuming = false;
379
+ serverResumable = false;
380
+ lastEventId = 0;
381
+ carryText = [];
382
+ carryToolUse = [];
383
+ // Snapshot BEFORE resetting: the rollback decision below needs to know what the
384
+ // user can currently see, but the counters must not carry into the restarted turn
385
+ // or a second restart would report characters this one already discarded.
386
+ const hadRendered = emittedAnythingAcrossAttempts;
387
+ const renderedChars = emittedCharsAcrossAttempts;
388
+ emittedAnythingAcrossAttempts = false;
389
+ emittedCharsAcrossAttempts = 0;
390
+ if (hadRendered && !allowRestartAfterRender) {
391
+ // Output is already on screen and the caller can't take it back, so a restart
392
+ // would duplicate it. Surface the failure instead of corrupting the transcript.
393
+ throw new Error('Connection lost and this turn could no longer be resumed. Send your message again.');
394
+ }
395
+ if (hadRendered) {
396
+ onEvent({
397
+ type: 'stream_restart',
398
+ reason: 'Connection lost too long to resume',
399
+ discardedChars: renderedChars,
400
+ });
401
+ }
402
+ if (attempt < MAX_RETRIES && canRetry()) {
403
+ reportRetry('Could not resume — restarting this turn');
404
+ await sleepWithinBudget(backoffMs(attempt));
405
+ continue;
406
+ }
407
+ errorBodyOverride = JSON.stringify({ error: 'Could not resume this turn — send your message again.' });
408
+ break;
409
+ }
410
+ // 409 from the backend's turn-idempotency gate. Two distinct meanings, and the
411
+ // body distinguishes them:
412
+ //
413
+ // turnInFlight — another attempt carrying the same turnId is STILL RUNNING
414
+ // (two sockets racing; typically a reconnect landing before the
415
+ // original finished tearing down). Running it anyway would
416
+ // double-charge one turn, which is what that gate prevents.
417
+ // WAITING is correct: the original either completes (and this
418
+ // attempt is then served the stored answer for free) or fails
419
+ // (and this attempt runs for real).
420
+ // turnCompleted — the turn finished and was billed, but its response is too
421
+ // large to resend. DETERMINISTIC; retrying can never help.
422
+ if (response.status === 409) {
423
+ const body = await response.text();
424
+ let inFlight = false;
425
+ try {
426
+ inFlight = JSON.parse(body)?.turnInFlight === true;
427
+ }
428
+ catch { /* treat as terminal */ }
429
+ // A wait is NOT a failed attempt, so it must not consume the network-error
430
+ // budget: a real coding turn runs for minutes, and spending `MAX_RETRIES` on
431
+ // fixed short sleeps burned the whole allowance in ~10 s and hard-failed while
432
+ // the original turn was perfectly healthy — leaving the user billed for an
433
+ // answer they never received. Only the wall-clock budget bounds this loop.
434
+ if (inFlight && canRetry()) {
435
+ inFlightWaits++;
436
+ reportRetry('This turn is already being processed — waiting for it');
437
+ const retryAfter = parseInt(response.headers.get('retry-after') ?? '0', 10);
438
+ // Honour the server's hint but keep growing our own backoff, so a wildly
439
+ // optimistic Retry-After can't turn this into a hot loop.
440
+ await sleepWithinBudget(Math.max(retryAfter * 1000, backoffMs(inFlightWaits)));
441
+ attempt--; // refund the attempt — this was a wait, not a failure
442
+ continue;
443
+ }
444
+ // Terminal 409 (or the wait budget is spent): rebuild a response-like shape so
445
+ // the shared !response.ok handler below can surface the server's own message.
446
+ errorBodyOverride = body;
447
+ break;
448
+ }
449
+ // Every other 4xx (413 Payload Too Large, 400 Bad Request, 402 Insufficient
450
+ // balance) is DETERMINISTIC given the same body — retrying resends the identical
451
+ // oversized conversation and can never succeed. Fall through to the non-retryable
452
+ // !response.ok handler below instead of burning retries (this was the 16-request
453
+ // "retry storm").
141
454
  break; // success or non-retryable error
142
455
  }
143
456
  catch (err) {
@@ -145,19 +458,31 @@ async function streamChat(messages, options, onEvent) {
145
458
  if (err.name === 'AbortError')
146
459
  throw err;
147
460
  lastErr = err;
148
- if (attempt < MAX_RETRIES) {
461
+ if (attempt < MAX_RETRIES && canRetry()) {
149
462
  // Connection couldn't even be established — network drop / DNS blip / the
150
463
  // machine just woke from sleep and Wi-Fi hasn't reconnected yet.
151
464
  reportRetry('Connection lost — attempting to reconnect');
152
- await sleep(backoffMs(attempt));
465
+ await sleepWithinBudget(backoffMs(attempt));
153
466
  continue;
154
467
  }
468
+ // Out of attempts, or out of reconnect budget. MUST break: falling off the end
469
+ // of the catch re-enters the for-loop with no backoff and no further budget
470
+ // check, firing the remaining attempts back-to-back in milliseconds — a burst
471
+ // of full-conversation POSTs, invisible to the UI. (The 429/5xx branches above
472
+ // already break for the same reason.) Tag the error so the outer loop can
473
+ // report budget exhaustion rather than a bare transport failure.
474
+ // Guard the shape: a patched fetch/undici that throws a string or null would turn
475
+ // a network failure into a TypeError raised by the error handler itself.
476
+ if (!canRetry() && lastErr && typeof lastErr === 'object') {
477
+ Object.assign(lastErr, { retryable: true });
478
+ }
479
+ break;
155
480
  }
156
481
  }
157
482
  if (!response)
158
483
  throw lastErr ?? new Error('Request failed after max retries');
159
484
  if (!response.ok) {
160
- const errText = await response.text();
485
+ const errText = errorBodyOverride ?? await response.text();
161
486
  let errMsg = `API error ${response.status}`;
162
487
  let balance;
163
488
  try {
@@ -178,13 +503,60 @@ async function streamChat(messages, options, onEvent) {
178
503
  if (!response.body) {
179
504
  throw new Error('Response body is null');
180
505
  }
181
- // Accumulated state for building the assistant message
182
- const textParts = [];
183
- const toolUseBlocks = [];
506
+ // Accumulated state for building the assistant message.
507
+ //
508
+ // Seeded from the previous attempt when RESUMING. This is essential and easy to miss:
509
+ // a resume delivers only the frames after `lastEventId`, so the text and tool calls
510
+ // received before the drop live in the outer carry-over. Starting empty here would
511
+ // rebuild the message from the tail alone and silently truncate the answer — the
512
+ // beginning of the response would simply vanish.
513
+ const textParts = resuming ? [...carryText] : [];
514
+ const toolUseBlocks = resuming ? [...carryToolUse] : [];
184
515
  let completedMessage = null;
185
- // Once anything has been shown to the caller (text / tool_use / thinking), a retry would
186
- // duplicate it so from that point on a stream error is surfaced, never retried.
516
+ // Publish the accumulators outward continuously, so if THIS attempt dies the next one
517
+ // can resume on top of what it already received. Assigning the same array references is
518
+ // enough — the resume path copies them.
519
+ carryText = textParts;
520
+ carryToolUse = toolUseBlocks;
521
+ // Once anything has been shown to the caller (text / tool_use / thinking), a naive retry
522
+ // would duplicate it on screen. Two outcomes are possible from here:
523
+ // • caller CAN'T undo its rendering → the failure stays fatal (legacy behaviour);
524
+ // • caller CAN (allowRestartAfterRender) → we tag the error `needsRestart`, the outer
525
+ // loop emits `stream_restart` so the caller drops the partial render, and the turn
526
+ // is retried from scratch. Nothing else needs undoing: tool calls only execute after
527
+ // `message_complete`, which by definition never arrived on a failed attempt.
187
528
  let emittedToCaller = false;
529
+ // Visible characters emitted this attempt — reported in `stream_restart` purely so the
530
+ // UI can say how much output is being discarded.
531
+ let emittedChars = 0;
532
+ /**
533
+ * Classify a transport/stream failure. `retryable` means "safe to retry with nothing
534
+ * shown yet"; `needsRestart` means "safe to retry ONLY if the caller rolls back what
535
+ * it already rendered". Anything else is terminal.
536
+ */
537
+ const tagTransient = (err) => {
538
+ if (!emittedToCaller)
539
+ return Object.assign(err, { retryable: true });
540
+ if (allowRestartAfterRender) {
541
+ return Object.assign(err, { retryable: true, needsRestart: true, discardedChars: emittedChars });
542
+ }
543
+ return err;
544
+ };
545
+ /**
546
+ * True once the turn's complete assistant message has arrived.
547
+ *
548
+ * There is a real window between `message_complete` and `done`: the backend awaits
549
+ * its billing promise (a DB round-trip) before sending `done`, so a socket that
550
+ * dies in between produces a transport error on a turn that has ALREADY fully
551
+ * succeeded. Retrying there would be the worst possible outcome — it discards a
552
+ * complete, correct answer, re-runs the model, and bills the user twice.
553
+ *
554
+ * Every failure path therefore checks this and resolves instead of rejecting: we
555
+ * have the message, and `done` carries no information we need. This also restores
556
+ * the premise the restart design rests on — a restart now genuinely implies
557
+ * `message_complete` never arrived.
558
+ */
559
+ const haveCompleteMessage = () => completedMessage !== null;
188
560
  // Partial input accumulator keyed by tool_use id
189
561
  const partialInputs = {};
190
562
  await new Promise((resolve, reject) => {
@@ -192,11 +564,19 @@ async function streamChat(messages, options, onEvent) {
192
564
  // Declared here so the heartbeat watchdog closure can reference it.
193
565
  const stream = response.body;
194
566
  // ── Heartbeat watchdog ────────────────────────────────────────────────────
195
- // The backend sends ": ping" comments every 20 s. If we receive no data at
196
- // all for 45 s the connection has silently dropped (proxy timeout, network
567
+ // The backend sends ": ping" comments every 10 s. If we receive no data at
568
+ // all for 90 s the connection has silently dropped (proxy timeout, network
197
569
  // blip). Reject so the caller sees an actionable error instead of hanging.
198
- const HEARTBEAT_TIMEOUT_MS = 45000;
199
- // Progress watchdog. The backend's ": ping" keepalive (every 20 s) resets lastDataAt,
570
+ //
571
+ // Sizing: this must tolerate a burst of consecutive LOST pings, not just one.
572
+ // The old pairing (20 s ping / 45 s timeout) survived exactly two misses —
573
+ // routinely exceeded on flaky Wi-Fi, tethered 4G, or the seconds right after a
574
+ // laptop wakes, so a perfectly healthy connection got torn down and the turn
575
+ // restarted for nothing. At 10 s / 90 s we ride out eight consecutive misses
576
+ // while still detecting a truly dead socket faster in absolute terms than the
577
+ // proxy's own idle timeout.
578
+ const HEARTBEAT_TIMEOUT_MS = 90000;
579
+ // Progress watchdog. The backend's ": ping" keepalive (every 10 s) resets lastDataAt,
200
580
  // so the heartbeat above only catches a fully dead connection — NOT an upstream model
201
581
  // stall, where Anthropic stops emitting tokens but the backend keeps pinging. That case
202
582
  // would otherwise hang on "Thinking…" forever. lastProgressAt is bumped ONLY on real
@@ -210,13 +590,34 @@ async function streamChat(messages, options, onEvent) {
210
590
  // attempt warmed the prompt cache, so the retry's TTFT was short. Allow 300 s.
211
591
  // • AFTER the first model event, tokens are flowing; a 150 s silent gap mid-stream
212
592
  // is a genuine stall.
213
- const FIRST_EVENT_TIMEOUT_MS = 300000;
214
- const PROGRESS_TIMEOUT_MS = 150000;
593
+ // Overridable so tests can reach the stall path in seconds instead of minutes.
594
+ // That path is where the subtlest bug in this file lives: `lastProgressAt` stops
595
+ // advancing at `message_complete` while heartbeat pings keep `lastDataAt` fresh, so
596
+ // this watchdog — and only this one — can fire on a turn that already succeeded.
597
+ const FIRST_EVENT_TIMEOUT_MS = Number(process.env.NEXRALL_FIRST_EVENT_TIMEOUT_MS) || 300000;
598
+ const PROGRESS_TIMEOUT_MS = Number(process.env.NEXRALL_PROGRESS_TIMEOUT_MS) || 150000;
215
599
  let sawModelEvent = false;
216
600
  let lastDataAt = Date.now();
217
601
  let lastProgressAt = Date.now();
218
602
  const heartbeatWatchdog = setInterval(() => {
219
603
  const now = Date.now();
604
+ // The turn already succeeded and we're only waiting on the trailing `done`.
605
+ // Finish with what we have rather than discarding a complete message and
606
+ // re-running the model.
607
+ //
608
+ // The silence condition is essential, not decorative: the backend AWAITS its
609
+ // billing write before emitting `balance_status` and `done`, so the gap after
610
+ // `message_complete` is normal, expected, and can run for seconds on a slow DB.
611
+ // Firing on the next tick regardless would tear down a perfectly healthy socket
612
+ // and swallow the low-balance nudge — trading the bug this guard was written to
613
+ // fix for a different one. Wait out the full heartbeat timeout first; only then
614
+ // is the trailing frame genuinely never coming.
615
+ if (haveCompleteMessage() && now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
616
+ clearInterval(heartbeatWatchdog);
617
+ stream.destroy?.();
618
+ resolve();
619
+ return;
620
+ }
220
621
  if (now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
221
622
  clearInterval(heartbeatWatchdog);
222
623
  stream.destroy?.();
@@ -227,24 +628,36 @@ async function streamChat(messages, options, onEvent) {
227
628
  // `now - lastDataAt` immediately reads as a large gap even though nothing is
228
629
  // actually wrong with the connection; without this it used to hard-fail
229
630
  // instead of silently reconnecting.
230
- const deadErr = new Error(emittedToCaller
231
- ? 'Connection lost — no data received for 45 s. Retry your message.'
232
- : 'Connection lost — no data received for 45 s. Reconnecting…');
233
- reject(emittedToCaller ? deadErr : Object.assign(deadErr, { retryable: true }));
631
+ const recoverable = !emittedToCaller || !!allowRestartAfterRender;
632
+ const secs = Math.round(HEARTBEAT_TIMEOUT_MS / 1000);
633
+ reject(tagTransient(new Error(recoverable
634
+ ? `Connection lost no data received for ${secs} s. Reconnecting…`
635
+ : `Connection lost — no data received for ${secs} s. Retry your message.`)));
234
636
  return;
235
637
  }
236
638
  const stallLimitMs = sawModelEvent ? PROGRESS_TIMEOUT_MS : FIRST_EVENT_TIMEOUT_MS;
237
639
  if (now - lastProgressAt > stallLimitMs) {
238
640
  clearInterval(heartbeatWatchdog);
239
641
  stream.destroy?.();
642
+ // Same rule as the heartbeat branch above, and MORE easily reached here: this
643
+ // watchdog reads `lastProgressAt`, which only real model events bump. Once
644
+ // `message_complete` lands, nothing bumps it again — so while the backend awaits
645
+ // its billing write, the heartbeat's `: ping` keeps `lastDataAt` fresh (both
646
+ // branches above correctly stay silent) but THIS clock runs out and reports a
647
+ // "stall" on a turn that already succeeded. Restarting there would discard a
648
+ // correct answer and re-bill the model call, repeatedly. The turn is complete;
649
+ // finish with it.
650
+ if (haveCompleteMessage()) {
651
+ resolve();
652
+ return;
653
+ }
240
654
  // If nothing has been shown to the caller yet, a fresh attempt can't duplicate
241
655
  // output — tag it retryable so the outer loop transparently retries the turn
242
656
  // instead of killing it. Once output has been emitted, a retry would duplicate
243
657
  // rendered text/tool calls, so surface the stall as a terminal error.
244
- const stallErr = new Error(sawModelEvent
245
- ? `The model stopped responding mid-stream (no output for ${Math.round(stallLimitMs / 1000)} s). Retry your message.`
246
- : `The model did not start responding within ${Math.round(stallLimitMs / 1000)} s (large context can take a while to process). Retry your message.`);
247
- reject(emittedToCaller ? stallErr : Object.assign(stallErr, { retryable: true }));
658
+ reject(tagTransient(new Error(sawModelEvent
659
+ ? `The model stopped responding mid-stream (no output for ${Math.round(stallLimitMs / 1000)} s).`
660
+ : `The model did not start responding within ${Math.round(stallLimitMs / 1000)} s (large context can take a while to process).`)));
248
661
  return;
249
662
  }
250
663
  }, 5000);
@@ -255,6 +668,15 @@ async function streamChat(messages, options, onEvent) {
255
668
  return;
256
669
  }
257
670
  {
671
+ // Track the server-assigned frame id. This is the resume cursor: on a drop we
672
+ // hand it back via Last-Event-ID and receive exactly the frames we missed,
673
+ // instead of re-running the whole turn. Only present on resumable turns.
674
+ const frameId = event.id;
675
+ if (frameId) {
676
+ const n = Number(frameId);
677
+ if (Number.isFinite(n) && n > lastEventId)
678
+ lastEventId = n;
679
+ }
258
680
  const raw = event.data;
259
681
  if (!raw || raw === '[DONE]') {
260
682
  resolve();
@@ -283,6 +705,9 @@ async function streamChat(messages, options, onEvent) {
283
705
  const text = typeof evt.text === 'string' ? evt.text : '';
284
706
  textParts.push(text);
285
707
  emittedToCaller = true;
708
+ emittedChars += text.length;
709
+ emittedAnythingAcrossAttempts = true;
710
+ emittedCharsAcrossAttempts += text.length;
286
711
  onEvent({ type: 'text', text });
287
712
  break;
288
713
  }
@@ -305,7 +730,14 @@ async function streamChat(messages, options, onEvent) {
305
730
  }
306
731
  }
307
732
  toolUseBlocks.push(block);
733
+ // Deliberately does NOT bump `emittedChars`: a streamed tool_use is a
734
+ // preview that no consumer renders. The agent loop's `tool_use` case is a
735
+ // no-op and the UI's tool row is only drawn later, from `options.onToolUse`
736
+ // during execution — which happens after streamChat() returns and so never
737
+ // runs on a failed attempt. `emittedToCaller` is still set, conservatively,
738
+ // so a restart continues to require an explicit rollback handler.
308
739
  emittedToCaller = true;
740
+ emittedAnythingAcrossAttempts = true;
309
741
  onEvent({ type: 'tool_use', id: block.id, name: block.name, input: block.input });
310
742
  break;
311
743
  }
@@ -360,6 +792,17 @@ async function streamChat(messages, options, onEvent) {
360
792
  if (typeof evt.input_tokens === 'number' && typeof evt.output_tokens === 'number') {
361
793
  onEvent({
362
794
  type: 'usage',
795
+ // Forwarded, not dropped: the backend tags the usage of an attempt it
796
+ // billed but never completed (its stream `abort` path). Without this
797
+ // the flag dies here and every consumer that sums usage silently folds
798
+ // a discarded attempt's tokens into the successful turn's total.
799
+ ...(evt.partial === true ? { partial: true } : {}),
800
+ // `replayed` means these tokens are being reported a SECOND time: the
801
+ // turn completed and was billed on an earlier attempt whose `done` never
802
+ // reached us, and the backend served this one from its idempotency cache
803
+ // instead of re-running the model. Nothing new was charged, so a cost
804
+ // display must not add them again.
805
+ ...(evt.replayed === true ? { replayed: true } : {}),
363
806
  usage: {
364
807
  input_tokens: evt.input_tokens,
365
808
  output_tokens: evt.output_tokens,
@@ -380,6 +823,9 @@ async function streamChat(messages, options, onEvent) {
380
823
  const text = typeof evt.text === 'string' ? evt.text : '';
381
824
  if (text) {
382
825
  emittedToCaller = true;
826
+ emittedChars += text.length;
827
+ emittedAnythingAcrossAttempts = true;
828
+ emittedCharsAcrossAttempts += text.length;
383
829
  onEvent({ type: 'thinking', text });
384
830
  }
385
831
  break;
@@ -397,10 +843,20 @@ async function streamChat(messages, options, onEvent) {
397
843
  const text = typeof evt.text === 'string' ? evt.text : '';
398
844
  if (text) {
399
845
  emittedToCaller = true;
846
+ emittedChars += text.length;
847
+ emittedAnythingAcrossAttempts = true;
848
+ emittedCharsAcrossAttempts += text.length;
400
849
  onEvent({ type: 'thinking_delta', text });
401
850
  }
402
851
  break;
403
852
  }
853
+ case 'resumable': {
854
+ // The backend numbers this turn's frames and buffers them, so a dropped
855
+ // connection can reattach mid-response rather than re-running the turn.
856
+ // Purely a capability announcement — nothing to show the user.
857
+ serverResumable = true;
858
+ break;
859
+ }
404
860
  case 'balance_status': {
405
861
  const balance = typeof evt.balance === 'number' ? evt.balance : 0;
406
862
  const zero = !!evt.zero;
@@ -415,12 +871,36 @@ async function streamChat(messages, options, onEvent) {
415
871
  case 'error': {
416
872
  const message = typeof evt.message === 'string' ? evt.message :
417
873
  typeof evt.error === 'string' ? evt.error : 'Unknown SSE error';
874
+ // The GET /chat/resume tail loop sends this when the turn's Redis frame
875
+ // buffer developed a gap (trimmed window, a swallowed flush error, or the
876
+ // owning pod died) or the turn is simply gone — see routes/code.js. Unlike
877
+ // `isRetryableStreamMsg` below, this is NOT a fuzzy text match: the backend
878
+ // sets the flag explicitly, so it can never silently stop matching if either
879
+ // side rewrites the English message. There is no cursor left to resume
880
+ // from — a plain "retryable" resume-with-Last-Event-ID would just 410 again
881
+ // — so this always needs a fresh turnId, which the outer loop mints via
882
+ // `forceRestart`.
883
+ const notResumable = evt.notResumable === true;
418
884
  // Transient upstream failure before any output → let the outer loop retry it
419
885
  // transparently instead of killing the turn (this is the "Overloaded" case).
420
- if (isRetryableStreamMsg(message) && !emittedToCaller) {
886
+ // But never retry once the turn's message has already been delivered in
887
+ // full: a late error frame (e.g. the backend's billing write failing after
888
+ // `message_complete`) must not discard a correct answer and re-bill the
889
+ // model call. Surface it as a notice and finish with what we have.
890
+ if (haveCompleteMessage()) {
891
+ clearInterval(heartbeatWatchdog);
892
+ onEvent({ type: 'error', message });
893
+ resolve();
894
+ }
895
+ else if (notResumable && (!emittedToCaller || allowRestartAfterRender)) {
896
+ clearInterval(heartbeatWatchdog);
897
+ stream.destroy?.();
898
+ reject(Object.assign(tagTransient(new Error(message)), { forceRestart: true }));
899
+ }
900
+ else if (isRetryableStreamMsg(message) && (!emittedToCaller || allowRestartAfterRender)) {
421
901
  clearInterval(heartbeatWatchdog);
422
902
  stream.destroy?.();
423
- reject(Object.assign(new Error(message), { retryable: true }));
903
+ reject(tagTransient(new Error(message)));
424
904
  }
425
905
  else {
426
906
  onEvent({ type: 'error', message });
@@ -459,16 +939,21 @@ async function streamChat(messages, options, onEvent) {
459
939
  resolve();
460
940
  return;
461
941
  }
942
+ // Socket died AFTER the turn was fully delivered — the only thing still
943
+ // outstanding was the trailing `done`. Treat as success: rejecting here would
944
+ // throw away a complete answer and (with restart enabled) re-run and re-bill
945
+ // the entire turn. This is the widest such window in practice, because the
946
+ // backend does a DB write between `message_complete` and `done`.
947
+ if (haveCompleteMessage()) {
948
+ resolve();
949
+ return;
950
+ }
462
951
  // Transport-level failures ("Premature close" / ECONNRESET / socket hang up)
463
952
  // happen whenever the backend restarts mid-deploy or a proxy drops the socket.
464
953
  // They are exactly as transient as an overloaded_error — if nothing has been
465
954
  // emitted to the caller yet, retry the attempt transparently instead of
466
955
  // surfacing "Stream failed: Premature close" and killing the whole turn.
467
- if (!emittedToCaller) {
468
- reject(Object.assign(err, { retryable: true }));
469
- return;
470
- }
471
- reject(err);
956
+ reject(tagTransient(err));
472
957
  });
473
958
  });
474
959
  if (completedMessage) {
@@ -497,11 +982,80 @@ async function streamChat(messages, options, onEvent) {
497
982
  catch (err) {
498
983
  if (abortSignal?.aborted || controller.signal.aborted || err.name === 'AbortError')
499
984
  throw err;
500
- if (err.retryable && sAttempt < MAX_RETRIES) {
985
+ const e = err;
986
+ if (e.retryable && sAttempt < MAX_RETRIES && canRetry()) {
987
+ // The backend explicitly said this turnId's buffer is unrecoverable (gap in the
988
+ // Redis frame stream, or the owning pod is gone — see the `notResumable` case in
989
+ // client.ts's SSE parser). Resuming with the same `lastEventId` would just hit
990
+ // the same wall again, so — exactly like the inline 410-while-resuming handling
991
+ // above — release the old claim, mint a fresh turnId, and restart clean instead
992
+ // of falling into the "prefer resuming" branch below.
993
+ if (e.forceRestart) {
994
+ await cancelTurn(turnId);
995
+ turnId = (0, crypto_1.randomUUID)();
996
+ resuming = false;
997
+ serverResumable = false;
998
+ lastEventId = 0;
999
+ carryText = [];
1000
+ carryToolUse = [];
1001
+ const hadRendered = emittedAnythingAcrossAttempts;
1002
+ const renderedChars = emittedCharsAcrossAttempts;
1003
+ emittedAnythingAcrossAttempts = false;
1004
+ emittedCharsAcrossAttempts = 0;
1005
+ if (hadRendered && !allowRestartAfterRender) {
1006
+ throw new Error('Connection lost and this turn could no longer be resumed. Send your message again.');
1007
+ }
1008
+ if (hadRendered) {
1009
+ onEvent({
1010
+ type: 'stream_restart',
1011
+ reason: err.message || 'Could not resume — restarting this turn',
1012
+ discardedChars: renderedChars,
1013
+ });
1014
+ }
1015
+ reportRetry('Could not resume — restarting this turn');
1016
+ await sleepWithinBudget(backoffMs(sAttempt));
1017
+ continue;
1018
+ }
1019
+ // PREFER RESUMING over restarting.
1020
+ //
1021
+ // The turn is still generating on the server (a dropped socket no longer aborts
1022
+ // it), and every frame it emits is numbered and buffered. So instead of throwing
1023
+ // the work away we reattach at `lastEventId` and receive only what we missed:
1024
+ // nothing re-processed, nothing re-billed, nothing re-rendered — which also means
1025
+ // NO `stream_restart` and no rollback for the caller, because the output already
1026
+ // on screen remains valid.
1027
+ //
1028
+ // Requires having seen at least one numbered frame; without a cursor there is
1029
+ // nothing to resume from and a fresh attempt is correct anyway.
1030
+ if (serverResumable && lastEventId > 0) {
1031
+ resuming = true;
1032
+ reportRetry(err.message || 'Connection interrupted — resuming');
1033
+ await sleepWithinBudget(backoffMs(sAttempt));
1034
+ continue;
1035
+ }
1036
+ // Not resumable — fall back to restarting the whole turn. The dead attempt had
1037
+ // already rendered output, so the caller must throw that partial render away
1038
+ // BEFORE the replacement starts streaming or the re-sent text appears twice.
1039
+ // Emitted first so the UI is clean by the time "reconnecting…" goes up.
1040
+ if (e.needsRestart) {
1041
+ onEvent({
1042
+ type: 'stream_restart',
1043
+ reason: err.message || 'Connection interrupted',
1044
+ discardedChars: e.discardedChars ?? 0,
1045
+ });
1046
+ }
501
1047
  reportRetry(err.message || 'Connection interrupted — reconnecting');
502
- await sleep(backoffMs(sAttempt));
1048
+ await sleepWithinBudget(backoffMs(sAttempt));
503
1049
  continue;
504
1050
  }
1051
+ // Out of retries (or out of time budget) — make the distinction explicit so
1052
+ // "it just gave up" is never a mystery. A budget exhaustion means the network
1053
+ // was down for the entire window, which is actionable in a way that a bare
1054
+ // "Stream failed" is not.
1055
+ if (e.retryable && !canRetry()) {
1056
+ throw Object.assign(new Error(`${err.message} — gave up after ${Math.round(MAX_TOTAL_RETRY_MS / 60000)} minutes of reconnect attempts. ` +
1057
+ `Check your connection and resend; completed work in this turn is preserved.`), { retryBudgetExhausted: true });
1058
+ }
505
1059
  throw err;
506
1060
  }
507
1061
  }
@@ -512,6 +1066,31 @@ async function streamChat(messages, options, onEvent) {
512
1066
  clearInterval(abortPoll);
513
1067
  }
514
1068
  }
1069
+ // ─── Cancel a turn ────────────────────────────────────────────────────────────
1070
+ /**
1071
+ * Ask the server to stop generating a turn.
1072
+ *
1073
+ * This is REQUIRED for Stop to work, not an optimisation. A dropped socket used to abort
1074
+ * the model; now it detaches and keeps generating so a network blip doesn't discard the
1075
+ * turn (see the resume machinery above). Since a deliberate Stop looks identical to a
1076
+ * blip from the server's side, it has to be signalled explicitly — otherwise the model
1077
+ * would run to completion and bill in full after the user pressed Stop.
1078
+ *
1079
+ * Best-effort and never throws: Stop must feel instant, and the server's detach grace
1080
+ * bounds the cost if this request never lands.
1081
+ */
1082
+ async function cancelTurn(turnId) {
1083
+ try {
1084
+ await (0, node_fetch_1.default)(`${exports.API_BASE}/api/code/chat/cancel`, {
1085
+ method: 'POST',
1086
+ headers: { ...authHeaders(), 'Content-Type': 'application/json' },
1087
+ body: JSON.stringify({ turnId }),
1088
+ });
1089
+ }
1090
+ catch {
1091
+ // Ignored deliberately — see above.
1092
+ }
1093
+ }
515
1094
  // ─── Get Balance ──────────────────────────────────────────────────────────────
516
1095
  async function getBalance() {
517
1096
  const response = await (0, node_fetch_1.default)(`${exports.API_BASE}/api/code/balance`, {