@assistant-ui/core 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/model-context/tool.d.ts +25 -0
  2. package/dist/model-context/tool.d.ts.map +1 -1
  3. package/dist/model-context/tool.js +25 -0
  4. package/dist/model-context/tool.js.map +1 -1
  5. package/dist/react/AssistantRuntimeProvider.d.ts +33 -0
  6. package/dist/react/AssistantRuntimeProvider.d.ts.map +1 -1
  7. package/dist/react/AssistantRuntimeProvider.js +22 -0
  8. package/dist/react/AssistantRuntimeProvider.js.map +1 -1
  9. package/dist/react/client/DataRenderers.d.ts +7 -0
  10. package/dist/react/client/DataRenderers.d.ts.map +1 -1
  11. package/dist/react/client/DataRenderers.js +7 -0
  12. package/dist/react/client/DataRenderers.js.map +1 -1
  13. package/dist/react/client/Tools.d.ts +12 -0
  14. package/dist/react/client/Tools.d.ts.map +1 -1
  15. package/dist/react/client/Tools.js +8 -0
  16. package/dist/react/client/Tools.js.map +1 -1
  17. package/dist/react/index.d.ts +1 -0
  18. package/dist/react/index.d.ts.map +1 -1
  19. package/dist/react/index.js +1 -0
  20. package/dist/react/index.js.map +1 -1
  21. package/dist/react/model-context/makeAssistantDataUI.d.ts +13 -0
  22. package/dist/react/model-context/makeAssistantDataUI.d.ts.map +1 -1
  23. package/dist/react/model-context/makeAssistantDataUI.js +6 -0
  24. package/dist/react/model-context/makeAssistantDataUI.js.map +1 -1
  25. package/dist/react/model-context/makeAssistantTool.d.ts +15 -0
  26. package/dist/react/model-context/makeAssistantTool.d.ts.map +1 -1
  27. package/dist/react/model-context/makeAssistantTool.js +8 -0
  28. package/dist/react/model-context/makeAssistantTool.js.map +1 -1
  29. package/dist/react/model-context/makeAssistantToolUI.d.ts +15 -0
  30. package/dist/react/model-context/makeAssistantToolUI.d.ts.map +1 -1
  31. package/dist/react/model-context/makeAssistantToolUI.js +8 -0
  32. package/dist/react/model-context/makeAssistantToolUI.js.map +1 -1
  33. package/dist/react/model-context/toolbox.d.ts +29 -0
  34. package/dist/react/model-context/toolbox.d.ts.map +1 -1
  35. package/dist/react/model-context/useAssistantDataUI.d.ts +9 -0
  36. package/dist/react/model-context/useAssistantDataUI.d.ts.map +1 -1
  37. package/dist/react/model-context/useAssistantDataUI.js +6 -0
  38. package/dist/react/model-context/useAssistantDataUI.js.map +1 -1
  39. package/dist/react/model-context/useAssistantTool.d.ts +34 -0
  40. package/dist/react/model-context/useAssistantTool.d.ts.map +1 -1
  41. package/dist/react/model-context/useAssistantTool.js +30 -0
  42. package/dist/react/model-context/useAssistantTool.js.map +1 -1
  43. package/dist/react/model-context/useAssistantToolUI.d.ts +12 -0
  44. package/dist/react/model-context/useAssistantToolUI.d.ts.map +1 -1
  45. package/dist/react/model-context/useAssistantToolUI.js +9 -0
  46. package/dist/react/model-context/useAssistantToolUI.js.map +1 -1
  47. package/dist/react/model-context/useToolArgsStatus.d.ts +29 -0
  48. package/dist/react/model-context/useToolArgsStatus.d.ts.map +1 -1
  49. package/dist/react/model-context/useToolArgsStatus.js +24 -0
  50. package/dist/react/model-context/useToolArgsStatus.js.map +1 -1
  51. package/dist/react/primitive-hooks/useActionBarCopy.d.ts.map +1 -1
  52. package/dist/react/primitive-hooks/useActionBarCopy.js +4 -3
  53. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  54. package/dist/react/primitives/messagePart/MessagePartInProgress.d.ts +6 -0
  55. package/dist/react/primitives/messagePart/MessagePartInProgress.d.ts.map +1 -0
  56. package/dist/react/primitives/messagePart/MessagePartInProgress.js +7 -0
  57. package/dist/react/primitives/messagePart/MessagePartInProgress.js.map +1 -0
  58. package/dist/react/runtimes/useToolInvocations.d.ts +9 -0
  59. package/dist/react/runtimes/useToolInvocations.d.ts.map +1 -1
  60. package/dist/react/runtimes/useToolInvocations.js +318 -264
  61. package/dist/react/runtimes/useToolInvocations.js.map +1 -1
  62. package/dist/react/types/MessagePartComponentTypes.d.ts +11 -0
  63. package/dist/react/types/MessagePartComponentTypes.d.ts.map +1 -1
  64. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +1 -0
  65. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  66. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +11 -0
  67. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  68. package/dist/store/clients/model-context-client.d.ts.map +1 -1
  69. package/dist/store/clients/model-context-client.js +24 -4
  70. package/dist/store/clients/model-context-client.js.map +1 -1
  71. package/dist/store/scopes/model-context.d.ts +4 -1
  72. package/dist/store/scopes/model-context.d.ts.map +1 -1
  73. package/dist/types/message.d.ts +22 -0
  74. package/dist/types/message.d.ts.map +1 -1
  75. package/package.json +10 -9
  76. package/src/model-context/tool.ts +25 -0
  77. package/src/react/AssistantRuntimeProvider.tsx +33 -0
  78. package/src/react/client/DataRenderers.ts +7 -0
  79. package/src/react/client/Tools.ts +10 -0
  80. package/src/react/index.ts +1 -0
  81. package/src/react/model-context/makeAssistantDataUI.ts +13 -0
  82. package/src/react/model-context/makeAssistantTool.ts +15 -0
  83. package/src/react/model-context/makeAssistantToolUI.ts +15 -0
  84. package/src/react/model-context/toolbox.ts +32 -1
  85. package/src/react/model-context/useAssistantDataUI.ts +9 -0
  86. package/src/react/model-context/useAssistantTool.ts +34 -0
  87. package/src/react/model-context/useAssistantToolUI.ts +12 -0
  88. package/src/react/model-context/useToolArgsStatus.ts +29 -0
  89. package/src/react/primitive-hooks/useActionBarCopy.ts +9 -5
  90. package/src/react/primitives/messagePart/MessagePartInProgress.ts +15 -0
  91. package/src/react/runtimes/useToolInvocations.ts +410 -341
  92. package/src/react/types/MessagePartComponentTypes.ts +11 -0
  93. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +11 -0
  94. package/src/store/clients/model-context-client.test.ts +108 -0
  95. package/src/store/clients/model-context-client.ts +36 -6
  96. package/src/store/scopes/model-context.ts +4 -1
  97. package/src/tests/external-store-thread-runtime-core.test.ts +113 -0
  98. package/src/types/message.ts +22 -0
@@ -69,16 +69,60 @@ type UseToolInvocationsParams = {
69
69
  ) => void;
70
70
  };
71
71
 
72
+ /**
73
+ * Streaming execution state for a frontend tool.
74
+ *
75
+ * Custom runtime integrations use this to mirror in-flight tool calls while
76
+ * `useToolInvocations` executes tools in the browser.
77
+ */
72
78
  export type ToolExecutionStatus =
73
- | { type: "executing" }
74
- | { type: "interrupt"; payload: { type: "human"; payload: unknown } };
79
+ | {
80
+ /** The tool's execute function is currently running. */
81
+ type: "executing";
82
+ }
83
+ | {
84
+ /** The tool is waiting for a human input payload before continuing. */
85
+ type: "interrupt";
86
+ /** Human input request emitted by the tool execution context. */
87
+ payload: { type: "human"; payload: unknown };
88
+ };
75
89
 
76
- type ToolState = {
90
+ /**
91
+ * Per-logical-tool-call state. A single discriminator distinguishes restored
92
+ * snapshots (no controller; only used for signature comparison) from active
93
+ * snapshots that are being streamed through the assistant-stream pipeline.
94
+ */
95
+ type ToolCallEntry = {
96
+ toolName: string;
97
+ /** Last observed `argsText` for this tool call. */
77
98
  argsText: string;
99
+ /** Last observed `result !== undefined` for this tool call. */
78
100
  hasResult: boolean;
79
- argsComplete: boolean;
80
- streamToolCallId: string;
81
- controller: ToolCallStreamController;
101
+ } & (
102
+ | {
103
+ /** Restored phase — observed during a history-load snapshot. */
104
+ controller?: undefined;
105
+ streamId?: undefined;
106
+ argsComplete?: undefined;
107
+ }
108
+ | {
109
+ /** Active phase — chunks are flowing through `controller`. */
110
+ controller: ToolCallStreamController;
111
+ /** Current physical stream id (differs from logical id after a rewrite). */
112
+ streamId: string;
113
+ argsComplete: boolean;
114
+ }
115
+ );
116
+
117
+ /**
118
+ * Per-physical-stream-id execution lifecycle bookkeeping. Tracked separately
119
+ * from `ToolCallEntry` so that `reset()` can clear tool-call state
120
+ * synchronously while in-flight executions still find their cleanup info via
121
+ * `onExecutionEnd` after `abort()` settles.
122
+ */
123
+ type ExecutingStream = {
124
+ logicalToolCallId: string;
125
+ abandoned: boolean;
82
126
  };
83
127
 
84
128
  export function useToolInvocations({
@@ -87,7 +131,37 @@ export function useToolInvocations({
87
131
  onResult,
88
132
  setToolStatuses,
89
133
  }: UseToolInvocationsParams) {
90
- const lastToolStates = useRef<Record<string, ToolState>>({});
134
+ /**
135
+ * Single source of truth for per-tool-call lifecycle. Keyed by *logical*
136
+ * toolCallId (the id the host knows). Restored entries have no controller;
137
+ * active entries carry their stream id and rewrite/execution bookkeeping.
138
+ */
139
+ const entriesRef = useRef<Map<string, ToolCallEntry>>(new Map());
140
+
141
+ /**
142
+ * Reverse alias map populated only when a rewrite assigns a synthetic stream
143
+ * id to an entry. Identity mappings are implicit via the fallback in
144
+ * `getLogicalToolCallId`.
145
+ */
146
+ const streamToLogicalRef = useRef<Map<string, string>>(new Map());
147
+
148
+ /**
149
+ * Stream ids whose `result` chunks must be dropped before reaching `onResult`.
150
+ * Populated when:
151
+ * - an argsText rewrite supersedes a stream (the old stream's result, if
152
+ * any, is no longer authoritative)
153
+ * - `reset()` is called while a pre-resolved tool call has a never-settling
154
+ * Promise pending in the executor — the eventual cancellation chunk
155
+ * would otherwise be forwarded to a host that has already moved on.
156
+ */
157
+ const abandonedStreamIdsRef = useRef<Set<string>>(new Set());
158
+
159
+ /**
160
+ * Stream ids whose `execute` should be short-circuited in the tool wrapper.
161
+ * Tracked by physical stream id (not logical id) so cleanup is keyed off
162
+ * the same id the wrapper sees in its context.
163
+ */
164
+ const skipExecuteStreamIdsRef = useRef<Set<string>>(new Set());
91
165
 
92
166
  const humanInputRef = useRef<
93
167
  Map<
@@ -99,24 +173,28 @@ export function useToolInvocations({
99
173
  >
100
174
  >(new Map());
101
175
 
176
+ /**
177
+ * In-flight `execute` invocations keyed by physical stream id. Lives outside
178
+ * `entriesRef` so `reset()` can drop tool-call state without orphaning the
179
+ * cleanup the cancellation `onExecutionEnd` still needs.
180
+ */
181
+ const executingRef = useRef<Map<string, ExecutingStream>>(new Map());
182
+
102
183
  const acRef = useRef<AbortController>(new AbortController());
103
184
  const executingCountRef = useRef(0);
104
- const startedExecutionToolCallIdsRef = useRef<Set<string>>(new Set());
105
185
  const settledResolversRef = useRef<Array<() => void>>([]);
106
- const toolCallIdAliasesRef = useRef<Map<string, string>>(new Map());
107
- const ignoredResultToolCallIdsRef = useRef<Set<string>>(new Set());
108
186
  const rewriteCounterRef = useRef(0);
109
187
 
110
- const getLogicalToolCallId = (toolCallId: string) => {
111
- return toolCallIdAliasesRef.current.get(toolCallId) ?? toolCallId;
112
- };
188
+ /**
189
+ * `true` until the first snapshot has been processed; `reset()` flips it
190
+ * back to `true`. Snapshots observed while this is `true` are treated as
191
+ * historical: their tool calls are recorded in `entriesRef` as restored
192
+ * but no streamCall/execute fires. The next snapshot is processed as live.
193
+ */
194
+ const pendingRestoreRef = useRef(true);
113
195
 
114
- const shouldIgnoreAndCleanupResult = (toolCallId: string) => {
115
- if (!ignoredResultToolCallIdsRef.current.has(toolCallId)) return false;
116
- ignoredResultToolCallIdsRef.current.delete(toolCallId);
117
- toolCallIdAliasesRef.current.delete(toolCallId);
118
- return true;
119
- };
196
+ const getLogicalToolCallId = (streamId: string) =>
197
+ streamToLogicalRef.current.get(streamId) ?? streamId;
120
198
 
121
199
  const getWrappedTools = () => {
122
200
  const tools = getTools();
@@ -133,11 +211,23 @@ export function useToolInvocations({
133
211
  ...(execute !== undefined && {
134
212
  execute: (
135
213
  ...[args, context]: Parameters<NonNullable<typeof execute>>
136
- ) =>
137
- execute(args, {
214
+ ) => {
215
+ if (skipExecuteStreamIdsRef.current.has(context.toolCallId)) {
216
+ // Pre-resolved on first live observation: never invoke the
217
+ // host's execute fn. Returning a never-settling Promise keeps
218
+ // the executor's pending entry alive but enqueues nothing.
219
+ // The membership in skipExecuteStreamIdsRef must outlive the
220
+ // wrapper call so `reset()`'s seeding loop (which reads this
221
+ // Set to identify pre-resolved entries needing cancellation
222
+ // suppression) sees the entry. Growth is bounded by the
223
+ // number of pre-resolved tool calls observed in the session.
224
+ return new Promise(() => {}) as never;
225
+ }
226
+ return execute(args, {
138
227
  ...context,
139
228
  toolCallId: getLogicalToolCallId(context.toolCallId),
140
- }),
229
+ });
230
+ },
141
231
  }),
142
232
  ...(streamCall !== undefined && {
143
233
  streamCall: (
@@ -163,6 +253,13 @@ export function useToolInvocations({
163
253
  ) as Record<string, Tool>;
164
254
  };
165
255
 
256
+ const resolveAllSettledResolvers = () => {
257
+ const resolvers = settledResolversRef.current;
258
+ settledResolversRef.current = [];
259
+ // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return
260
+ resolvers.forEach((resolve) => resolve());
261
+ };
262
+
166
263
  const [controller] = useState(() => {
167
264
  const [stream, controller] = createAssistantStreamController();
168
265
  const transform = unstable_toolResultStream(
@@ -171,14 +268,12 @@ export function useToolInvocations({
171
268
  (toolCallId: string, payload: unknown) => {
172
269
  const logicalToolCallId = getLogicalToolCallId(toolCallId);
173
270
  return new Promise<unknown>((resolve, reject) => {
174
- // Reject previous human input request if it exists
175
271
  const previous = humanInputRef.current.get(logicalToolCallId);
176
272
  if (previous) {
177
273
  previous.reject(
178
274
  new Error("Human input request was superseded by a new request"),
179
275
  );
180
276
  }
181
-
182
277
  humanInputRef.current.set(logicalToolCallId, { resolve, reject });
183
278
  setToolStatuses((prev) => ({
184
279
  ...prev,
@@ -190,85 +285,91 @@ export function useToolInvocations({
190
285
  });
191
286
  },
192
287
  {
193
- onExecutionStart: (toolCallId: string) => {
194
- if (ignoredResultToolCallIdsRef.current.has(toolCallId)) {
195
- return;
196
- }
197
- startedExecutionToolCallIdsRef.current.add(toolCallId);
198
- const logicalToolCallId = getLogicalToolCallId(toolCallId);
288
+ onExecutionStart: (streamId: string) => {
289
+ if (skipExecuteStreamIdsRef.current.has(streamId)) return;
290
+
291
+ const logicalToolCallId = getLogicalToolCallId(streamId);
292
+ const abandoned = abandonedStreamIdsRef.current.has(streamId);
293
+ executingRef.current.set(streamId, {
294
+ logicalToolCallId,
295
+ abandoned,
296
+ });
199
297
  executingCountRef.current++;
200
- setToolStatuses((prev) => ({
201
- ...prev,
202
- [logicalToolCallId]: { type: "executing" },
203
- }));
204
- },
205
- onExecutionEnd: (toolCallId: string) => {
206
- const wasStarted =
207
- startedExecutionToolCallIdsRef.current.delete(toolCallId);
208
- if (ignoredResultToolCallIdsRef.current.has(toolCallId)) {
209
- if (wasStarted) {
210
- executingCountRef.current--;
211
- if (executingCountRef.current === 0) {
212
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return
213
- settledResolversRef.current.forEach((resolve) => resolve());
214
- settledResolversRef.current = [];
215
- }
216
- }
217
- return;
218
- }
219
- if (!wasStarted) {
220
- return;
298
+ if (!abandoned) {
299
+ setToolStatuses((prev) => ({
300
+ ...prev,
301
+ [logicalToolCallId]: { type: "executing" },
302
+ }));
221
303
  }
222
- const logicalToolCallId = getLogicalToolCallId(toolCallId);
304
+ },
305
+ onExecutionEnd: (streamId: string) => {
306
+ const info = executingRef.current.get(streamId);
307
+ if (!info) return;
308
+ executingRef.current.delete(streamId);
309
+
223
310
  executingCountRef.current--;
224
- setToolStatuses((prev) => {
225
- const next = { ...prev };
226
- delete next[logicalToolCallId];
227
- return next;
228
- });
229
- // Resolve any waiting abort promises when all tools have settled
311
+ if (!info.abandoned) {
312
+ setToolStatuses((prev) => {
313
+ const next = { ...prev };
314
+ delete next[info.logicalToolCallId];
315
+ return next;
316
+ });
317
+ }
230
318
  if (executingCountRef.current === 0) {
231
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return
232
- settledResolversRef.current.forEach((resolve) => resolve());
233
- settledResolversRef.current = [];
319
+ resolveAllSettledResolvers();
234
320
  }
235
321
  },
236
322
  },
237
323
  );
324
+
238
325
  stream
239
326
  .pipeThrough(transform)
240
327
  .pipeThrough(new AssistantMetaTransformStream())
241
328
  .pipeTo(
242
329
  new WritableStream({
243
330
  write(chunk) {
244
- if (chunk.type === "result") {
245
- if (shouldIgnoreAndCleanupResult(chunk.meta.toolCallId)) {
246
- return;
247
- }
331
+ if (chunk.type !== "result") return;
248
332
 
249
- const logicalToolCallId = getLogicalToolCallId(
250
- chunk.meta.toolCallId,
251
- );
252
- if (logicalToolCallId !== chunk.meta.toolCallId) {
253
- toolCallIdAliasesRef.current.delete(chunk.meta.toolCallId);
254
- }
255
- // the tool call result was already set by the backend
256
- if (lastToolStates.current[logicalToolCallId]?.hasResult) return;
257
-
258
- onResult({
259
- type: "add-tool-result",
260
- toolCallId: logicalToolCallId,
261
- toolName: chunk.meta.toolName,
262
- result: chunk.result,
263
- isError: chunk.isError,
264
- ...(chunk.artifact !== undefined && {
265
- artifact: chunk.artifact,
266
- }),
267
- ...(chunk.modelContent !== undefined && {
268
- modelContent: chunk.modelContent,
269
- }),
270
- });
333
+ const streamId = chunk.meta.toolCallId;
334
+ const logicalToolCallId = getLogicalToolCallId(streamId);
335
+ const entry = entriesRef.current.get(logicalToolCallId);
336
+
337
+ // Result chunk from a rewrite-superseded stream: drop and clean
338
+ // up the alias.
339
+ if (abandonedStreamIdsRef.current.delete(streamId)) {
340
+ streamToLogicalRef.current.delete(streamId);
341
+ return;
342
+ }
343
+
344
+ // Pre-resolved tool call whose entry has been cleared by
345
+ // `reset()`. Both the real result chunk and the post-abort
346
+ // cancellation chunk can land here in either order; suppress
347
+ // both via the long-lived `skipExecuteStreamIdsRef` marker.
348
+ if (!entry && skipExecuteStreamIdsRef.current.has(streamId)) {
349
+ return;
350
+ }
351
+
352
+ // The host already set the result (via the live snapshot's
353
+ // `setResponse` path). Suppress the executor's redundant emit.
354
+ if (entry?.hasResult) return;
355
+
356
+ if (streamId !== logicalToolCallId) {
357
+ streamToLogicalRef.current.delete(streamId);
271
358
  }
359
+
360
+ onResult({
361
+ type: "add-tool-result",
362
+ toolCallId: logicalToolCallId,
363
+ toolName: chunk.meta.toolName,
364
+ result: chunk.result,
365
+ isError: chunk.isError,
366
+ ...(chunk.artifact !== undefined && {
367
+ artifact: chunk.artifact,
368
+ }),
369
+ ...(chunk.modelContent !== undefined && {
370
+ modelContent: chunk.modelContent,
371
+ }),
372
+ });
272
373
  },
273
374
  }),
274
375
  );
@@ -276,37 +377,7 @@ export function useToolInvocations({
276
377
  return controller;
277
378
  });
278
379
 
279
- const ignoredToolIds = useRef<Set<string>>(new Set());
280
- const isInitialState = useRef(true);
281
-
282
380
  useEffect(() => {
283
- const createToolState = ({
284
- controller,
285
- streamToolCallId,
286
- }: {
287
- controller: ToolCallStreamController;
288
- streamToolCallId: string;
289
- }): ToolState => ({
290
- argsText: "",
291
- hasResult: false,
292
- argsComplete: false,
293
- streamToolCallId,
294
- controller,
295
- });
296
-
297
- const setToolState = (toolCallId: string, state: ToolState) => {
298
- lastToolStates.current[toolCallId] = state;
299
- return state;
300
- };
301
-
302
- const patchToolState = (
303
- toolCallId: string,
304
- state: ToolState,
305
- patch: Partial<ToolState>,
306
- ) => {
307
- return setToolState(toolCallId, { ...state, ...patch });
308
- };
309
-
310
381
  const hasExecutableTool = (toolName: string) => {
311
382
  const tool = getTools()?.[toolName];
312
383
  return tool?.execute !== undefined || tool?.streamCall !== undefined;
@@ -323,256 +394,251 @@ export function useToolInvocations({
323
394
  }) => {
324
395
  if (hasResult) return true;
325
396
  if (!hasExecutableTool(toolName)) {
326
- // Non-executable tools can emit parseable snapshots mid-stream.
327
- // Wait until the run settles before closing the args stream.
397
+ // Non-executable tools can emit parseable JSON mid-stream; wait for
398
+ // the run to settle before closing.
328
399
  return !state.isRunning && isArgsTextComplete(argsText);
329
400
  }
330
401
  return isArgsTextComplete(argsText);
331
402
  };
332
403
 
333
- const restartToolArgsStream = ({
334
- toolCallId,
335
- toolName,
336
- state,
337
- }: {
338
- toolCallId: string;
339
- toolName: string;
340
- state: ToolState;
341
- }) => {
342
- ignoredResultToolCallIdsRef.current.add(state.streamToolCallId);
343
- state.controller.argsText.close();
344
-
345
- const streamToolCallId = `${toolCallId}:rewrite:${rewriteCounterRef.current++}`;
346
- toolCallIdAliasesRef.current.set(streamToolCallId, toolCallId);
404
+ const startActiveEntry = (
405
+ toolCallId: string,
406
+ toolName: string,
407
+ skipExecute: boolean,
408
+ ): ToolCallEntry => {
347
409
  const toolCallController = controller.addToolCallPart({
348
410
  toolName,
349
- toolCallId: streamToolCallId,
411
+ toolCallId,
412
+ });
413
+ if (skipExecute) {
414
+ skipExecuteStreamIdsRef.current.add(toolCallId);
415
+ }
416
+ const entry: ToolCallEntry = {
417
+ toolName,
418
+ controller: toolCallController,
419
+ streamId: toolCallId,
420
+ argsText: "",
421
+ hasResult: false,
422
+ argsComplete: false,
423
+ };
424
+ entriesRef.current.set(toolCallId, entry);
425
+ return entry;
426
+ };
427
+
428
+ const restartArgsStream = (entry: ToolCallEntry, toolCallId: string) => {
429
+ if (!entry.controller) return;
430
+ abandonedStreamIdsRef.current.add(entry.streamId);
431
+ // The wrapper's execute short-circuit follows the current stream id;
432
+ // the abandoned id stays in `skipExecuteStreamIdsRef` if it was there,
433
+ // which is harmless and keeps in-flight chunks consistent.
434
+ const wasSkipExecute = skipExecuteStreamIdsRef.current.has(
435
+ entry.streamId,
436
+ );
437
+ entry.controller.argsText.close();
438
+
439
+ const newStreamId = `${toolCallId}:rewrite:${rewriteCounterRef.current++}`;
440
+ streamToLogicalRef.current.set(newStreamId, toolCallId);
441
+ const newController = controller.addToolCallPart({
442
+ toolName: entry.toolName,
443
+ toolCallId: newStreamId,
350
444
  });
445
+ if (wasSkipExecute) {
446
+ skipExecuteStreamIdsRef.current.add(newStreamId);
447
+ }
351
448
 
352
449
  if (process.env.NODE_ENV !== "production") {
353
450
  console.warn("started replacement stream tool call", {
354
451
  toolCallId,
355
- streamToolCallId,
452
+ streamToolCallId: newStreamId,
356
453
  });
357
454
  }
358
455
 
359
- return setToolState(toolCallId, {
360
- ...createToolState({
361
- controller: toolCallController,
362
- streamToolCallId,
363
- }),
364
- hasResult: state.hasResult,
365
- });
456
+ entry.controller = newController;
457
+ entry.streamId = newStreamId;
458
+ entry.argsText = "";
459
+ entry.argsComplete = false;
366
460
  };
367
461
 
368
- const processMessages = (
369
- messages: readonly (typeof state.messages)[number][],
462
+ const processArgsText = (
463
+ entry: ToolCallEntry,
464
+ content: {
465
+ toolCallId: string;
466
+ toolName: string;
467
+ argsText: string;
468
+ result?: unknown;
469
+ },
370
470
  ) => {
371
- messages.forEach((message) => {
372
- message.content.forEach((content) => {
373
- if (content.type === "tool-call") {
374
- if (isInitialState.current) {
375
- ignoredToolIds.current.add(content.toolCallId);
471
+ if (!entry.controller) return;
472
+ const hasResult = content.result !== undefined;
473
+
474
+ if (content.argsText !== entry.argsText) {
475
+ let shouldWriteArgsText = true;
476
+
477
+ if (entry.argsComplete) {
478
+ if (isEquivalentCompleteArgsText(entry.argsText, content.argsText)) {
479
+ entry.argsText = content.argsText;
480
+ shouldWriteArgsText = false;
481
+ } else {
482
+ const canRestart =
483
+ !entry.hasResult && !executingRef.current.has(entry.streamId);
484
+ if (process.env.NODE_ENV !== "production") {
485
+ console.warn(
486
+ canRestart
487
+ ? "argsText updated after controller was closed, restarting tool args stream:"
488
+ : "argsText updated after controller was closed:",
489
+ { previous: entry.argsText, next: content.argsText },
490
+ );
491
+ }
492
+ if (!canRestart) {
493
+ entry.argsText = content.argsText;
494
+ shouldWriteArgsText = false;
376
495
  } else {
377
- if (ignoredToolIds.current.has(content.toolCallId)) {
378
- return;
379
- }
380
- let lastState = lastToolStates.current[content.toolCallId];
381
- if (!lastState) {
382
- if (content.result !== undefined) {
383
- if (content.messages) {
384
- processMessages(content.messages);
385
- }
386
- return;
387
- }
388
-
389
- toolCallIdAliasesRef.current.set(
390
- content.toolCallId,
391
- content.toolCallId,
392
- );
393
- const toolCallController = controller.addToolCallPart({
394
- toolName: content.toolName,
496
+ restartArgsStream(entry, content.toolCallId);
497
+ }
498
+ }
499
+ } else if (!content.argsText.startsWith(entry.argsText)) {
500
+ // Mid-stream rewrite. If both texts parse to equivalent JSON it's a
501
+ // key-reorder snapshot — accept silently. Otherwise restart.
502
+ if (
503
+ isArgsTextComplete(entry.argsText) &&
504
+ isArgsTextComplete(content.argsText) &&
505
+ isEquivalentCompleteArgsText(entry.argsText, content.argsText)
506
+ ) {
507
+ const shouldClose = shouldCloseArgsStream({
508
+ toolName: content.toolName,
509
+ argsText: content.argsText,
510
+ hasResult,
511
+ });
512
+ if (shouldClose) entry.controller.argsText.close();
513
+ entry.argsText = content.argsText;
514
+ entry.argsComplete = shouldClose;
515
+ shouldWriteArgsText = false;
516
+ } else {
517
+ if (process.env.NODE_ENV !== "production") {
518
+ console.warn(
519
+ "argsText rewrote previous snapshot, restarting tool args stream:",
520
+ {
521
+ previous: entry.argsText,
522
+ next: content.argsText,
395
523
  toolCallId: content.toolCallId,
396
- });
397
- lastState = setToolState(
398
- content.toolCallId,
399
- createToolState({
400
- controller: toolCallController,
401
- streamToolCallId: content.toolCallId,
402
- }),
403
- );
404
- }
524
+ },
525
+ );
526
+ }
527
+ restartArgsStream(entry, content.toolCallId);
528
+ }
529
+ }
530
+
531
+ if (shouldWriteArgsText) {
532
+ const delta = content.argsText.slice(entry.argsText.length);
533
+ entry.controller.argsText.append(delta);
534
+ const shouldClose = shouldCloseArgsStream({
535
+ toolName: content.toolName,
536
+ argsText: content.argsText,
537
+ hasResult,
538
+ });
539
+ if (shouldClose) entry.controller.argsText.close();
540
+ entry.argsText = content.argsText;
541
+ entry.argsComplete = shouldClose;
542
+ }
543
+ }
405
544
 
406
- if (content.argsText !== lastState.argsText) {
407
- let shouldWriteArgsText = true;
408
-
409
- if (lastState.argsComplete) {
410
- if (
411
- isEquivalentCompleteArgsText(
412
- lastState.argsText,
413
- content.argsText,
414
- )
415
- ) {
416
- lastState = patchToolState(content.toolCallId, lastState, {
417
- argsText: content.argsText,
418
- });
419
- shouldWriteArgsText = false;
420
- }
421
-
422
- if (shouldWriteArgsText) {
423
- const canRestartClosedArgsStream =
424
- !lastState.hasResult &&
425
- !startedExecutionToolCallIdsRef.current.has(
426
- lastState.streamToolCallId,
427
- );
428
-
429
- if (process.env.NODE_ENV !== "production") {
430
- console.warn(
431
- canRestartClosedArgsStream
432
- ? "argsText updated after controller was closed, restarting tool args stream:"
433
- : "argsText updated after controller was closed:",
434
- {
435
- previous: lastState.argsText,
436
- next: content.argsText,
437
- },
438
- );
439
- }
440
-
441
- if (!canRestartClosedArgsStream) {
442
- lastState = patchToolState(
443
- content.toolCallId,
444
- lastState,
445
- {
446
- argsText: content.argsText,
447
- },
448
- );
449
- shouldWriteArgsText = false;
450
- }
451
- }
452
-
453
- if (shouldWriteArgsText) {
454
- lastState = restartToolArgsStream({
455
- toolCallId: content.toolCallId,
456
- toolName: content.toolName,
457
- state: lastState,
458
- });
459
- }
460
- } else if (!content.argsText.startsWith(lastState.argsText)) {
461
- // Check if this is key reordering (both are complete JSON)
462
- // This happens when transitioning from streaming to complete state
463
- // and the provider returns keys in a different order
464
- if (
465
- isArgsTextComplete(lastState.argsText) &&
466
- isArgsTextComplete(content.argsText) &&
467
- isEquivalentCompleteArgsText(
468
- lastState.argsText,
469
- content.argsText,
470
- )
471
- ) {
472
- const shouldClose = shouldCloseArgsStream({
473
- toolName: content.toolName,
474
- argsText: content.argsText,
475
- hasResult: content.result !== undefined,
476
- });
477
- if (shouldClose) {
478
- lastState.controller.argsText.close();
479
- }
480
- lastState = patchToolState(content.toolCallId, lastState, {
481
- argsText: content.argsText,
482
- argsComplete: shouldClose,
483
- });
484
- shouldWriteArgsText = false;
485
- }
486
- if (shouldWriteArgsText) {
487
- if (process.env.NODE_ENV !== "production") {
488
- console.warn(
489
- "argsText rewrote previous snapshot, restarting tool args stream:",
490
- {
491
- previous: lastState.argsText,
492
- next: content.argsText,
493
- toolCallId: content.toolCallId,
494
- },
495
- );
496
- }
497
- lastState = restartToolArgsStream({
498
- toolCallId: content.toolCallId,
499
- toolName: content.toolName,
500
- state: lastState,
501
- });
502
- }
503
- }
504
-
505
- if (shouldWriteArgsText) {
506
- const argsTextDelta = content.argsText.slice(
507
- lastState.argsText.length,
508
- );
509
- lastState.controller.argsText.append(argsTextDelta);
510
-
511
- const shouldClose = shouldCloseArgsStream({
512
- toolName: content.toolName,
513
- argsText: content.argsText,
514
- hasResult: content.result !== undefined,
515
- });
516
- if (shouldClose) {
517
- lastState.controller.argsText.close();
518
- }
519
-
520
- lastState = patchToolState(content.toolCallId, lastState, {
521
- argsText: content.argsText,
522
- argsComplete: shouldClose,
523
- });
524
- }
525
- }
545
+ if (!entry.argsComplete) {
546
+ const shouldClose = shouldCloseArgsStream({
547
+ toolName: content.toolName,
548
+ argsText: content.argsText,
549
+ hasResult,
550
+ });
551
+ if (shouldClose) {
552
+ entry.controller.argsText.close();
553
+ entry.argsText = content.argsText;
554
+ entry.argsComplete = true;
555
+ }
556
+ }
557
+ };
526
558
 
527
- if (!lastState.argsComplete) {
528
- const shouldClose = shouldCloseArgsStream({
529
- toolName: content.toolName,
530
- argsText: content.argsText,
531
- hasResult: content.result !== undefined,
532
- });
533
- if (shouldClose) {
534
- lastState.controller.argsText.close();
535
- lastState = patchToolState(content.toolCallId, lastState, {
536
- argsText: content.argsText,
537
- argsComplete: true,
538
- });
539
- }
540
- }
559
+ const processMessages = (
560
+ messages: readonly (typeof state.messages)[number][],
561
+ ) => {
562
+ const isRestore = pendingRestoreRef.current;
541
563
 
542
- if (content.result !== undefined && !lastState.hasResult) {
543
- patchToolState(content.toolCallId, lastState, {
544
- hasResult: true,
545
- argsComplete: true,
546
- });
547
-
548
- lastState.controller.setResponse(
549
- new ToolResponse({
550
- result: content.result as ReadonlyJSONValue,
551
- artifact: content.artifact as ReadonlyJSONValue | undefined,
552
- isError: content.isError,
553
- ...(content.modelContent !== undefined
554
- ? { modelContent: content.modelContent }
555
- : {}),
556
- }),
557
- );
558
- lastState.controller.close();
559
- }
564
+ messages.forEach((message) => {
565
+ message.content.forEach((content) => {
566
+ if (content.type !== "tool-call") return;
567
+
568
+ const existing = entriesRef.current.get(content.toolCallId);
569
+
570
+ if (isRestore) {
571
+ // Don't overwrite an already-active entry (e.g. live tool-call
572
+ // observed before this restore snapshot landed). Restore can only
573
+ // seed entries the runtime has never seen.
574
+ if (!existing?.controller) {
575
+ entriesRef.current.set(content.toolCallId, {
576
+ toolName: content.toolName,
577
+ argsText: content.argsText,
578
+ hasResult: content.result !== undefined,
579
+ });
560
580
  }
581
+ if (content.messages) processMessages(content.messages);
582
+ return;
583
+ }
561
584
 
562
- // Recursively process nested messages
563
- if (content.messages) {
564
- processMessages(content.messages);
585
+ // Live snapshot.
586
+ let entry = existing;
587
+
588
+ if (entry && !entry.controller) {
589
+ // Restored entry observed in a live snapshot. Promote if its
590
+ // signature has changed; otherwise treat as still-historical.
591
+ const signatureChanged =
592
+ content.argsText !== entry.argsText ||
593
+ (content.result !== undefined) !== entry.hasResult;
594
+ if (!signatureChanged) {
595
+ if (content.messages) processMessages(content.messages);
596
+ return;
565
597
  }
598
+ entriesRef.current.delete(content.toolCallId);
599
+ entry = undefined;
600
+ }
601
+
602
+ if (!entry) {
603
+ entry = startActiveEntry(
604
+ content.toolCallId,
605
+ content.toolName,
606
+ content.result !== undefined,
607
+ );
566
608
  }
609
+
610
+ processArgsText(entry, content);
611
+
612
+ if (content.result !== undefined && !entry.hasResult) {
613
+ // `entry` is in active phase from this point — either it was
614
+ // just created by `startActiveEntry` above, or it pre-existed
615
+ // and `processArgsText` preserved (or replaced via rewrite) its
616
+ // controller. Narrow once instead of asserting at every use.
617
+ const { controller: activeController } = entry;
618
+ if (!activeController) return;
619
+ entry.hasResult = true;
620
+ entry.argsComplete = true;
621
+ activeController.setResponse(
622
+ new ToolResponse({
623
+ result: content.result as ReadonlyJSONValue,
624
+ artifact: content.artifact as ReadonlyJSONValue | undefined,
625
+ isError: content.isError,
626
+ ...(content.modelContent !== undefined
627
+ ? { modelContent: content.modelContent }
628
+ : {}),
629
+ }),
630
+ );
631
+ activeController.close();
632
+ }
633
+
634
+ if (content.messages) processMessages(content.messages);
567
635
  });
568
636
  });
569
637
  };
570
638
 
571
639
  processMessages(state.messages);
572
640
 
573
- if (isInitialState.current) {
574
- isInitialState.current = false;
575
- }
641
+ pendingRestoreRef.current = false;
576
642
  }, [state, controller, getTools]);
577
643
 
578
644
  const abort = (): Promise<void> => {
@@ -584,7 +650,6 @@ export function useToolInvocations({
584
650
  acRef.current.abort();
585
651
  acRef.current = new AbortController();
586
652
 
587
- // Return a promise that resolves when all executing tools have settled
588
653
  if (executingCountRef.current === 0) {
589
654
  return Promise.resolve();
590
655
  }
@@ -595,13 +660,17 @@ export function useToolInvocations({
595
660
 
596
661
  return {
597
662
  reset: () => {
598
- isInitialState.current = true;
599
- ignoredToolIds.current.clear();
600
- lastToolStates.current = {};
663
+ pendingRestoreRef.current = true;
664
+ entriesRef.current.clear();
665
+ // `skipExecuteStreamIdsRef` is not cleared: it has to outlive `reset()`
666
+ // so (a) any wrapper call still inbound through the stream pipeline
667
+ // continues to short-circuit `execute`, and (b) the consumer can
668
+ // recognize and drop any post-abort cancellation `result` chunks for
669
+ // pre-resolved streams whose entries have been cleared. Membership
670
+ // grows by one per pre-resolved tool call observed in the session.
601
671
  void abort().finally(() => {
602
- startedExecutionToolCallIdsRef.current.clear();
603
- toolCallIdAliasesRef.current.clear();
604
- ignoredResultToolCallIdsRef.current.clear();
672
+ executingRef.current.clear();
673
+ streamToLogicalRef.current.clear();
605
674
  rewriteCounterRef.current = 0;
606
675
  });
607
676
  },