@mlx-node/lm 0.0.8 → 0.0.10

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.
@@ -11,12 +11,12 @@
11
11
  * Design notes:
12
12
  *
13
13
  * - The session tracks its own `ChatMessage[]` history on the
14
- * TypeScript side. In the common text-continue case the history
15
- * is only appended to and never read back each `send()` on
16
- * turn >= 1 issues a cheap `chatSessionContinue` delta against
17
- * the live KV cache. The history is kept purely so the
18
- * image-change mid-session path can call `chatSessionStart` with
19
- * the full rebuilt history for a clean re-prefill.
14
+ * TypeScript side and passes the complete structured transcript
15
+ * on every turn. Native code renders that transcript with the
16
+ * checkpoint-provided chat template, verifies the completed history
17
+ * against the committed cache, and appends the template-authored suffix
18
+ * to the exact cached token IDs. Incompatible or edited history cold
19
+ * replays the complete render.
20
20
  *
21
21
  * - An image hash (`lastImagesKey`) tracks the images bound to the
22
22
  * current cache. A `send()` call whose image set has changed
@@ -24,7 +24,9 @@
24
24
  * restart: `resetCaches()` → push the new user message (with
25
25
  * images) to history → `chatSessionStart(history)`.
26
26
  *
27
- * - Text-only `send()` on turn >= 1 takes the cheap delta path.
27
+ * - Text-only `send()` on turn >= 1 still gets incremental prefill
28
+ * on a token-prefix hit. Prompt structure is never reconstructed
29
+ * from Rust string literals.
28
30
  *
29
31
  * - `sendToolResult` always dispatches `chatSessionContinueTool`,
30
32
  * since tool turns never change image state. The session enforces
@@ -48,9 +50,7 @@
48
50
  * `sendToolResult*()` throw. The only valid recovery is
49
51
  * `reset()` or `primeHistory()` + `startFromHistory*()` with
50
52
  * a fully-resolved conversation — there is no "advance past
51
- * the broken turn" path. This mirrors the native ChatML delta
52
- * format which would otherwise silently corrupt multi-call
53
- * conversations.
53
+ * the broken turn" path.
54
54
  *
55
55
  * - `sawFinal` gates `turnCount` advance on the streaming path, so
56
56
  * the session refuses to advance when the stream throws
@@ -85,21 +85,17 @@
85
85
  */
86
86
  import { createHash } from 'node:crypto';
87
87
  /**
88
- * Typed prefix the native delta path uses to reject a text-only
89
- * continuation while the session still holds image/audio KV state
90
- * (gemma4 raises this after a media turn). The native session refuses
91
- * to advance the cheap delta on top of media KV, so the session layer
92
- * recognizes this exact prefix and transparently replays the whole
93
- * conversation through the cold start path instead of surfacing the
94
- * raw error to the caller.
88
+ * Typed prefix native media guards use when a history cannot be continued
89
+ * from the held image/audio state. The session layer recognizes this exact
90
+ * prefix and transparently replays the complete structured conversation.
95
91
  *
96
92
  * MUST stay byte-for-byte identical to the Rust constant
97
93
  * `IMAGE_CHANGE_RESTART_PREFIX` in
98
94
  * `crates/mlx-core/src/engine/cache.rs` — it is not exported across the
99
95
  * NAPI boundary, so the two literals are kept in sync by hand. The
100
96
  * native message starts with this prefix and is delivered as the
101
- * `Error.message`: on the sync delta path as a rejected promise, and on
102
- * the streaming delta path as a thrown error on the generator's first
97
+ * `Error.message`: on the sync path as a rejected promise, and on
98
+ * the streaming path as a thrown error on the generator's first
103
99
  * iteration (the native worker-thread sink error is re-thrown by the
104
100
  * `packages/lm/src/stream.ts` bridge before any chunk is yielded).
105
101
  */
@@ -192,23 +188,40 @@ function toAssistantToolCalls(toolCalls) {
192
188
  }
193
189
  /**
194
190
  * Build an assistant `ChatMessage` from a just-completed turn's
195
- * decoded text + tool-call list. The assistant entry is appended to
196
- * `this.history` after every successful turn and is later read back
197
- * by the native `chatSessionStart` cold-replay path (image-change
198
- * mid-session restart, `startFromHistory*`, server-side
199
- * `SessionRegistry` cache-miss rebuild). Dropping the `toolCalls`
200
- * field here would orphan any subsequent `{role: 'tool', ...}`
201
- * entries on replay — the jinja template would render a
202
- * `<tool_response>` for a call that was never declared on the
203
- * preceding assistant turn, corrupting the conversation structure
204
- * and changing model behavior after a restart.
191
+ * decoded text, exact raw text, tool-call list, reasoning body, and resolved
192
+ * thinking mode. LFM2 replays the exact raw content because its checkpoint
193
+ * template does not consume structured reasoning; Qwen/Gemma retain their
194
+ * structured fields. The assistant entry is appended to `this.history` after every
195
+ * successful turn and is later read back by the native
196
+ * `chatSessionStart` cold-replay path (image-change mid-session
197
+ * restart, `startFromHistory*`, server-side `SessionRegistry`
198
+ * cache-miss rebuild). Dropping `toolCalls`, `reasoningContent`, or
199
+ * `thinkingEnabled` changes the rendered assistant bytes on replay:
200
+ * tool responses lose their declaring call, reasoning disappears, or
201
+ * an empty disabled-thinking channel is reinterpreted under the
202
+ * current turn's mode.
205
203
  */
206
- function buildAssistantMessage(text, toolCalls) {
204
+ function buildAssistantMessage(text, toolCalls, thinking, thinkingEnabled, rawText, replayRawText) {
205
+ if (replayRawText) {
206
+ return {
207
+ role: 'assistant',
208
+ content: rawText ?? text,
209
+ thinkingEnabled,
210
+ };
211
+ }
207
212
  const calls = toAssistantToolCalls(toolCalls);
213
+ const message = {
214
+ role: 'assistant',
215
+ content: text,
216
+ thinkingEnabled,
217
+ };
208
218
  if (calls) {
209
- return { role: 'assistant', content: text, toolCalls: calls };
219
+ message.toolCalls = calls;
220
+ }
221
+ if (thinking != null) {
222
+ message.reasoningContent = thinking;
210
223
  }
211
- return { role: 'assistant', content: text };
224
+ return message;
212
225
  }
213
226
  /**
214
227
  * Count the `ok`-status tool calls in a `ChatResult.toolCalls` /
@@ -230,6 +243,63 @@ function countOkToolCalls(toolCalls) {
230
243
  }
231
244
  return n;
232
245
  }
246
+ /**
247
+ * Select the assistant text committed after a successful stream.
248
+ *
249
+ * Default emitters expose parsed text on the terminal event, so an empty
250
+ * value is authoritative for a tool-only turn and must not fall back to raw
251
+ * streamed tool markup. Gemma's channel-aware emitter instead sends visible
252
+ * text as deltas and an empty terminal value for ordinary no-tool replies, so
253
+ * retain the accumulated visible text in that distinct shape.
254
+ */
255
+ function selectCommittedStreamText(finalText, accumulatedVisible, terminalTextAuthoritative) {
256
+ if (finalText == null)
257
+ return accumulatedVisible;
258
+ if (terminalTextAuthoritative === true)
259
+ return finalText;
260
+ if (terminalTextAuthoritative === false)
261
+ return accumulatedVisible;
262
+ if (finalText !== '')
263
+ return finalText;
264
+ return accumulatedVisible;
265
+ }
266
+ /** Mirror the native default used by `resolve_include_reasoning`. */
267
+ function includesReasoning(config) {
268
+ return config.includeReasoning ?? config.reasoningEffort !== 'none';
269
+ }
270
+ /**
271
+ * Session history must retain reasoning even when the caller hides it. Ask
272
+ * native finalization for the full parsed turn without mutating the committed
273
+ * request config; the public view is redacted again below.
274
+ */
275
+ function withReplayReasoning(config, model) {
276
+ if (includesReasoning(config))
277
+ return config;
278
+ if (model.supportsReplayReasoningCapture?.() !== true)
279
+ return config;
280
+ // A zero-budget "none" turn cannot produce a reasoning body worth
281
+ // replaying. Preserve the caller's native suppression flag for this common
282
+ // short-generation path (for example title generation).
283
+ if (config.reasoningEffort === 'none' && (config.thinkingTokenBudget ?? 0) <= 0) {
284
+ return config;
285
+ }
286
+ return { ...config, includeReasoning: true };
287
+ }
288
+ function publicChatResult(result, config) {
289
+ if (includesReasoning(config))
290
+ return result;
291
+ const safeRaw = result.publicRawText;
292
+ return { ...result, thinking: undefined, rawText: safeRaw ?? result.text };
293
+ }
294
+ function publicStreamEvent(event, config) {
295
+ if (includesReasoning(config))
296
+ return event;
297
+ if (!event.done) {
298
+ return event.isReasoning === true ? null : event;
299
+ }
300
+ const safeRaw = event.publicRawText;
301
+ return { ...event, thinking: null, rawText: safeRaw ?? event.text };
302
+ }
233
303
  /**
234
304
  * Compute a stable hex-encoded identity key for a list of image
235
305
  * byte buffers.
@@ -306,11 +376,12 @@ export class ChatSession {
306
376
  model;
307
377
  system;
308
378
  defaultConfig;
379
+ /** Tool definitions are conversation state for deterministic template replay. */
380
+ activeTools;
309
381
  /**
310
382
  * Full conversation history tracked on the TS side. Appended to on
311
- * every successful turn. Only read back when the image-change path
312
- * triggers a restart normal text continues use the server-side
313
- * cache, not this array.
383
+ * every successful turn and sent on every role-aware native turn so
384
+ * the model-provided template remains the sole prompt authority.
314
385
  */
315
386
  history = [];
316
387
  /**
@@ -329,7 +400,7 @@ export class ChatSession {
329
400
  lastAudioKey = null;
330
401
  turnCount = 0;
331
402
  inFlight = false;
332
- /** A failed/abandoned native delta must be followed by a full replay. */
403
+ /** A failed/abandoned native turn must be followed by a full replay. */
333
404
  needsFullReplay = false;
334
405
  /**
335
406
  * Count of `ok` tool calls emitted by the prior assistant turn, or
@@ -356,6 +427,7 @@ export class ChatSession {
356
427
  this.model = model;
357
428
  this.system = options.system;
358
429
  this.defaultConfig = options.defaultConfig ?? {};
430
+ this.activeTools = this.defaultConfig.tools;
359
431
  }
360
432
  /**
361
433
  * Number of completed turns. Increments only after a successful
@@ -440,9 +512,10 @@ export class ChatSession {
440
512
  /**
441
513
  * Send a user message and resolve with the assistant reply.
442
514
  *
443
- * Turn 0 and any turn whose image set has changed dispatch through
444
- * `chatSessionStart` with the full history. All other turns use
445
- * the cheap `chatSessionContinue` delta path.
515
+ * Turn 0 and any turn whose image set changed dispatch through
516
+ * `chatSessionStart`. Later turns pass the same complete structured
517
+ * history through `chatSessionContinue`; native code renders the
518
+ * model template and reuses KV on an exact token-prefix match.
446
519
  */
447
520
  async send(userMessage, opts = {}) {
448
521
  if (this.inFlight) {
@@ -454,11 +527,11 @@ export class ChatSession {
454
527
  const mergedConfig = this.mergeConfig(opts.config);
455
528
  const newImagesKey = computeImagesKey(opts.images);
456
529
  const newAudioKey = computeAudioKey(opts.audio);
457
- // Only an explicit NEW image/audio set can trigger a restart. Omitting
530
+ // Only an explicit NEW image/audio set can trigger a forced restart. Omitting
458
531
  // `images`/`audio` (key === null) is interpreted as "keep the current
459
532
  // media cache state" — the server-side cache already holds any prior
460
533
  // media context, so a text-only follow-up like "what about the
461
- // top-right?" can stay on the cheap delta path even after a media turn.
534
+ // top-right?" can ask native code to verify/reuse the templated history.
462
535
  const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
463
536
  const audioChanged = newAudioKey !== null && newAudioKey !== this.lastAudioKey;
464
537
  const isFirstTurn = this.turnCount === 0;
@@ -466,14 +539,15 @@ export class ChatSession {
466
539
  if (isFirstTurn || imageChanged || audioChanged || replayRequired) {
467
540
  return await this.runStartPath(userMessage, opts.images, opts.audio, imageChanged || audioChanged || replayRequired, isFirstTurn, mergedConfig);
468
541
  }
469
- // Delta continue: text-only, images/audio always null. The server
470
- // cache already holds all prior turns (including any media from an
471
- // earlier restart), so we only need to ship the new user string.
542
+ // Role-aware continuation: pass the complete structured transcript.
543
+ // Native code renders it with the checkpoint template and only reuses
544
+ // the live cache when the resulting tokens exactly extend that cache.
472
545
  const pendingUser = { role: 'user', content: userMessage };
473
- const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(pendingUser), mergedConfig);
546
+ const pendingHistory = this.historyWithPending(pendingUser);
547
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
474
548
  let result;
475
549
  try {
476
- result = await this.model.chatSessionContinue(userMessage, null, null, constrainedConfig);
550
+ result = await this.model.chatSessionContinue(pendingHistory, withReplayReasoning(constrainedConfig, this.model));
477
551
  }
478
552
  catch (err) {
479
553
  if (!isMediaHeldRestartError(err)) {
@@ -481,19 +555,20 @@ export class ChatSession {
481
555
  throw err;
482
556
  }
483
557
  // The native session holds media KV (gemma4 after an image/audio
484
- // turn) and refused the text delta. Transparently replay the full
558
+ // turn) and refused the continuation. Transparently replay the full
485
559
  // conversation through the cold start path. The earlier media turn
486
560
  // already lives in `this.history`, so the start path re-renders it;
487
561
  // the trailing-media keys keep `lastImagesKey`/`lastAudioKey`
488
- // consistent across the replay. The delta path has NOT pushed
562
+ // consistent across the replay. The continuation path has NOT pushed
489
563
  // `userMessage` yet, so `runStartPath` pushing it adds no duplicate.
490
564
  return await this.runStartPath(userMessage, undefined, undefined, true, false, constrainedConfig);
491
565
  }
492
566
  this.history.push(pendingUser);
493
- this.history.push(buildAssistantMessage(result.text, result.toolCalls));
567
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
494
568
  this.turnCount++;
569
+ this.commitActiveTools(constrainedConfig);
495
570
  this.recordToolCallFanout(result.toolCalls);
496
- return result;
571
+ return publicChatResult(result, constrainedConfig);
497
572
  }
498
573
  finally {
499
574
  this.inFlight = false;
@@ -535,12 +610,17 @@ export class ChatSession {
535
610
  }
536
611
  // Delta continue stream: text-only.
537
612
  const pendingUser = { role: 'user', content: userMessage };
538
- const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(pendingUser), mergedConfig);
613
+ const pendingHistory = this.historyWithPending(pendingUser);
614
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
539
615
  let sawFinal = false;
540
616
  let accumulated = '';
541
617
  let accumulatedVisible = '';
542
618
  let finalRaw = null;
619
+ let finalReplayRaw = null;
620
+ let finalTextAuthoritative;
543
621
  let finalToolCalls;
622
+ let finalThinking = null;
623
+ let finalThinkingEnabled = false;
544
624
  // Set when the media-held rejection re-routes this turn through the
545
625
  // cold start stream. The replay path owns the history push, turnCount
546
626
  // increment, and media-key rehydration, so the commit `finally` below
@@ -548,12 +628,16 @@ export class ChatSession {
548
628
  let delegated = false;
549
629
  try {
550
630
  try {
551
- for await (const event of this.model.chatStreamSessionContinue(userMessage, null, null, constrainedConfig, opts.signal)) {
631
+ for await (const event of this.model.chatStreamSessionContinue(pendingHistory, withReplayReasoning(constrainedConfig, this.model), opts.signal)) {
552
632
  if (event.done) {
553
633
  if (event.finishReason !== 'error') {
554
634
  sawFinal = true;
555
635
  finalRaw = event.text;
636
+ finalReplayRaw = event.rawText;
637
+ finalTextAuthoritative = event.textAuthoritative;
556
638
  finalToolCalls = event.toolCalls;
639
+ finalThinking = event.thinking;
640
+ finalThinkingEnabled = event.thinkingEnabled;
557
641
  }
558
642
  }
559
643
  else {
@@ -562,7 +646,9 @@ export class ChatSession {
562
646
  accumulatedVisible += event.text;
563
647
  }
564
648
  }
565
- yield event;
649
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
650
+ if (publicEvent !== null)
651
+ yield publicEvent;
566
652
  }
567
653
  }
568
654
  catch (err) {
@@ -594,8 +680,9 @@ export class ChatSession {
594
680
  // committed (or rolled back) — so this commit must stay off.
595
681
  if (sawFinal && !delegated) {
596
682
  this.history.push(pendingUser);
597
- this.history.push(buildAssistantMessage(finalRaw || accumulatedVisible, finalToolCalls));
683
+ this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
598
684
  this.turnCount++;
685
+ this.commitActiveTools(constrainedConfig);
599
686
  this.recordToolCallFanout(finalToolCalls);
600
687
  }
601
688
  else if (!delegated) {
@@ -611,9 +698,9 @@ export class ChatSession {
611
698
  }
612
699
  }
613
700
  /**
614
- * Send a tool-result turn. Always dispatches
615
- * `chatSessionContinueTool` tool turns never change image state,
616
- * so there is no restart path here.
701
+ * Send a tool-result turn. The declaring assistant tool call and the
702
+ * pending result are both included in the full history passed to
703
+ * `chatSessionContinueTool`.
617
704
  *
618
705
  * Rejects if the prior assistant turn emitted more than one `ok`
619
706
  * tool call: the chat-session API only supports exactly one tool
@@ -647,8 +734,14 @@ export class ChatSession {
647
734
  try {
648
735
  const { isError, config } = opts;
649
736
  const mergedConfig = this.mergeConfig(config);
650
- const toolMsg = { role: 'tool', content, toolCallId, isError };
651
- const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(toolMsg), mergedConfig);
737
+ const toolMsg = {
738
+ role: 'tool',
739
+ content,
740
+ toolCallId,
741
+ isError,
742
+ };
743
+ const pendingHistory = this.historyWithPending(toolMsg);
744
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
652
745
  // A cold native session (turnCount===0) has no live KV to delta
653
746
  // against — the typical cause is an interrupted media-held replay
654
747
  // whose rollback wiped the cache and reset the counter while
@@ -663,12 +756,13 @@ export class ChatSession {
663
756
  return await this.replayToolResultThroughStartPath(toolMsg, constrainedConfig);
664
757
  }
665
758
  try {
666
- const result = await this.model.chatSessionContinueTool(toolCallId, content, constrainedConfig, isError ?? null);
759
+ const result = await this.model.chatSessionContinueTool(pendingHistory, withReplayReasoning(constrainedConfig, this.model));
667
760
  this.history.push({ role: 'tool', content, toolCallId, isError });
668
- this.history.push(buildAssistantMessage(result.text, result.toolCalls));
761
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
669
762
  this.turnCount++;
763
+ this.commitActiveTools(constrainedConfig);
670
764
  this.recordToolCallFanout(result.toolCalls);
671
- return result;
765
+ return publicChatResult(result, constrainedConfig);
672
766
  }
673
767
  catch (err) {
674
768
  if (!isMediaHeldRestartError(err)) {
@@ -725,8 +819,14 @@ export class ChatSession {
725
819
  try {
726
820
  const { isError, config, signal } = opts;
727
821
  const mergedConfig = this.mergeConfig(config);
728
- const toolMsg = { role: 'tool', content, toolCallId, isError };
729
- const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(toolMsg), mergedConfig);
822
+ const toolMsg = {
823
+ role: 'tool',
824
+ content,
825
+ toolCallId,
826
+ isError,
827
+ };
828
+ const pendingHistory = this.historyWithPending(toolMsg);
829
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
730
830
  // A cold native session (turnCount===0) has no live KV to delta
731
831
  // against — typically the residue of an interrupted media-held
732
832
  // replay whose rollback wiped the cache and reset the counter
@@ -745,7 +845,11 @@ export class ChatSession {
745
845
  let accumulated = '';
746
846
  let accumulatedVisible = '';
747
847
  let finalRaw = null;
848
+ let finalReplayRaw = null;
849
+ let finalTextAuthoritative;
748
850
  let finalToolCalls;
851
+ let finalThinking = null;
852
+ let finalThinkingEnabled = false;
749
853
  // Set when the media-held rejection re-routes this tool turn
750
854
  // through the cold start stream. The replay path owns the history
751
855
  // push, turnCount increment, and media-key rehydration, so the
@@ -753,12 +857,16 @@ export class ChatSession {
753
857
  let delegated = false;
754
858
  try {
755
859
  try {
756
- for await (const event of this.model.chatStreamSessionContinueTool(toolCallId, content, constrainedConfig, signal, isError ?? null)) {
860
+ for await (const event of this.model.chatStreamSessionContinueTool(pendingHistory, withReplayReasoning(constrainedConfig, this.model), signal)) {
757
861
  if (event.done) {
758
862
  if (event.finishReason !== 'error') {
759
863
  sawFinal = true;
760
864
  finalRaw = event.text;
865
+ finalReplayRaw = event.rawText;
866
+ finalTextAuthoritative = event.textAuthoritative;
761
867
  finalToolCalls = event.toolCalls;
868
+ finalThinking = event.thinking;
869
+ finalThinkingEnabled = event.thinkingEnabled;
762
870
  }
763
871
  }
764
872
  else {
@@ -767,7 +875,9 @@ export class ChatSession {
767
875
  accumulatedVisible += event.text;
768
876
  }
769
877
  }
770
- yield event;
878
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
879
+ if (publicEvent !== null)
880
+ yield publicEvent;
771
881
  }
772
882
  }
773
883
  catch (err) {
@@ -799,8 +909,9 @@ export class ChatSession {
799
909
  // off.
800
910
  if (sawFinal && !delegated) {
801
911
  this.history.push({ role: 'tool', content, toolCallId, isError });
802
- this.history.push(buildAssistantMessage(finalRaw || accumulatedVisible, finalToolCalls));
912
+ this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
803
913
  this.turnCount++;
914
+ this.commitActiveTools(constrainedConfig);
804
915
  this.recordToolCallFanout(finalToolCalls);
805
916
  }
806
917
  else if (!delegated) {
@@ -862,6 +973,7 @@ export class ChatSession {
862
973
  this.turnCount = 0;
863
974
  this.unresolvedOkToolCallCount = null;
864
975
  this.needsFullReplay = false;
976
+ this.activeTools = this.defaultConfig.tools;
865
977
  }
866
978
  /**
867
979
  * Prime the session history without running inference.
@@ -929,14 +1041,15 @@ export class ChatSession {
929
1041
  const mergedConfig = this.mergeConfig(config);
930
1042
  const historySnapshot = this.history.slice();
931
1043
  const constrainedConfig = await this.constrainToContextCapacity(historySnapshot, mergedConfig);
932
- const result = await this.model.chatSessionStart(historySnapshot, constrainedConfig);
933
- this.history.push(buildAssistantMessage(result.text, result.toolCalls));
1044
+ const result = await this.model.chatSessionStart(historySnapshot, withReplayReasoning(constrainedConfig, this.model));
1045
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
934
1046
  this.turnCount++;
935
1047
  this.needsFullReplay = false;
936
1048
  this.lastImagesKey = this.computeTrailingImagesKey();
937
1049
  this.lastAudioKey = this.computeTrailingAudioKey();
1050
+ this.commitActiveTools(constrainedConfig);
938
1051
  this.recordToolCallFanout(result.toolCalls);
939
- return result;
1052
+ return publicChatResult(result, constrainedConfig);
940
1053
  }
941
1054
  finally {
942
1055
  this.inFlight = false;
@@ -971,14 +1084,22 @@ export class ChatSession {
971
1084
  let accumulated = '';
972
1085
  let accumulatedVisible = '';
973
1086
  let finalRaw = null;
1087
+ let finalReplayRaw = null;
1088
+ let finalTextAuthoritative;
974
1089
  let finalToolCalls;
1090
+ let finalThinking = null;
1091
+ let finalThinkingEnabled = false;
975
1092
  try {
976
- for await (const event of this.model.chatStreamSessionStart(historySnapshot, constrainedConfig, signal)) {
1093
+ for await (const event of this.model.chatStreamSessionStart(historySnapshot, withReplayReasoning(constrainedConfig, this.model), signal)) {
977
1094
  if (event.done) {
978
1095
  if (event.finishReason !== 'error') {
979
1096
  sawFinal = true;
980
1097
  finalRaw = event.text;
1098
+ finalReplayRaw = event.rawText;
1099
+ finalTextAuthoritative = event.textAuthoritative;
981
1100
  finalToolCalls = event.toolCalls;
1101
+ finalThinking = event.thinking;
1102
+ finalThinkingEnabled = event.thinkingEnabled;
982
1103
  }
983
1104
  }
984
1105
  else {
@@ -987,7 +1108,9 @@ export class ChatSession {
987
1108
  accumulatedVisible += event.text;
988
1109
  }
989
1110
  }
990
- yield event;
1111
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
1112
+ if (publicEvent !== null)
1113
+ yield publicEvent;
991
1114
  }
992
1115
  }
993
1116
  finally {
@@ -997,11 +1120,12 @@ export class ChatSession {
997
1120
  // mutated on a successful commit — on any non-success exit,
998
1121
  // the primed state is left intact so the caller can retry.
999
1122
  if (sawFinal) {
1000
- this.history.push(buildAssistantMessage(finalRaw || accumulatedVisible, finalToolCalls));
1123
+ this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
1001
1124
  this.turnCount++;
1002
1125
  this.needsFullReplay = false;
1003
1126
  this.lastImagesKey = this.computeTrailingImagesKey();
1004
1127
  this.lastAudioKey = this.computeTrailingAudioKey();
1128
+ this.commitActiveTools(constrainedConfig);
1005
1129
  this.recordToolCallFanout(finalToolCalls);
1006
1130
  }
1007
1131
  }
@@ -1102,12 +1226,25 @@ export class ChatSession {
1102
1226
  const n = countOkToolCalls(toolCalls);
1103
1227
  this.unresolvedOkToolCallCount = n > 0 ? n : null;
1104
1228
  }
1229
+ /**
1230
+ * Persist the effective tools only when their turn commits successfully.
1231
+ * Preflights and failed/abandoned turns intentionally never call this.
1232
+ */
1233
+ commitActiveTools(config) {
1234
+ if (config.tools !== undefined) {
1235
+ this.activeTools = config.tools;
1236
+ }
1237
+ }
1105
1238
  /**
1106
1239
  * Merge default + per-call config and force `reuseCache: true`.
1107
1240
  * The session path is a session-reuse operation by construction —
1108
1241
  * `reuseCache: false` on the continue path would wipe the very
1109
1242
  * cache the delta depends on.
1110
1243
  *
1244
+ * Tool resolution here is side-effect free because public capacity
1245
+ * preflights use this same merge path. Successful turn commit sites call
1246
+ * {@link commitActiveTools} after native inference finishes.
1247
+ *
1111
1248
  * MTP auto-default: if neither `defaultConfig` nor `overlay`
1112
1249
  * sets `enableMtp` AND the underlying model exposes
1113
1250
  * `hasMtpWeights()` returning `true`, set `enableMtp = true` so the
@@ -1124,6 +1261,14 @@ export class ChatSession {
1124
1261
  ...overlay,
1125
1262
  reuseCache: true,
1126
1263
  };
1264
+ // Tools are part of the committed conversation state. Constructor
1265
+ // defaults seed that state, but must not overwrite a tool set committed by
1266
+ // a later successful turn. A current-call overlay is the only higher
1267
+ // precedence source; it remains provisional until a success site calls
1268
+ // commitActiveTools().
1269
+ if (overlay?.tools === undefined && this.activeTools !== undefined) {
1270
+ merged.tools = this.activeTools;
1271
+ }
1127
1272
  if (merged.enableMtp === undefined &&
1128
1273
  typeof this.model.hasMtpWeights === 'function' &&
1129
1274
  this.model.hasMtpWeights()) {
@@ -1223,8 +1368,8 @@ export class ChatSession {
1223
1368
  // (e.g. the assistant reply below) don't retroactively mutate
1224
1369
  // what the native side / any mock observed as its `messages`
1225
1370
  // argument.
1226
- const result = await this.model.chatSessionStart(this.history.slice(), constrainedConfig);
1227
- this.history.push(buildAssistantMessage(result.text, result.toolCalls));
1371
+ const result = await this.model.chatSessionStart(this.history.slice(), withReplayReasoning(constrainedConfig, this.model));
1372
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
1228
1373
  this.turnCount++;
1229
1374
  this.needsFullReplay = false;
1230
1375
  // The start path always re-renders the FULL preserved history, so the
@@ -1236,8 +1381,9 @@ export class ChatSession {
1236
1381
  // turn to be mis-detected as a change and replayed twice.
1237
1382
  this.lastImagesKey = this.computeTrailingImagesKey();
1238
1383
  this.lastAudioKey = this.computeTrailingAudioKey();
1384
+ this.commitActiveTools(constrainedConfig);
1239
1385
  this.recordToolCallFanout(result.toolCalls);
1240
- return result;
1386
+ return publicChatResult(result, constrainedConfig);
1241
1387
  }
1242
1388
  catch (err) {
1243
1389
  // Roll back: drop the tentative user push so history stays
@@ -1282,17 +1428,25 @@ export class ChatSession {
1282
1428
  let accumulated = '';
1283
1429
  let accumulatedVisible = '';
1284
1430
  let finalRaw = null;
1431
+ let finalReplayRaw = null;
1432
+ let finalTextAuthoritative;
1285
1433
  let finalToolCalls;
1434
+ let finalThinking = null;
1435
+ let finalThinkingEnabled = false;
1286
1436
  // Snapshot the history before dispatch — see `runStartPath` for
1287
1437
  // the rationale.
1288
1438
  const historySnapshot = this.history.slice();
1289
1439
  try {
1290
- for await (const event of this.model.chatStreamSessionStart(historySnapshot, constrainedConfig, signal)) {
1440
+ for await (const event of this.model.chatStreamSessionStart(historySnapshot, withReplayReasoning(constrainedConfig, this.model), signal)) {
1291
1441
  if (event.done) {
1292
1442
  if (event.finishReason !== 'error') {
1293
1443
  sawFinal = true;
1294
1444
  finalRaw = event.text;
1445
+ finalReplayRaw = event.rawText;
1446
+ finalTextAuthoritative = event.textAuthoritative;
1295
1447
  finalToolCalls = event.toolCalls;
1448
+ finalThinking = event.thinking;
1449
+ finalThinkingEnabled = event.thinkingEnabled;
1296
1450
  }
1297
1451
  }
1298
1452
  else {
@@ -1301,7 +1455,9 @@ export class ChatSession {
1301
1455
  accumulatedVisible += event.text;
1302
1456
  }
1303
1457
  }
1304
- yield event;
1458
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
1459
+ if (publicEvent !== null)
1460
+ yield publicEvent;
1305
1461
  }
1306
1462
  }
1307
1463
  finally {
@@ -1314,7 +1470,7 @@ export class ChatSession {
1314
1470
  // generator was wound down. Mid-stream throws still propagate
1315
1471
  // naturally — finally runs first, then the error continues up.
1316
1472
  if (sawFinal) {
1317
- this.history.push(buildAssistantMessage(finalRaw || accumulatedVisible, finalToolCalls));
1473
+ this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
1318
1474
  this.turnCount++;
1319
1475
  this.needsFullReplay = false;
1320
1476
  // The start path always re-renders the FULL preserved history, so the
@@ -1326,6 +1482,7 @@ export class ChatSession {
1326
1482
  // turn to be mis-detected as a change and replayed twice.
1327
1483
  this.lastImagesKey = this.computeTrailingImagesKey();
1328
1484
  this.lastAudioKey = this.computeTrailingAudioKey();
1485
+ this.commitActiveTools(constrainedConfig);
1329
1486
  this.recordToolCallFanout(finalToolCalls);
1330
1487
  }
1331
1488
  else {