@tanstack/ai 0.39.0 → 0.39.1

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.
@@ -220,6 +220,24 @@ export declare class StreamProcessor {
220
220
  * Handle MESSAGES_SNAPSHOT event
221
221
  */
222
222
  private handleMessagesSnapshotEvent;
223
+ /**
224
+ * Reconcile a freshly normalized snapshot with the pre-snapshot message
225
+ * state so unreconstructable tool-call metadata is preserved.
226
+ *
227
+ * Post-pass (a): anchor `tool-result`-only assistant messages (the shape
228
+ * `aguiSnapshotMessageToUIMessage` emits for AG-UI `role: 'tool'` wire
229
+ * messages) into the message containing the matching `tool-call` part, or —
230
+ * when the snapshot supplies no such part — the nearest earlier anchorable
231
+ * assistant message, matching the in-stream fan-out shape
232
+ * `assistant: [text, tool-call, tool-result, ...]`. Detached messages with
233
+ * no earlier anchorable assistant are kept verbatim.
234
+ *
235
+ * Post-pass (b): when a `tool-result` part references a `toolCallId` whose
236
+ * `tool-call` part is absent from the snapshot, carry the `tool-call` part
237
+ * forward from the pre-snapshot state (state and output untouched) so a
238
+ * subsequent `addToolResult(toolCallId)` can still locate the call.
239
+ */
240
+ private reconcileSnapshotToolCalls;
223
241
  /**
224
242
  * Handle TEXT_MESSAGE_CONTENT event.
225
243
  *
@@ -1,5 +1,6 @@
1
1
  import { generateMessageId, uiMessageToModelMessages, aguiSnapshotMessageToUIMessage } from "../messages.js";
2
2
  import { normalizeToolResult } from "../../../utilities/tool-result.js";
3
+ import { isProviderExecutedToolCall } from "../../../utilities/provider-executed.js";
3
4
  import { defaultJSONParser } from "./json-parser.js";
4
5
  import { updateToolCallWithOutput, updateToolResultPart, updateToolCallApprovalResponse, appendStructuredOutputDelta, updateToolCallPart, errorStructuredOutputPart, updateThinkingPart, completeStructuredOutputPart, updateToolCallApproval, updateTextPart } from "./message-updaters.js";
5
6
  import { ImmediateStrategy } from "./strategies.js";
@@ -197,7 +198,7 @@ class StreamProcessor {
197
198
  lastAssistant.parts.filter((p) => p.type === "tool-result").map((p) => p.toolCallId)
198
199
  );
199
200
  return toolParts.every(
200
- (part) => part.state === "complete" || part.state === "approval-responded" || part.output !== void 0 && !part.approval || toolResultIds.has(part.id)
201
+ (part) => part.state === "complete" || part.state === "approval-responded" || part.output !== void 0 && !part.approval || toolResultIds.has(part.id) || isProviderExecutedToolCall(part)
201
202
  );
202
203
  }
203
204
  /**
@@ -548,9 +549,87 @@ class StreamProcessor {
548
549
  */
549
550
  handleMessagesSnapshotEvent(chunk) {
550
551
  this.resetStreamState();
551
- this.messages = chunk.messages.map(aguiSnapshotMessageToUIMessage);
552
+ const prevMessages = this.messages;
553
+ const normalized = chunk.messages.map(aguiSnapshotMessageToUIMessage);
554
+ this.messages = this.reconcileSnapshotToolCalls(normalized, prevMessages);
552
555
  this.emitMessagesChange();
553
556
  }
557
+ /**
558
+ * Reconcile a freshly normalized snapshot with the pre-snapshot message
559
+ * state so unreconstructable tool-call metadata is preserved.
560
+ *
561
+ * Post-pass (a): anchor `tool-result`-only assistant messages (the shape
562
+ * `aguiSnapshotMessageToUIMessage` emits for AG-UI `role: 'tool'` wire
563
+ * messages) into the message containing the matching `tool-call` part, or —
564
+ * when the snapshot supplies no such part — the nearest earlier anchorable
565
+ * assistant message, matching the in-stream fan-out shape
566
+ * `assistant: [text, tool-call, tool-result, ...]`. Detached messages with
567
+ * no earlier anchorable assistant are kept verbatim.
568
+ *
569
+ * Post-pass (b): when a `tool-result` part references a `toolCallId` whose
570
+ * `tool-call` part is absent from the snapshot, carry the `tool-call` part
571
+ * forward from the pre-snapshot state (state and output untouched) so a
572
+ * subsequent `addToolResult(toolCallId)` can still locate the call.
573
+ */
574
+ reconcileSnapshotToolCalls(snapshot, prevMessages) {
575
+ const prevToolCalls = /* @__PURE__ */ new Map();
576
+ for (const msg of prevMessages) {
577
+ for (const part of msg.parts) {
578
+ if (part.type === "tool-call") {
579
+ prevToolCalls.set(part.id, part);
580
+ }
581
+ }
582
+ }
583
+ const snapshotToolCallIds = /* @__PURE__ */ new Set();
584
+ for (const msg of snapshot) {
585
+ for (const part of msg.parts) {
586
+ if (part.type === "tool-call") {
587
+ snapshotToolCallIds.add(part.id);
588
+ }
589
+ }
590
+ }
591
+ const reconciled = [];
592
+ for (const msg of snapshot) {
593
+ const toolResultPart = msg.role === "assistant" && msg.parts.length === 1 ? msg.parts.find((p) => p.type === "tool-result") : void 0;
594
+ if (!toolResultPart) {
595
+ reconciled.push(msg);
596
+ continue;
597
+ }
598
+ const target = reconciled.findLast(
599
+ (m) => m.parts.some(
600
+ (p) => p.type === "tool-call" && p.id === toolResultPart.toolCallId
601
+ )
602
+ ) ?? reconciled.findLast(
603
+ (m) => m.role === "assistant" && !(m.parts.length === 1 && m.parts[0]?.type === "tool-result")
604
+ );
605
+ if (!target) {
606
+ if (!snapshotToolCallIds.has(toolResultPart.toolCallId)) {
607
+ console.warn(
608
+ `[StreamProcessor] MESSAGES_SNAPSHOT contains a tool-result for "${toolResultPart.toolCallId}" but no matching tool-call exists in the snapshot, and there is no assistant message to anchor into; addToolResult("${toolResultPart.toolCallId}") will not be able to locate this call`
609
+ );
610
+ }
611
+ reconciled.push(msg);
612
+ continue;
613
+ }
614
+ const parts = [...target.parts];
615
+ if (!snapshotToolCallIds.has(toolResultPart.toolCallId) && !parts.some(
616
+ (p) => p.type === "tool-call" && p.id === toolResultPart.toolCallId
617
+ )) {
618
+ const prev = prevToolCalls.get(toolResultPart.toolCallId);
619
+ if (prev) {
620
+ parts.push({ ...prev });
621
+ snapshotToolCallIds.add(prev.id);
622
+ } else {
623
+ console.warn(
624
+ `[StreamProcessor] MESSAGES_SNAPSHOT contains a tool-result for "${toolResultPart.toolCallId}" but no matching tool-call exists in the snapshot or the pre-snapshot state; addToolResult("${toolResultPart.toolCallId}") will not be able to locate this call`
625
+ );
626
+ }
627
+ }
628
+ parts.push(toolResultPart);
629
+ target.parts = parts;
630
+ }
631
+ return reconciled;
632
+ }
554
633
  /**
555
634
  * Handle TEXT_MESSAGE_CONTENT event.
556
635
  *
@@ -1185,8 +1264,11 @@ class StreamProcessor {
1185
1264
  */
1186
1265
  isToolCallPartErrored(toolCallId) {
1187
1266
  return this.messages.some(
1188
- (msg) => msg.parts.some(
1189
- (part) => part.type === "tool-call" && part.id === toolCallId && part.state === "error"
1267
+ (msg) => (
1268
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- `parts` is typed as required, but seeded ModelMessage-shaped messages can lack it at runtime.
1269
+ msg.parts?.some(
1270
+ (part) => part.type === "tool-call" && part.id === toolCallId && part.state === "error"
1271
+ )
1190
1272
  )
1191
1273
  );
1192
1274
  }