aegis-desktop 0.8.14 → 0.8.16

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.
@@ -58,6 +58,45 @@ function baseOf(baseURL) {
58
58
  return String(baseURL || DEFAULT_BASE).replace(/\/+$/, '');
59
59
  }
60
60
 
61
+ /**
62
+ * DeepSeek's chat template (also the one a locally-pulled `deepseek-*` Ollama
63
+ * model uses) wraps tool calls and turn boundaries in special tokens —
64
+ * fullwidth pipe (U+FF5C) delimiting an identifier that uses ▁ (U+2581) as its
65
+ * word separator, e.g. |tool▁calls▁begin|, |tool▁call▁end|, |Assistant|,
66
+ * |begin▁of▁sentence|. These are chat-template internals, never user-facing
67
+ * text; when the local daemon's tool-call path leaks them into `delta.content`
68
+ * they used to reach the screen verbatim — reported as the pasted
69
+ * "||DSML||" envelope. Returns a per-turn stripper closure with a short
70
+ * lookback buffer so a token split across two SSE chunks isn't half-rendered
71
+ * before its closing pipe arrives.
72
+ */
73
+ const DEEPSEEK_ENVELOPE_RE = /|[A-Za-z][A-Za-z0-9]*(?:▁[A-Za-z0-9]+)*|/g;
74
+ function makeDeepSeekEnvelopeStripper() {
75
+ let buf = '';
76
+ const strip = (chunk) => {
77
+ buf += chunk;
78
+ buf = buf.replace(DEEPSEEK_ENVELOPE_RE, '');
79
+ const openIdx = buf.lastIndexOf('|');
80
+ if (openIdx !== -1) {
81
+ const tail = buf.slice(openIdx);
82
+ if (tail.length <= 60 && /^|[A-Za-z0-9▁]*$/.test(tail)) {
83
+ const out = buf.slice(0, openIdx);
84
+ buf = tail;
85
+ return out;
86
+ }
87
+ }
88
+ const out = buf;
89
+ buf = '';
90
+ return out;
91
+ };
92
+ strip.flush = () => {
93
+ const out = buf;
94
+ buf = '';
95
+ return out;
96
+ };
97
+ return strip;
98
+ }
99
+
61
100
  /**
62
101
  * Whether a base URL addresses hardware on this machine (or on this private
63
102
  * network) — the gate that decides whether the free lane exists at all.
@@ -369,6 +408,7 @@ async function chat({
369
408
  let usage = null;
370
409
  const toolCalls = [];
371
410
  const byIndex = new Map();
411
+ const stripEnvelope = makeDeepSeekEnvelopeStripper();
372
412
 
373
413
  await readSSE(
374
414
  res,
@@ -378,8 +418,11 @@ async function chat({
378
418
  if (!choice) return;
379
419
  const delta = choice.delta || {};
380
420
  if (typeof delta.content === 'string' && delta.content) {
381
- content += delta.content;
382
- if (typeof onDelta === 'function') onDelta({ text: delta.content });
421
+ const clean = stripEnvelope(delta.content);
422
+ if (clean) {
423
+ content += clean;
424
+ if (typeof onDelta === 'function') onDelta({ text: clean });
425
+ }
383
426
  }
384
427
  const frags = delta.tool_calls || [];
385
428
  for (const f of frags) {
@@ -399,6 +442,15 @@ async function chat({
399
442
  { idleTimeoutMs: SSE_IDLE_TIMEOUT_MS }
400
443
  );
401
444
 
445
+ // A trailing '|…' that never closed (stream ended right after it, or it
446
+ // was real prose all along) is real content, not an unfinished envelope
447
+ // token — release it rather than dropping it silently.
448
+ const strayTail = stripEnvelope.flush();
449
+ if (strayTail) {
450
+ content += strayTail;
451
+ if (typeof onDelta === 'function') onDelta({ text: strayTail });
452
+ }
453
+
402
454
  for (const [i, acc] of [...byIndex.entries()].sort((a, b) => a[0] - b[0])) {
403
455
  if (!acc.function.name && !acc.function.arguments) continue;
404
456
  // Ollama does not always mint an id; the engine's tool loop keys results
package/main.js CHANGED
@@ -682,11 +682,28 @@ function providerRouteConfigured(engine) {
682
682
  * import to stay unit-testable.
683
683
  */
684
684
  function createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConfigured, avatar) {
685
- // One service for the window's lifetime, bound to the app's data dir. It is
686
- // created lazily inside its handlers rather than here, so a broken shared
687
- // module surfaces as an error in the fingerprint panel instead of preventing
688
- // the window from opening at all.
689
- const fingerprint = createFingerprintService({ dir });
685
+ // One service for the window's lifetime, bound to the app's data dir — but
686
+ // created LAZILY, which is what the comment here always claimed and what the
687
+ // code did not do. `createFingerprintService({ dir })` throws on a missing
688
+ // dir, and a test or a call site that has no userData yet (every
689
+ // `createIpcDispatch(client)` in `test/desktop-shell.mjs`, and any bootstrap
690
+ // where `resolveUserDataDir` came back empty) died at construction — taking
691
+ // the whole dispatch surface with it, not just the fingerprint panel. The
692
+ // proxy defers construction to the first fingerprint call, so a broken shared
693
+ // module surfaces as an error in that panel instead of preventing the window
694
+ // from opening at all, exactly as documented below.
695
+ let fingerprintService = null;
696
+ const fingerprint = new Proxy({}, {
697
+ get(_target, prop) {
698
+ if (!fingerprintService) fingerprintService = createFingerprintService({ dir });
699
+ const value = fingerprintService[prop];
700
+ return typeof value === 'function' ? value.bind(fingerprintService) : value;
701
+ },
702
+ has(_target, prop) {
703
+ if (!fingerprintService) fingerprintService = createFingerprintService({ dir });
704
+ return prop in fingerprintService;
705
+ },
706
+ });
690
707
  // `providerConfigured` is an injected thunk (bootstrap() passes one that reads
691
708
  // the settings store) so this module keeps its no-Electron, unit-testable
692
709
  // shape; absent in tests and older call sites, where it reads as "no".
@@ -716,14 +733,29 @@ function createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConf
716
733
  // In-app API key entry (plan rebuild): replace the live client key and
717
734
  // persist it encrypted at rest via the settings store. The renderer only
718
735
  // ever sees the masked preview back — never the raw key.
736
+ // Resolves `{ ok, ... }` / `{ ok: false, error }` rather than throwing —
737
+ // same reasoning as the fingerprint handlers below: an IPC rejection
738
+ // reaches the renderer as an opaque "Error invoking remote method" with
739
+ // the real cause buried in main-process logs, and key entry is the one
740
+ // flow every user hits on first run.
719
741
  setApiKey: (payload) => {
720
- const key = payload && payload.key;
721
- aegis.setApiKey(key || '');
722
- if (persistApiKey) persistApiKey(aegis.apiKey);
723
- return { keyConfigured: Boolean(aegis.apiKey), keyMask: maskKey(aegis.apiKey) };
742
+ try {
743
+ const key = payload && payload.key;
744
+ aegis.setApiKey(key || '');
745
+ if (persistApiKey) persistApiKey(aegis.apiKey);
746
+ return { ok: true, keyConfigured: Boolean(aegis.apiKey), keyMask: maskKey(aegis.apiKey) };
747
+ } catch (e) {
748
+ return { ok: false, error: (e && e.message) || String(e) };
749
+ }
724
750
  },
725
751
 
726
- verifyApiKey: () => aegis.verifyApiKey(),
752
+ verifyApiKey: async () => {
753
+ try {
754
+ return { ok: true, ...(await aegis.verifyApiKey()) };
755
+ } catch (e) {
756
+ return { ok: false, error: (e && e.message) || String(e) };
757
+ }
758
+ },
727
759
  tokenBankBalance: () => aegis.tokenBankBalance(),
728
760
 
729
761
  // Billing (plan upgrade + token-bank top-up). aegis1 creates a Stripe
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.14",
4
+ "version": "0.8.16",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
package/renderer/app.js CHANGED
@@ -664,6 +664,21 @@ function rafPainter(paint) {
664
664
  return transcript ? transcript.paint(paint) : paint;
665
665
  }
666
666
 
667
+ /**
668
+ * What the last stop attempt actually achieved.
669
+ *
670
+ * `models.cancel()` travels over IPC and can be REFUSED — the engine has no
671
+ * AbortController for that session id (`{ ok: false }`), the channel is not
672
+ * registered (the promise rejects), or the call throws. The old code wrapped
673
+ * this in a bare `catch` and ignored the returned promise, so every one of
674
+ * those became silence: the button said "stopping…", the turn kept streaming,
675
+ * and the only visible evidence was a salvage bubble that never appeared —
676
+ * which reads as a salvage bug several layers away from the real one. Recorded
677
+ * here so the smoke run can fail on it, and the rejection is handled so it
678
+ * cannot vanish into an unhandled-rejection warning nobody reads.
679
+ */
680
+ let lastStopOutcome = null;
681
+
667
682
  /**
668
683
  * Stop the turn running right now. The transport already honours the abort all
669
684
  * the way down (engine.cancel -> AbortController -> the cloud client's fetch),
@@ -682,10 +697,43 @@ function stopPendingTurn() {
682
697
  // could be left `true` by an earlier turn and make an unrelated transport
683
698
  // failure look like a stop the user asked for.
684
699
  stoppedTurn = runningTurn;
700
+ const stopSessionId = pendingSessionId;
701
+ lastStopOutcome = { sessionId: stopSessionId, at: Date.now(), state: 'requested' };
685
702
  try {
686
- models.cancel(pendingSessionId);
687
- } catch {
688
- /* a dead controller is not an error */
703
+ const pending = models.cancel(stopSessionId);
704
+ if (pending && typeof pending.then === 'function') {
705
+ pending.then(
706
+ (r) => {
707
+ lastStopOutcome = {
708
+ sessionId: stopSessionId,
709
+ at: Date.now(),
710
+ state: 'settled',
711
+ // The engine reports `{ ok: Boolean(controller) }` — a false here
712
+ // means it had nothing to abort, i.e. the stop was refused. Not an
713
+ // error, but never a success either.
714
+ accepted: !r || r.ok !== false,
715
+ result: r === undefined ? null : r,
716
+ };
717
+ },
718
+ (e) => {
719
+ lastStopOutcome = {
720
+ sessionId: stopSessionId,
721
+ at: Date.now(),
722
+ state: 'rejected',
723
+ accepted: false,
724
+ error: (e && e.message) || String(e),
725
+ };
726
+ }
727
+ );
728
+ }
729
+ } catch (e) {
730
+ lastStopOutcome = {
731
+ sessionId: stopSessionId,
732
+ at: Date.now(),
733
+ state: 'threw',
734
+ accepted: false,
735
+ error: (e && e.message) || String(e),
736
+ };
689
737
  }
690
738
  // The abort is not instantaneous. Marking the button is all the feedback that
691
739
  // survives the trip: `setBusy(false)` deletes the entire pending bubble on
@@ -919,9 +967,11 @@ function renderUpdateBanner(state) {
919
967
  async function loadAccountInfo() {
920
968
  try {
921
969
  const verify = await aegis.verifyApiKey();
922
- els.plan.textContent = verify && verify.valid
923
- ? (verify.plan || 'active')
924
- : 'invalid key';
970
+ if (!verify || !verify.ok) {
971
+ els.plan.textContent = 'unavailable';
972
+ } else {
973
+ els.plan.textContent = verify.valid ? (verify.plan || 'active') : 'invalid key';
974
+ }
925
975
  } catch {
926
976
  els.plan.textContent = 'unavailable';
927
977
  }
@@ -1050,11 +1100,14 @@ async function saveApiKey() {
1050
1100
  els.apiKeyHint.textContent = 'saving…';
1051
1101
  try {
1052
1102
  const res = await aegis.setApiKey(key);
1103
+ if (!res || !res.ok) {
1104
+ throw new Error((res && res.error) || 'save failed');
1105
+ }
1053
1106
  // Never retain the raw key in the DOM once saved — show only the masked
1054
1107
  // preview the main process returned.
1055
1108
  els.apiKeyInput.value = '';
1056
1109
  els.apiKeyHint.textContent = key
1057
- ? `saved (${res && res.keyMask ? res.keyMask : 'configured'})`
1110
+ ? `saved (${res.keyMask ? res.keyMask : 'configured'})`
1058
1111
  : 'key cleared';
1059
1112
  await refreshAfterKeyChange();
1060
1113
  } catch (err) {
@@ -1071,7 +1124,10 @@ async function verifyAegisKey() {
1071
1124
  els.apiKeyHint.textContent = 'verifying…';
1072
1125
  try {
1073
1126
  const verify = await aegis.verifyApiKey();
1074
- els.apiKeyHint.textContent = verify && verify.valid
1127
+ if (!verify || !verify.ok) {
1128
+ throw new Error((verify && verify.error) || 'verify failed');
1129
+ }
1130
+ els.apiKeyHint.textContent = verify.valid
1075
1131
  ? `valid (${verify.plan || 'active'})`
1076
1132
  : 'invalid key';
1077
1133
  } catch (err) {
@@ -1772,7 +1828,27 @@ const memoryView = {
1772
1828
  * instead. Confusing the two is how a save would read a hidden textarea. */
1773
1829
  function overlayOpen() {
1774
1830
  if (memoryOverlayOpen()) return true;
1775
- return !!(window.aegisFingerprint && window.aegisFingerprint.isOpen());
1831
+ // Ask the PANEL, not the module.
1832
+ //
1833
+ // `window.aegisFingerprint` is desktop/renderer/fingerprint.js, whose exports
1834
+ // are `mount` plus pure helpers — it has no `isOpen`. The thing that knows
1835
+ // whether the sheet is up is the handle `mount()` returned, kept in `fpPanel`.
1836
+ // Reaching for `window.aegisFingerprint.isOpen()` threw inside the Escape
1837
+ // listener, and a listener that throws is reported as an uncaught error
1838
+ // rather than propagated to the dispatcher — so `defaultPrevented` stayed
1839
+ // false, Escape stopped interrupting turns, and the visible symptom was a
1840
+ // salvage failure several layers away. A source-shape change must not be able
1841
+ // to do that again: an unusable handle reads as "not open" here, so a broken
1842
+ // φ(α) panel can never disarm the keyboard interrupt (the smoke run fails
1843
+ // loudly instead, on the recorded reason).
1844
+ if (fpPanel && typeof fpPanel.isOpen === 'function') {
1845
+ try {
1846
+ return !!fpPanel.isOpen();
1847
+ } catch (e) {
1848
+ return false;
1849
+ }
1850
+ }
1851
+ return false;
1776
1852
  }
1777
1853
 
1778
1854
  /** The memory inspector alone. Split out of overlayOpen() when the authorship
@@ -4483,11 +4559,20 @@ async function init() {
4483
4559
  // which made its veto assertion unfalsifiable. Exposes state only: no
4484
4560
  // setters, nothing that can drive the UI. Frozen so a stray write in a test
4485
4561
  // cannot fake a passing run.
4562
+ // Bound below, read by the diagnostic just above. `defaultPrevented === false`
4563
+ // on its own cannot say whether Escape was declined by policy or never
4564
+ // handled at all, and that ambiguity is what makes an unwired interrupt look
4565
+ // like a renderer regression.
4566
+ let escapeBind = null;
4486
4567
  window.__aegisSmoke = Object.freeze({
4487
4568
  isScrolledUp: () => transcript.isScrolledUp(),
4488
4569
  metrics: () => transcript.metrics(),
4570
+ escapeDecision: () => (escapeBind && typeof escapeBind.lastDecision === 'function'
4571
+ ? escapeBind.lastDecision()
4572
+ : null),
4573
+ stopOutcome: () => lastStopOutcome,
4489
4574
  });
4490
- bindEscapeInterrupt({
4575
+ escapeBind = bindEscapeInterrupt({
4491
4576
  doc: document,
4492
4577
  // The memory overlay wins: while it is open, Escape closes it rather than
4493
4578
  // reaching past it to cancel a turn the user may not be looking at.
@@ -4499,9 +4584,18 @@ async function init() {
4499
4584
  // both are somehow up.
4500
4585
  isOverlayOpen: overlayOpen,
4501
4586
  onOverlayEscape: () => {
4502
- if (window.aegisFingerprint && window.aegisFingerprint.isOpen()) {
4503
- window.aegisFingerprint.close();
4504
- return;
4587
+ // Same handle, same reason as overlayOpen(): the module has no `close`,
4588
+ // the mounted panel does. Guessed at the module here too, which threw for
4589
+ // the same reason — and an overlay can only be closed by its own sheet's
4590
+ // handle, so there is nothing to fall back to but the memory overlay.
4591
+ if (fpPanel && typeof fpPanel.close === 'function') {
4592
+ try {
4593
+ fpPanel.close();
4594
+ return;
4595
+ } catch (e) {
4596
+ // fall through to the memory overlay rather than rethrowing inside a
4597
+ // keydown listener
4598
+ }
4505
4599
  }
4506
4600
  closeMemoryOverlay();
4507
4601
  },
@@ -218,21 +218,85 @@ function attachScrollLift(deps) {
218
218
  function bindEscapeInterrupt(deps) {
219
219
  const d = deps || {};
220
220
 
221
+ // Why the last Escape did what it did.
222
+ //
223
+ // This exists because the only externally visible signal an ignored Escape
224
+ // leaves behind is `defaultPrevented === false`, and that single bit cannot
225
+ // tell four legitimate refusals (overlay open, no pending turn, no stopTurn,
226
+ // auto-repeat) apart from the one genuinely broken case: a dependency threw
227
+ // inside the listener, and `dispatchEvent` reported the listener error as
228
+ // uncaught instead of propagating it to the caller. Both look identical from
229
+ // outside — the turn keeps running and `defaultPrevented` is false — so a
230
+ // wiring fault is indistinguishable from a policy decision without this.
231
+ let lastDecision = null;
232
+
221
233
  function handle(e) {
234
+ const decision = {
235
+ at: Date.now(),
236
+ key: e && e.key,
237
+ repeat: Boolean(e && e.repeat),
238
+ overlayOpen: null,
239
+ hasPendingTurn: null,
240
+ consumed: false,
241
+ threw: null,
242
+ };
243
+ lastDecision = decision;
244
+
222
245
  if (!e || e.key !== 'Escape') return false;
223
246
  // A held key is one intent, not one per repeat. Without this the auto-repeat
224
247
  // events keep arriving after the interrupted turn has already ended, and
225
248
  // the ones that land once the user has sent the next message stop that turn
226
249
  // instead — the keyboard twin of a stuck cancel button.
227
250
  if (e.repeat) return false;
228
- if (typeof d.isOverlayOpen === 'function' && d.isOverlayOpen()) {
229
- if (typeof d.onOverlayEscape === 'function') d.onOverlayEscape();
230
- return false;
251
+ if (typeof d.isOverlayOpen === 'function') {
252
+ try {
253
+ decision.overlayOpen = Boolean(d.isOverlayOpen());
254
+ } catch (err) {
255
+ // A probe that breaks must not disarm the interrupt: the user's only
256
+ // keyboard route to stopping a runaway turn is this handler, and
257
+ // "unknown" is not "open". The reason is recorded rather than raised,
258
+ // so the smoke run fails on it while the user still gets their Escape.
259
+ decision.threw = 'isOverlayOpen: ' + ((err && err.message) || err);
260
+ decision.overlayOpen = false;
261
+ }
262
+ if (decision.overlayOpen) {
263
+ if (typeof d.onOverlayEscape === 'function') {
264
+ try {
265
+ d.onOverlayEscape();
266
+ } catch (err) {
267
+ decision.threw = 'onOverlayEscape: ' + ((err && err.message) || err);
268
+ }
269
+ }
270
+ return false;
271
+ }
272
+ }
273
+ if (typeof d.hasPendingTurn === 'function') {
274
+ try {
275
+ decision.hasPendingTurn = Boolean(d.hasPendingTurn());
276
+ } catch (err) {
277
+ decision.threw = 'hasPendingTurn: ' + ((err && err.message) || err);
278
+ return false;
279
+ }
280
+ if (!decision.hasPendingTurn) return false;
231
281
  }
232
- if (typeof d.hasPendingTurn === 'function' && !d.hasPendingTurn()) return false;
233
282
  if (typeof d.stopTurn !== 'function') return false;
234
283
  if (typeof e.preventDefault === 'function') e.preventDefault();
235
- d.stopTurn();
284
+ decision.consumed = true;
285
+ // The key is consumed either way — the interrupt has been asked for and the
286
+ // turn must not survive a throw from the stop itself. What the stop
287
+ // ANSWERED is recorded, because a consumed key with a refused stop is the
288
+ // one combination that looks like success from outside while nothing has
289
+ // happened: `defaultPrevented` is true, the bubble never appears, and the
290
+ // turn runs to completion.
291
+ let stopResult = null;
292
+ try {
293
+ const r = d.stopTurn();
294
+ stopResult = r === undefined ? null : Boolean(r);
295
+ } catch (err) {
296
+ stopResult = false;
297
+ decision.threw = 'stopTurn: ' + ((err && err.message) || err);
298
+ }
299
+ decision.stopAccepted = stopResult;
236
300
  return true;
237
301
  }
238
302
 
@@ -242,6 +306,7 @@ function bindEscapeInterrupt(deps) {
242
306
 
243
307
  return {
244
308
  handle: handle,
309
+ lastDecision: () => lastDecision,
245
310
  unbind: () => {
246
311
  if (d.doc && typeof d.doc.removeEventListener === 'function') {
247
312
  d.doc.removeEventListener('keydown', handle);
package/vendor/aegis.js CHANGED
@@ -65,6 +65,50 @@ function envIdleMs(name) {
65
65
  return Number.isFinite(n) && n > 0 ? n : 0;
66
66
  }
67
67
 
68
+ /**
69
+ * DeepSeek's chat template wraps tool calls and turn boundaries in special
70
+ * tokens — fullwidth pipe (U+FF5C) delimiting an identifier that uses ▁
71
+ * (U+2581) as its word separator, e.g. |tool▁calls▁begin|, |tool▁call▁end|,
72
+ * |Assistant|, |begin▁of▁sentence|. These are chat-template internals, never
73
+ * user-facing text; when the provider's streaming tool-call path leaks them
74
+ * into `delta.content` they used to reach the screen verbatim — reported as
75
+ * the pasted "||DSML||" envelope on Windows/macOS. Returns a per-stream
76
+ * stripper closure with a short lookback buffer so a token split across two
77
+ * SSE chunks isn't half-rendered before its closing pipe arrives.
78
+ */
79
+ const DEEPSEEK_ENVELOPE_RE = /|[A-Za-z][A-Za-z0-9]*(?:▁[A-Za-z0-9]+)*|/g;
80
+ function makeDeepSeekEnvelopeStripper() {
81
+ let buf = '';
82
+ const strip = (chunk) => {
83
+ buf += chunk;
84
+ buf = buf.replace(DEEPSEEK_ENVELOPE_RE, '');
85
+ const openIdx = buf.lastIndexOf('|');
86
+ if (openIdx !== -1) {
87
+ const tail = buf.slice(openIdx);
88
+ // Held back only while the tail still looks like an in-progress token
89
+ // (no closing pipe yet); a bare '|' or genuine prose flushes normally.
90
+ if (tail.length <= 60 && /^|[A-Za-z0-9▁]*$/.test(tail)) {
91
+ const out = buf.slice(0, openIdx);
92
+ buf = tail;
93
+ return out;
94
+ }
95
+ }
96
+ const out = buf;
97
+ buf = '';
98
+ return out;
99
+ };
100
+ // Unconditional release of whatever is left buffered — called once the
101
+ // stream is fully drained, when a held-back '|…' is known to never close
102
+ // (dropped connection, or it was real prose all along) rather than an
103
+ // in-progress token.
104
+ strip.flush = () => {
105
+ const out = buf;
106
+ buf = '';
107
+ return out;
108
+ };
109
+ return strip;
110
+ }
111
+
68
112
  /**
69
113
  * UUID v4 that works everywhere: Web Crypto first (browsers, Node ≥ 19),
70
114
  * then Node's CJS crypto module (Node < 19), then a Math.random fallback for
@@ -221,10 +265,26 @@ function createClient(opts = {}) {
221
265
  }
222
266
  throw err;
223
267
  } finally {
268
+ // The timeout is time-to-*headers* only, so it is cleared the moment the
269
+ // response resolves: a long generation must never be cut off by it.
224
270
  if (timer) clearTimeout(timer);
225
- if (controller && callerSignal && typeof callerSignal.removeEventListener === 'function') {
226
- callerSignal.removeEventListener('abort', onAbort);
227
- }
271
+ // …but the caller's abort bridge is deliberately NOT removed here.
272
+ //
273
+ // `fetch` resolves at the status line, while the RESPONSE BODY is still
274
+ // bound to `controller.signal` and keeps being read long after this
275
+ // function has returned. Unhooking the caller's signal at that point
276
+ // detached Stop/Escape from the stream it was supposed to stop: the
277
+ // engine's own controller really did abort — `engine.cancel()` answered
278
+ // `{ ok: true }`, so the right controller was found and `abort()` was
279
+ // really called — and nothing happened, because the only thing wired to
280
+ // the live body had already been told to stop listening. The turn then
281
+ // streamed to completion while the UI sat on "stopping…", never settled,
282
+ // and never produced the salvaged bubble, because no error ever reached
283
+ // the caller's catch.
284
+ //
285
+ // `once: true` plus one signal per turn bounds the cost of leaving it
286
+ // attached to a single listener per turn — a far smaller price than a
287
+ // cancel button that does nothing.
228
288
  }
229
289
  }
230
290
 
@@ -838,6 +898,15 @@ function createClient(opts = {}) {
838
898
  // resolve with text only and the caller could never see the calls.
839
899
  const toolCallsByIndex = new Map();
840
900
  let finishReason = null;
901
+ // `fullText` above stays RAW (it is also the `seen` cursor the snapshot
902
+ // reconciliation below compares against the provider's own un-stripped
903
+ // snapshots); `cleanText` is what the caller actually receives — the same
904
+ // stream, with DeepSeek's chat-template envelope removed — and is what the
905
+ // final `message.content` below is built from, so a non-streaming caller
906
+ // reading the resolved result sees the same clean text a streaming caller
907
+ // already saw via `onStream`.
908
+ let cleanText = '';
909
+ const stripEnvelope = makeDeepSeekEnvelopeStripper();
841
910
 
842
911
  // Idle watchdog: if the server holds the connection open without ever
843
912
  // sending another byte (a stuck upstream call, a proxy that swallows the
@@ -863,6 +932,7 @@ function createClient(opts = {}) {
863
932
  let keepAlives = 0;
864
933
  async function readWithIdleTimeout() {
865
934
  let timer;
935
+ let onAbort = null;
866
936
  const remaining = Math.max(0, idleMs - (Date.now() - lastPayloadAt));
867
937
  const timeout = new Promise((_, reject) => {
868
938
  timer = setTimeout(() => {
@@ -873,10 +943,37 @@ function createClient(opts = {}) {
873
943
  ));
874
944
  }, remaining);
875
945
  });
946
+ // The caller's abort, raced against the read directly rather than trusted
947
+ // to propagate through the body stream.
948
+ //
949
+ // Whether an abort of the caller's signal reaches a response body that is
950
+ // already being read is a property of `boundedFetch`, not of this loop —
951
+ // and it is not a property this loop can observe. Racing here makes "the
952
+ // caller stopped" a fact the read loop establishes for itself, so Stop
953
+ // and Escape end the turn at the next chunk boundary regardless of how
954
+ // the request's signal was assembled. Without it an aborted turn kept
955
+ // consuming a stream nobody wanted and `chat()` never settled at all.
956
+ const aborted = signal && typeof signal.addEventListener === 'function'
957
+ ? new Promise((_, reject) => {
958
+ onAbort = () => {
959
+ const e = new Error('The turn was stopped.');
960
+ e.name = 'AbortError';
961
+ e.code = 'ABORT_ERR';
962
+ reject(e);
963
+ };
964
+ if (signal.aborted) onAbort();
965
+ else signal.addEventListener('abort', onAbort, { once: true });
966
+ })
967
+ : null;
876
968
  try {
877
- return await Promise.race([reader.read(), timeout]);
969
+ return await (aborted
970
+ ? Promise.race([reader.read(), timeout, aborted])
971
+ : Promise.race([reader.read(), timeout]));
878
972
  } finally {
879
973
  clearTimeout(timer);
974
+ if (signal && onAbort && typeof signal.removeEventListener === 'function') {
975
+ signal.removeEventListener('abort', onAbort);
976
+ }
880
977
  }
881
978
  }
882
979
 
@@ -969,7 +1066,13 @@ function createClient(opts = {}) {
969
1066
  choice && choice.delta && choice.delta.content,
970
1067
  choice && choice.message && choice.message.content,
971
1068
  fullText,
972
- (chunk) => onStream({ delta: chunk })
1069
+ (chunk) => {
1070
+ const clean = stripEnvelope(chunk);
1071
+ if (clean) {
1072
+ cleanText += clean;
1073
+ onStream({ delta: clean });
1074
+ }
1075
+ }
973
1076
  );
974
1077
  const fragments =
975
1078
  (choice && choice.delta && choice.delta.tool_calls) ||
@@ -999,13 +1102,22 @@ function createClient(opts = {}) {
999
1102
  throw err;
1000
1103
  }
1001
1104
 
1105
+ // A trailing '|…' that never closed (stream ended right after it, or it
1106
+ // was real prose all along) is real content, not an unfinished envelope
1107
+ // token — release it rather than dropping it silently.
1108
+ const strayTail = stripEnvelope.flush();
1109
+ if (strayTail) {
1110
+ cleanText += strayTail;
1111
+ onStream({ delta: strayTail });
1112
+ }
1113
+
1002
1114
  // An id-less fragment stream still yields usable calls, but the caller
1003
1115
  // needs *an* id to pair results back, so synthesize one.
1004
1116
  const toolCalls = [...toolCallsByIndex.entries()]
1005
1117
  .sort((a, b) => a[0] - b[0])
1006
1118
  .map(([idx, c]) => ({ ...c, id: c.id || `call_${idx}` }));
1007
1119
 
1008
- const message = { content: fullText };
1120
+ const message = { content: cleanText };
1009
1121
  if (toolCalls.length) message.tool_calls = toolCalls;
1010
1122
  const result = {
1011
1123
  model: resultModel,