@assistant-ui/core 0.2.0 → 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 (201) hide show
  1. package/README.md +45 -0
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/model-context/tool.d.ts +25 -0
  7. package/dist/model-context/tool.d.ts.map +1 -1
  8. package/dist/model-context/tool.js +25 -0
  9. package/dist/model-context/tool.js.map +1 -1
  10. package/dist/react/AssistantRuntimeProvider.d.ts +33 -0
  11. package/dist/react/AssistantRuntimeProvider.d.ts.map +1 -1
  12. package/dist/react/AssistantRuntimeProvider.js +22 -0
  13. package/dist/react/AssistantRuntimeProvider.js.map +1 -1
  14. package/dist/react/client/DataRenderers.d.ts +7 -0
  15. package/dist/react/client/DataRenderers.d.ts.map +1 -1
  16. package/dist/react/client/DataRenderers.js +7 -0
  17. package/dist/react/client/DataRenderers.js.map +1 -1
  18. package/dist/react/client/Tools.d.ts +18 -1
  19. package/dist/react/client/Tools.d.ts.map +1 -1
  20. package/dist/react/client/Tools.js +24 -19
  21. package/dist/react/client/Tools.js.map +1 -1
  22. package/dist/react/index.d.ts +2 -1
  23. package/dist/react/index.d.ts.map +1 -1
  24. package/dist/react/index.js +1 -0
  25. package/dist/react/index.js.map +1 -1
  26. package/dist/react/model-context/makeAssistantDataUI.d.ts +13 -0
  27. package/dist/react/model-context/makeAssistantDataUI.d.ts.map +1 -1
  28. package/dist/react/model-context/makeAssistantDataUI.js +6 -0
  29. package/dist/react/model-context/makeAssistantDataUI.js.map +1 -1
  30. package/dist/react/model-context/makeAssistantTool.d.ts +15 -0
  31. package/dist/react/model-context/makeAssistantTool.d.ts.map +1 -1
  32. package/dist/react/model-context/makeAssistantTool.js +8 -0
  33. package/dist/react/model-context/makeAssistantTool.js.map +1 -1
  34. package/dist/react/model-context/makeAssistantToolUI.d.ts +15 -0
  35. package/dist/react/model-context/makeAssistantToolUI.d.ts.map +1 -1
  36. package/dist/react/model-context/makeAssistantToolUI.js +8 -0
  37. package/dist/react/model-context/makeAssistantToolUI.js.map +1 -1
  38. package/dist/react/model-context/toolbox.d.ts +29 -0
  39. package/dist/react/model-context/toolbox.d.ts.map +1 -1
  40. package/dist/react/model-context/useAssistantDataUI.d.ts +9 -0
  41. package/dist/react/model-context/useAssistantDataUI.d.ts.map +1 -1
  42. package/dist/react/model-context/useAssistantDataUI.js +6 -0
  43. package/dist/react/model-context/useAssistantDataUI.js.map +1 -1
  44. package/dist/react/model-context/useAssistantTool.d.ts +34 -0
  45. package/dist/react/model-context/useAssistantTool.d.ts.map +1 -1
  46. package/dist/react/model-context/useAssistantTool.js +30 -0
  47. package/dist/react/model-context/useAssistantTool.js.map +1 -1
  48. package/dist/react/model-context/useAssistantToolUI.d.ts +12 -0
  49. package/dist/react/model-context/useAssistantToolUI.d.ts.map +1 -1
  50. package/dist/react/model-context/useAssistantToolUI.js +9 -0
  51. package/dist/react/model-context/useAssistantToolUI.js.map +1 -1
  52. package/dist/react/model-context/useToolArgsStatus.d.ts +29 -0
  53. package/dist/react/model-context/useToolArgsStatus.d.ts.map +1 -1
  54. package/dist/react/model-context/useToolArgsStatus.js +24 -0
  55. package/dist/react/model-context/useToolArgsStatus.js.map +1 -1
  56. package/dist/react/primitive-hooks/useActionBarCopy.d.ts.map +1 -1
  57. package/dist/react/primitive-hooks/useActionBarCopy.js +4 -3
  58. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  59. package/dist/react/primitive-hooks/useComposerSend.d.ts.map +1 -1
  60. package/dist/react/primitive-hooks/useComposerSend.js +2 -3
  61. package/dist/react/primitive-hooks/useComposerSend.js.map +1 -1
  62. package/dist/react/primitives/message/MessageAttachments.js +1 -1
  63. package/dist/react/primitives/message/MessageAttachments.js.map +1 -1
  64. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  65. package/dist/react/primitives/message/MessageParts.js +14 -10
  66. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  67. package/dist/react/primitives/messagePart/MessagePartInProgress.d.ts +6 -0
  68. package/dist/react/primitives/messagePart/MessagePartInProgress.d.ts.map +1 -0
  69. package/dist/react/primitives/messagePart/MessagePartInProgress.js +7 -0
  70. package/dist/react/primitives/messagePart/MessagePartInProgress.js.map +1 -0
  71. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -0
  72. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  73. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +2 -0
  74. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  75. package/dist/react/runtimes/cloud/auiV0.d.ts +10 -1
  76. package/dist/react/runtimes/cloud/auiV0.d.ts.map +1 -1
  77. package/dist/react/runtimes/cloud/auiV0.js +21 -3
  78. package/dist/react/runtimes/cloud/auiV0.js.map +1 -1
  79. package/dist/react/runtimes/useToolInvocations.d.ts +11 -1
  80. package/dist/react/runtimes/useToolInvocations.d.ts.map +1 -1
  81. package/dist/react/runtimes/useToolInvocations.js +325 -256
  82. package/dist/react/runtimes/useToolInvocations.js.map +1 -1
  83. package/dist/react/types/MessagePartComponentTypes.d.ts +11 -0
  84. package/dist/react/types/MessagePartComponentTypes.d.ts.map +1 -1
  85. package/dist/react/types/scopes/tools.d.ts +4 -0
  86. package/dist/react/types/scopes/tools.d.ts.map +1 -1
  87. package/dist/runtime/api/composer-runtime.d.ts +1 -0
  88. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  89. package/dist/runtime/api/composer-runtime.js +2 -0
  90. package/dist/runtime/api/composer-runtime.js.map +1 -1
  91. package/dist/runtime/api/thread-runtime.d.ts +2 -0
  92. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  93. package/dist/runtime/base/base-composer-runtime-core.d.ts +1 -0
  94. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  95. package/dist/runtime/base/base-composer-runtime-core.js +1 -1
  96. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  97. package/dist/runtime/base/base-thread-runtime-core.d.ts +1 -0
  98. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  99. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  100. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts +1 -0
  101. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  102. package/dist/runtime/base/default-edit-composer-runtime-core.js +3 -0
  103. package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
  104. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts +1 -0
  105. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  106. package/dist/runtime/base/default-thread-composer-runtime-core.js +12 -1
  107. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  108. package/dist/runtime/interfaces/composer-runtime-core.d.ts +1 -0
  109. package/dist/runtime/interfaces/composer-runtime-core.d.ts.map +1 -1
  110. package/dist/runtime/interfaces/thread-runtime-core.d.ts +6 -0
  111. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  112. package/dist/runtimes/external-store/external-store-adapter.d.ts +15 -0
  113. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  114. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts +1 -1
  115. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  116. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js +14 -12
  117. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  118. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +2 -0
  119. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  120. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +13 -0
  121. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  122. package/dist/runtimes/local/local-thread-runtime-core.d.ts +1 -0
  123. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  124. package/dist/runtimes/local/local-thread-runtime-core.js +1 -0
  125. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  126. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +2 -0
  127. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  128. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +2 -0
  129. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  130. package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
  131. package/dist/runtimes/remote-thread-list/empty-thread-core.js +2 -0
  132. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  133. package/dist/store/clients/model-context-client.d.ts.map +1 -1
  134. package/dist/store/clients/model-context-client.js +24 -4
  135. package/dist/store/clients/model-context-client.js.map +1 -1
  136. package/dist/store/clients/no-op-composer-client.d.ts.map +1 -1
  137. package/dist/store/clients/no-op-composer-client.js +1 -0
  138. package/dist/store/clients/no-op-composer-client.js.map +1 -1
  139. package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
  140. package/dist/store/runtime-clients/composer-runtime-client.js +1 -0
  141. package/dist/store/runtime-clients/composer-runtime-client.js.map +1 -1
  142. package/dist/store/scopes/composer.d.ts +9 -0
  143. package/dist/store/scopes/composer.d.ts.map +1 -1
  144. package/dist/store/scopes/model-context.d.ts +4 -1
  145. package/dist/store/scopes/model-context.d.ts.map +1 -1
  146. package/dist/types/index.d.ts +1 -1
  147. package/dist/types/index.d.ts.map +1 -1
  148. package/dist/types/message.d.ts +50 -1
  149. package/dist/types/message.d.ts.map +1 -1
  150. package/dist/types/message.js +2 -1
  151. package/dist/types/message.js.map +1 -1
  152. package/package.json +7 -7
  153. package/src/index.ts +6 -0
  154. package/src/model-context/tool.ts +25 -0
  155. package/src/react/AssistantRuntimeProvider.tsx +33 -0
  156. package/src/react/client/DataRenderers.ts +7 -0
  157. package/src/react/client/Tools.ts +56 -22
  158. package/src/react/index.ts +2 -1
  159. package/src/react/model-context/makeAssistantDataUI.ts +13 -0
  160. package/src/react/model-context/makeAssistantTool.ts +15 -0
  161. package/src/react/model-context/makeAssistantToolUI.ts +15 -0
  162. package/src/react/model-context/toolbox.ts +32 -1
  163. package/src/react/model-context/useAssistantDataUI.ts +9 -0
  164. package/src/react/model-context/useAssistantTool.ts +34 -0
  165. package/src/react/model-context/useAssistantToolUI.ts +12 -0
  166. package/src/react/model-context/useToolArgsStatus.ts +29 -0
  167. package/src/react/primitive-hooks/useActionBarCopy.ts +9 -5
  168. package/src/react/primitive-hooks/useComposerSend.ts +2 -3
  169. package/src/react/primitives/message/MessageAttachments.test.tsx +50 -0
  170. package/src/react/primitives/message/MessageAttachments.tsx +1 -1
  171. package/src/react/primitives/message/MessageParts.tsx +20 -9
  172. package/src/react/primitives/messagePart/MessagePartInProgress.ts +15 -0
  173. package/src/react/runtimes/cloud/auiV0.ts +37 -4
  174. package/src/react/runtimes/useToolInvocations.ts +422 -333
  175. package/src/react/types/MessagePartComponentTypes.ts +11 -0
  176. package/src/react/types/scopes/tools.ts +5 -0
  177. package/src/runtime/api/composer-runtime.ts +3 -0
  178. package/src/runtime/base/base-composer-runtime-core.ts +2 -1
  179. package/src/runtime/base/base-thread-runtime-core.ts +1 -0
  180. package/src/runtime/base/default-edit-composer-runtime-core.ts +4 -0
  181. package/src/runtime/base/default-thread-composer-runtime-core.ts +12 -1
  182. package/src/runtime/interfaces/composer-runtime-core.ts +1 -0
  183. package/src/runtime/interfaces/thread-runtime-core.ts +6 -0
  184. package/src/runtimes/external-store/external-store-adapter.ts +15 -0
  185. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +15 -9
  186. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +13 -0
  187. package/src/runtimes/local/local-thread-runtime-core.ts +1 -0
  188. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +2 -0
  189. package/src/runtimes/remote-thread-list/empty-thread-core.ts +2 -0
  190. package/src/store/clients/model-context-client.test.ts +108 -0
  191. package/src/store/clients/model-context-client.ts +36 -6
  192. package/src/store/clients/no-op-composer-client.ts +1 -0
  193. package/src/store/runtime-clients/composer-runtime-client.ts +1 -0
  194. package/src/store/scopes/composer.ts +9 -0
  195. package/src/store/scopes/model-context.ts +4 -1
  196. package/src/tests/auiV0Encode.test.ts +55 -0
  197. package/src/tests/composer-can-send.test.ts +112 -0
  198. package/src/tests/external-store-thread-list-runtime-core.test.ts +34 -0
  199. package/src/tests/external-store-thread-runtime-core.test.ts +113 -0
  200. package/src/types/index.ts +2 -0
  201. package/src/types/message.ts +66 -7
@@ -7,6 +7,7 @@ import {
7
7
  ToolResponse,
8
8
  unstable_toolResultStream,
9
9
  type Tool,
10
+ type ToolModelContentPart,
10
11
  } from "assistant-stream";
11
12
  import {
12
13
  AssistantMetaTransformStream,
@@ -28,6 +29,7 @@ export type AddToolResultCommand = {
28
29
  readonly result: ReadonlyJSONValue;
29
30
  readonly isError: boolean;
30
31
  readonly artifact?: ReadonlyJSONValue;
32
+ readonly modelContent?: readonly ToolModelContentPart[];
31
33
  };
32
34
 
33
35
  const isArgsTextComplete = (argsText: string) => {
@@ -67,16 +69,60 @@ type UseToolInvocationsParams = {
67
69
  ) => void;
68
70
  };
69
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
+ */
70
78
  export type ToolExecutionStatus =
71
- | { type: "executing" }
72
- | { 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
+ };
73
89
 
74
- 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. */
75
98
  argsText: string;
99
+ /** Last observed `result !== undefined` for this tool call. */
76
100
  hasResult: boolean;
77
- argsComplete: boolean;
78
- streamToolCallId: string;
79
- 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;
80
126
  };
81
127
 
82
128
  export function useToolInvocations({
@@ -85,7 +131,37 @@ export function useToolInvocations({
85
131
  onResult,
86
132
  setToolStatuses,
87
133
  }: UseToolInvocationsParams) {
88
- 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());
89
165
 
90
166
  const humanInputRef = useRef<
91
167
  Map<
@@ -97,24 +173,28 @@ export function useToolInvocations({
97
173
  >
98
174
  >(new Map());
99
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
+
100
183
  const acRef = useRef<AbortController>(new AbortController());
101
184
  const executingCountRef = useRef(0);
102
- const startedExecutionToolCallIdsRef = useRef<Set<string>>(new Set());
103
185
  const settledResolversRef = useRef<Array<() => void>>([]);
104
- const toolCallIdAliasesRef = useRef<Map<string, string>>(new Map());
105
- const ignoredResultToolCallIdsRef = useRef<Set<string>>(new Set());
106
186
  const rewriteCounterRef = useRef(0);
107
187
 
108
- const getLogicalToolCallId = (toolCallId: string) => {
109
- return toolCallIdAliasesRef.current.get(toolCallId) ?? toolCallId;
110
- };
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);
111
195
 
112
- const shouldIgnoreAndCleanupResult = (toolCallId: string) => {
113
- if (!ignoredResultToolCallIdsRef.current.has(toolCallId)) return false;
114
- ignoredResultToolCallIdsRef.current.delete(toolCallId);
115
- toolCallIdAliasesRef.current.delete(toolCallId);
116
- return true;
117
- };
196
+ const getLogicalToolCallId = (streamId: string) =>
197
+ streamToLogicalRef.current.get(streamId) ?? streamId;
118
198
 
119
199
  const getWrappedTools = () => {
120
200
  const tools = getTools();
@@ -124,17 +204,30 @@ export function useToolInvocations({
124
204
  Object.entries(tools).map(([name, tool]) => {
125
205
  const execute = tool.execute;
126
206
  const streamCall = tool.streamCall;
207
+ const toModelOutput = tool.toModelOutput;
127
208
 
128
209
  const wrappedTool = {
129
210
  ...tool,
130
211
  ...(execute !== undefined && {
131
212
  execute: (
132
213
  ...[args, context]: Parameters<NonNullable<typeof execute>>
133
- ) =>
134
- 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, {
135
227
  ...context,
136
228
  toolCallId: getLogicalToolCallId(context.toolCallId),
137
- }),
229
+ });
230
+ },
138
231
  }),
139
232
  ...(streamCall !== undefined && {
140
233
  streamCall: (
@@ -145,12 +238,28 @@ export function useToolInvocations({
145
238
  toolCallId: getLogicalToolCallId(context.toolCallId),
146
239
  }),
147
240
  }),
241
+ ...(toModelOutput !== undefined && {
242
+ toModelOutput: (
243
+ options: Parameters<NonNullable<typeof toModelOutput>>[0],
244
+ ) =>
245
+ toModelOutput({
246
+ ...options,
247
+ toolCallId: getLogicalToolCallId(options.toolCallId),
248
+ }),
249
+ }),
148
250
  } as Tool;
149
251
  return [name, wrappedTool];
150
252
  }),
151
253
  ) as Record<string, Tool>;
152
254
  };
153
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
+
154
263
  const [controller] = useState(() => {
155
264
  const [stream, controller] = createAssistantStreamController();
156
265
  const transform = unstable_toolResultStream(
@@ -159,14 +268,12 @@ export function useToolInvocations({
159
268
  (toolCallId: string, payload: unknown) => {
160
269
  const logicalToolCallId = getLogicalToolCallId(toolCallId);
161
270
  return new Promise<unknown>((resolve, reject) => {
162
- // Reject previous human input request if it exists
163
271
  const previous = humanInputRef.current.get(logicalToolCallId);
164
272
  if (previous) {
165
273
  previous.reject(
166
274
  new Error("Human input request was superseded by a new request"),
167
275
  );
168
276
  }
169
-
170
277
  humanInputRef.current.set(logicalToolCallId, { resolve, reject });
171
278
  setToolStatuses((prev) => ({
172
279
  ...prev,
@@ -178,80 +285,91 @@ export function useToolInvocations({
178
285
  });
179
286
  },
180
287
  {
181
- onExecutionStart: (toolCallId: string) => {
182
- if (ignoredResultToolCallIdsRef.current.has(toolCallId)) {
183
- return;
184
- }
185
- startedExecutionToolCallIdsRef.current.add(toolCallId);
186
- 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
+ });
187
297
  executingCountRef.current++;
188
- setToolStatuses((prev) => ({
189
- ...prev,
190
- [logicalToolCallId]: { type: "executing" },
191
- }));
192
- },
193
- onExecutionEnd: (toolCallId: string) => {
194
- const wasStarted =
195
- startedExecutionToolCallIdsRef.current.delete(toolCallId);
196
- if (ignoredResultToolCallIdsRef.current.has(toolCallId)) {
197
- if (wasStarted) {
198
- executingCountRef.current--;
199
- if (executingCountRef.current === 0) {
200
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return
201
- settledResolversRef.current.forEach((resolve) => resolve());
202
- settledResolversRef.current = [];
203
- }
204
- }
205
- return;
206
- }
207
- if (!wasStarted) {
208
- return;
298
+ if (!abandoned) {
299
+ setToolStatuses((prev) => ({
300
+ ...prev,
301
+ [logicalToolCallId]: { type: "executing" },
302
+ }));
209
303
  }
210
- 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
+
211
310
  executingCountRef.current--;
212
- setToolStatuses((prev) => {
213
- const next = { ...prev };
214
- delete next[logicalToolCallId];
215
- return next;
216
- });
217
- // 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
+ }
218
318
  if (executingCountRef.current === 0) {
219
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return
220
- settledResolversRef.current.forEach((resolve) => resolve());
221
- settledResolversRef.current = [];
319
+ resolveAllSettledResolvers();
222
320
  }
223
321
  },
224
322
  },
225
323
  );
324
+
226
325
  stream
227
326
  .pipeThrough(transform)
228
327
  .pipeThrough(new AssistantMetaTransformStream())
229
328
  .pipeTo(
230
329
  new WritableStream({
231
330
  write(chunk) {
232
- if (chunk.type === "result") {
233
- if (shouldIgnoreAndCleanupResult(chunk.meta.toolCallId)) {
234
- return;
235
- }
331
+ if (chunk.type !== "result") return;
236
332
 
237
- const logicalToolCallId = getLogicalToolCallId(
238
- chunk.meta.toolCallId,
239
- );
240
- if (logicalToolCallId !== chunk.meta.toolCallId) {
241
- toolCallIdAliasesRef.current.delete(chunk.meta.toolCallId);
242
- }
243
- // the tool call result was already set by the backend
244
- if (lastToolStates.current[logicalToolCallId]?.hasResult) return;
245
-
246
- onResult({
247
- type: "add-tool-result",
248
- toolCallId: logicalToolCallId,
249
- toolName: chunk.meta.toolName,
250
- result: chunk.result,
251
- isError: chunk.isError,
252
- ...(chunk.artifact && { artifact: chunk.artifact }),
253
- });
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;
254
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);
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
+ });
255
373
  },
256
374
  }),
257
375
  );
@@ -259,37 +377,7 @@ export function useToolInvocations({
259
377
  return controller;
260
378
  });
261
379
 
262
- const ignoredToolIds = useRef<Set<string>>(new Set());
263
- const isInitialState = useRef(true);
264
-
265
380
  useEffect(() => {
266
- const createToolState = ({
267
- controller,
268
- streamToolCallId,
269
- }: {
270
- controller: ToolCallStreamController;
271
- streamToolCallId: string;
272
- }): ToolState => ({
273
- argsText: "",
274
- hasResult: false,
275
- argsComplete: false,
276
- streamToolCallId,
277
- controller,
278
- });
279
-
280
- const setToolState = (toolCallId: string, state: ToolState) => {
281
- lastToolStates.current[toolCallId] = state;
282
- return state;
283
- };
284
-
285
- const patchToolState = (
286
- toolCallId: string,
287
- state: ToolState,
288
- patch: Partial<ToolState>,
289
- ) => {
290
- return setToolState(toolCallId, { ...state, ...patch });
291
- };
292
-
293
381
  const hasExecutableTool = (toolName: string) => {
294
382
  const tool = getTools()?.[toolName];
295
383
  return tool?.execute !== undefined || tool?.streamCall !== undefined;
@@ -306,253 +394,251 @@ export function useToolInvocations({
306
394
  }) => {
307
395
  if (hasResult) return true;
308
396
  if (!hasExecutableTool(toolName)) {
309
- // Non-executable tools can emit parseable snapshots mid-stream.
310
- // 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.
311
399
  return !state.isRunning && isArgsTextComplete(argsText);
312
400
  }
313
401
  return isArgsTextComplete(argsText);
314
402
  };
315
403
 
316
- const restartToolArgsStream = ({
317
- toolCallId,
318
- toolName,
319
- state,
320
- }: {
321
- toolCallId: string;
322
- toolName: string;
323
- state: ToolState;
324
- }) => {
325
- ignoredResultToolCallIdsRef.current.add(state.streamToolCallId);
326
- state.controller.argsText.close();
327
-
328
- const streamToolCallId = `${toolCallId}:rewrite:${rewriteCounterRef.current++}`;
329
- toolCallIdAliasesRef.current.set(streamToolCallId, toolCallId);
404
+ const startActiveEntry = (
405
+ toolCallId: string,
406
+ toolName: string,
407
+ skipExecute: boolean,
408
+ ): ToolCallEntry => {
330
409
  const toolCallController = controller.addToolCallPart({
331
410
  toolName,
332
- toolCallId: streamToolCallId,
411
+ toolCallId,
333
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,
444
+ });
445
+ if (wasSkipExecute) {
446
+ skipExecuteStreamIdsRef.current.add(newStreamId);
447
+ }
334
448
 
335
449
  if (process.env.NODE_ENV !== "production") {
336
450
  console.warn("started replacement stream tool call", {
337
451
  toolCallId,
338
- streamToolCallId,
452
+ streamToolCallId: newStreamId,
339
453
  });
340
454
  }
341
455
 
342
- return setToolState(toolCallId, {
343
- ...createToolState({
344
- controller: toolCallController,
345
- streamToolCallId,
346
- }),
347
- hasResult: state.hasResult,
348
- });
456
+ entry.controller = newController;
457
+ entry.streamId = newStreamId;
458
+ entry.argsText = "";
459
+ entry.argsComplete = false;
349
460
  };
350
461
 
351
- const processMessages = (
352
- 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
+ },
353
470
  ) => {
354
- messages.forEach((message) => {
355
- message.content.forEach((content) => {
356
- if (content.type === "tool-call") {
357
- if (isInitialState.current) {
358
- 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;
359
495
  } else {
360
- if (ignoredToolIds.current.has(content.toolCallId)) {
361
- return;
362
- }
363
- let lastState = lastToolStates.current[content.toolCallId];
364
- if (!lastState) {
365
- if (content.result !== undefined) {
366
- if (content.messages) {
367
- processMessages(content.messages);
368
- }
369
- return;
370
- }
371
-
372
- toolCallIdAliasesRef.current.set(
373
- content.toolCallId,
374
- content.toolCallId,
375
- );
376
- const toolCallController = controller.addToolCallPart({
377
- 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,
378
523
  toolCallId: content.toolCallId,
379
- });
380
- lastState = setToolState(
381
- content.toolCallId,
382
- createToolState({
383
- controller: toolCallController,
384
- streamToolCallId: content.toolCallId,
385
- }),
386
- );
387
- }
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
+ }
388
544
 
389
- if (content.argsText !== lastState.argsText) {
390
- let shouldWriteArgsText = true;
391
-
392
- if (lastState.argsComplete) {
393
- if (
394
- isEquivalentCompleteArgsText(
395
- lastState.argsText,
396
- content.argsText,
397
- )
398
- ) {
399
- lastState = patchToolState(content.toolCallId, lastState, {
400
- argsText: content.argsText,
401
- });
402
- shouldWriteArgsText = false;
403
- }
404
-
405
- if (shouldWriteArgsText) {
406
- const canRestartClosedArgsStream =
407
- !lastState.hasResult &&
408
- !startedExecutionToolCallIdsRef.current.has(
409
- lastState.streamToolCallId,
410
- );
411
-
412
- if (process.env.NODE_ENV !== "production") {
413
- console.warn(
414
- canRestartClosedArgsStream
415
- ? "argsText updated after controller was closed, restarting tool args stream:"
416
- : "argsText updated after controller was closed:",
417
- {
418
- previous: lastState.argsText,
419
- next: content.argsText,
420
- },
421
- );
422
- }
423
-
424
- if (!canRestartClosedArgsStream) {
425
- lastState = patchToolState(
426
- content.toolCallId,
427
- lastState,
428
- {
429
- argsText: content.argsText,
430
- },
431
- );
432
- shouldWriteArgsText = false;
433
- }
434
- }
435
-
436
- if (shouldWriteArgsText) {
437
- lastState = restartToolArgsStream({
438
- toolCallId: content.toolCallId,
439
- toolName: content.toolName,
440
- state: lastState,
441
- });
442
- }
443
- } else if (!content.argsText.startsWith(lastState.argsText)) {
444
- // Check if this is key reordering (both are complete JSON)
445
- // This happens when transitioning from streaming to complete state
446
- // and the provider returns keys in a different order
447
- if (
448
- isArgsTextComplete(lastState.argsText) &&
449
- isArgsTextComplete(content.argsText) &&
450
- isEquivalentCompleteArgsText(
451
- lastState.argsText,
452
- content.argsText,
453
- )
454
- ) {
455
- const shouldClose = shouldCloseArgsStream({
456
- toolName: content.toolName,
457
- argsText: content.argsText,
458
- hasResult: content.result !== undefined,
459
- });
460
- if (shouldClose) {
461
- lastState.controller.argsText.close();
462
- }
463
- lastState = patchToolState(content.toolCallId, lastState, {
464
- argsText: content.argsText,
465
- argsComplete: shouldClose,
466
- });
467
- shouldWriteArgsText = false;
468
- }
469
- if (shouldWriteArgsText) {
470
- if (process.env.NODE_ENV !== "production") {
471
- console.warn(
472
- "argsText rewrote previous snapshot, restarting tool args stream:",
473
- {
474
- previous: lastState.argsText,
475
- next: content.argsText,
476
- toolCallId: content.toolCallId,
477
- },
478
- );
479
- }
480
- lastState = restartToolArgsStream({
481
- toolCallId: content.toolCallId,
482
- toolName: content.toolName,
483
- state: lastState,
484
- });
485
- }
486
- }
487
-
488
- if (shouldWriteArgsText) {
489
- const argsTextDelta = content.argsText.slice(
490
- lastState.argsText.length,
491
- );
492
- lastState.controller.argsText.append(argsTextDelta);
493
-
494
- const shouldClose = shouldCloseArgsStream({
495
- toolName: content.toolName,
496
- argsText: content.argsText,
497
- hasResult: content.result !== undefined,
498
- });
499
- if (shouldClose) {
500
- lastState.controller.argsText.close();
501
- }
502
-
503
- lastState = patchToolState(content.toolCallId, lastState, {
504
- argsText: content.argsText,
505
- argsComplete: shouldClose,
506
- });
507
- }
508
- }
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
+ };
509
558
 
510
- if (!lastState.argsComplete) {
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
- lastState = patchToolState(content.toolCallId, lastState, {
519
- argsText: content.argsText,
520
- argsComplete: true,
521
- });
522
- }
523
- }
559
+ const processMessages = (
560
+ messages: readonly (typeof state.messages)[number][],
561
+ ) => {
562
+ const isRestore = pendingRestoreRef.current;
524
563
 
525
- if (content.result !== undefined && !lastState.hasResult) {
526
- patchToolState(content.toolCallId, lastState, {
527
- hasResult: true,
528
- argsComplete: true,
529
- });
530
-
531
- lastState.controller.setResponse(
532
- new ToolResponse({
533
- result: content.result as ReadonlyJSONValue,
534
- artifact: content.artifact as ReadonlyJSONValue | undefined,
535
- isError: content.isError,
536
- }),
537
- );
538
- lastState.controller.close();
539
- }
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
+ });
540
580
  }
581
+ if (content.messages) processMessages(content.messages);
582
+ return;
583
+ }
541
584
 
542
- // Recursively process nested messages
543
- if (content.messages) {
544
- 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;
545
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
+ );
546
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);
547
635
  });
548
636
  });
549
637
  };
550
638
 
551
639
  processMessages(state.messages);
552
640
 
553
- if (isInitialState.current) {
554
- isInitialState.current = false;
555
- }
641
+ pendingRestoreRef.current = false;
556
642
  }, [state, controller, getTools]);
557
643
 
558
644
  const abort = (): Promise<void> => {
@@ -564,7 +650,6 @@ export function useToolInvocations({
564
650
  acRef.current.abort();
565
651
  acRef.current = new AbortController();
566
652
 
567
- // Return a promise that resolves when all executing tools have settled
568
653
  if (executingCountRef.current === 0) {
569
654
  return Promise.resolve();
570
655
  }
@@ -575,13 +660,17 @@ export function useToolInvocations({
575
660
 
576
661
  return {
577
662
  reset: () => {
578
- isInitialState.current = true;
579
- ignoredToolIds.current.clear();
580
- 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.
581
671
  void abort().finally(() => {
582
- startedExecutionToolCallIdsRef.current.clear();
583
- toolCallIdAliasesRef.current.clear();
584
- ignoredResultToolCallIdsRef.current.clear();
672
+ executingRef.current.clear();
673
+ streamToLogicalRef.current.clear();
585
674
  rewriteCounterRef.current = 0;
586
675
  });
587
676
  },