@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
@@ -27,25 +27,53 @@ const isEquivalentCompleteArgsText = (previous, next) => {
27
27
  return isJSONValueEqual(previousValue, nextValue);
28
28
  };
29
29
  export function useToolInvocations({ state, getTools, onResult, setToolStatuses, }) {
30
- const lastToolStates = useRef({});
30
+ /**
31
+ * Single source of truth for per-tool-call lifecycle. Keyed by *logical*
32
+ * toolCallId (the id the host knows). Restored entries have no controller;
33
+ * active entries carry their stream id and rewrite/execution bookkeeping.
34
+ */
35
+ const entriesRef = useRef(new Map());
36
+ /**
37
+ * Reverse alias map populated only when a rewrite assigns a synthetic stream
38
+ * id to an entry. Identity mappings are implicit via the fallback in
39
+ * `getLogicalToolCallId`.
40
+ */
41
+ const streamToLogicalRef = useRef(new Map());
42
+ /**
43
+ * Stream ids whose `result` chunks must be dropped before reaching `onResult`.
44
+ * Populated when:
45
+ * - an argsText rewrite supersedes a stream (the old stream's result, if
46
+ * any, is no longer authoritative)
47
+ * - `reset()` is called while a pre-resolved tool call has a never-settling
48
+ * Promise pending in the executor — the eventual cancellation chunk
49
+ * would otherwise be forwarded to a host that has already moved on.
50
+ */
51
+ const abandonedStreamIdsRef = useRef(new Set());
52
+ /**
53
+ * Stream ids whose `execute` should be short-circuited in the tool wrapper.
54
+ * Tracked by physical stream id (not logical id) so cleanup is keyed off
55
+ * the same id the wrapper sees in its context.
56
+ */
57
+ const skipExecuteStreamIdsRef = useRef(new Set());
31
58
  const humanInputRef = useRef(new Map());
59
+ /**
60
+ * In-flight `execute` invocations keyed by physical stream id. Lives outside
61
+ * `entriesRef` so `reset()` can drop tool-call state without orphaning the
62
+ * cleanup the cancellation `onExecutionEnd` still needs.
63
+ */
64
+ const executingRef = useRef(new Map());
32
65
  const acRef = useRef(new AbortController());
33
66
  const executingCountRef = useRef(0);
34
- const startedExecutionToolCallIdsRef = useRef(new Set());
35
67
  const settledResolversRef = useRef([]);
36
- const toolCallIdAliasesRef = useRef(new Map());
37
- const ignoredResultToolCallIdsRef = useRef(new Set());
38
68
  const rewriteCounterRef = useRef(0);
39
- const getLogicalToolCallId = (toolCallId) => {
40
- return toolCallIdAliasesRef.current.get(toolCallId) ?? toolCallId;
41
- };
42
- const shouldIgnoreAndCleanupResult = (toolCallId) => {
43
- if (!ignoredResultToolCallIdsRef.current.has(toolCallId))
44
- return false;
45
- ignoredResultToolCallIdsRef.current.delete(toolCallId);
46
- toolCallIdAliasesRef.current.delete(toolCallId);
47
- return true;
48
- };
69
+ /**
70
+ * `true` until the first snapshot has been processed; `reset()` flips it
71
+ * back to `true`. Snapshots observed while this is `true` are treated as
72
+ * historical: their tool calls are recorded in `entriesRef` as restored
73
+ * but no streamCall/execute fires. The next snapshot is processed as live.
74
+ */
75
+ const pendingRestoreRef = useRef(true);
76
+ const getLogicalToolCallId = (streamId) => streamToLogicalRef.current.get(streamId) ?? streamId;
49
77
  const getWrappedTools = () => {
50
78
  const tools = getTools();
51
79
  if (!tools)
@@ -53,13 +81,27 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses,
53
81
  return Object.fromEntries(Object.entries(tools).map(([name, tool]) => {
54
82
  const execute = tool.execute;
55
83
  const streamCall = tool.streamCall;
84
+ const toModelOutput = tool.toModelOutput;
56
85
  const wrappedTool = {
57
86
  ...tool,
58
87
  ...(execute !== undefined && {
59
- execute: (...[args, context]) => execute(args, {
60
- ...context,
61
- toolCallId: getLogicalToolCallId(context.toolCallId),
62
- }),
88
+ execute: (...[args, context]) => {
89
+ if (skipExecuteStreamIdsRef.current.has(context.toolCallId)) {
90
+ // Pre-resolved on first live observation: never invoke the
91
+ // host's execute fn. Returning a never-settling Promise keeps
92
+ // the executor's pending entry alive but enqueues nothing.
93
+ // The membership in skipExecuteStreamIdsRef must outlive the
94
+ // wrapper call so `reset()`'s seeding loop (which reads this
95
+ // Set to identify pre-resolved entries needing cancellation
96
+ // suppression) sees the entry. Growth is bounded by the
97
+ // number of pre-resolved tool calls observed in the session.
98
+ return new Promise(() => { });
99
+ }
100
+ return execute(args, {
101
+ ...context,
102
+ toolCallId: getLogicalToolCallId(context.toolCallId),
103
+ });
104
+ },
63
105
  }),
64
106
  ...(streamCall !== undefined && {
65
107
  streamCall: (...[reader, context]) => streamCall(reader, {
@@ -67,16 +109,27 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses,
67
109
  toolCallId: getLogicalToolCallId(context.toolCallId),
68
110
  }),
69
111
  }),
112
+ ...(toModelOutput !== undefined && {
113
+ toModelOutput: (options) => toModelOutput({
114
+ ...options,
115
+ toolCallId: getLogicalToolCallId(options.toolCallId),
116
+ }),
117
+ }),
70
118
  };
71
119
  return [name, wrappedTool];
72
120
  }));
73
121
  };
122
+ const resolveAllSettledResolvers = () => {
123
+ const resolvers = settledResolversRef.current;
124
+ settledResolversRef.current = [];
125
+ // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return
126
+ resolvers.forEach((resolve) => resolve());
127
+ };
74
128
  const [controller] = useState(() => {
75
129
  const [stream, controller] = createAssistantStreamController();
76
130
  const transform = unstable_toolResultStream(getWrappedTools, () => acRef.current?.signal ?? new AbortController().signal, (toolCallId, payload) => {
77
131
  const logicalToolCallId = getLogicalToolCallId(toolCallId);
78
132
  return new Promise((resolve, reject) => {
79
- // Reject previous human input request if it exists
80
133
  const previous = humanInputRef.current.get(logicalToolCallId);
81
134
  if (previous) {
82
135
  previous.reject(new Error("Human input request was superseded by a new request"));
@@ -91,46 +144,38 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses,
91
144
  }));
92
145
  });
93
146
  }, {
94
- onExecutionStart: (toolCallId) => {
95
- if (ignoredResultToolCallIdsRef.current.has(toolCallId)) {
147
+ onExecutionStart: (streamId) => {
148
+ if (skipExecuteStreamIdsRef.current.has(streamId))
96
149
  return;
97
- }
98
- startedExecutionToolCallIdsRef.current.add(toolCallId);
99
- const logicalToolCallId = getLogicalToolCallId(toolCallId);
150
+ const logicalToolCallId = getLogicalToolCallId(streamId);
151
+ const abandoned = abandonedStreamIdsRef.current.has(streamId);
152
+ executingRef.current.set(streamId, {
153
+ logicalToolCallId,
154
+ abandoned,
155
+ });
100
156
  executingCountRef.current++;
101
- setToolStatuses((prev) => ({
102
- ...prev,
103
- [logicalToolCallId]: { type: "executing" },
104
- }));
105
- },
106
- onExecutionEnd: (toolCallId) => {
107
- const wasStarted = startedExecutionToolCallIdsRef.current.delete(toolCallId);
108
- if (ignoredResultToolCallIdsRef.current.has(toolCallId)) {
109
- if (wasStarted) {
110
- executingCountRef.current--;
111
- if (executingCountRef.current === 0) {
112
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return
113
- settledResolversRef.current.forEach((resolve) => resolve());
114
- settledResolversRef.current = [];
115
- }
116
- }
117
- return;
157
+ if (!abandoned) {
158
+ setToolStatuses((prev) => ({
159
+ ...prev,
160
+ [logicalToolCallId]: { type: "executing" },
161
+ }));
118
162
  }
119
- if (!wasStarted) {
163
+ },
164
+ onExecutionEnd: (streamId) => {
165
+ const info = executingRef.current.get(streamId);
166
+ if (!info)
120
167
  return;
121
- }
122
- const logicalToolCallId = getLogicalToolCallId(toolCallId);
168
+ executingRef.current.delete(streamId);
123
169
  executingCountRef.current--;
124
- setToolStatuses((prev) => {
125
- const next = { ...prev };
126
- delete next[logicalToolCallId];
127
- return next;
128
- });
129
- // Resolve any waiting abort promises when all tools have settled
170
+ if (!info.abandoned) {
171
+ setToolStatuses((prev) => {
172
+ const next = { ...prev };
173
+ delete next[info.logicalToolCallId];
174
+ return next;
175
+ });
176
+ }
130
177
  if (executingCountRef.current === 0) {
131
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return
132
- settledResolversRef.current.forEach((resolve) => resolve());
133
- settledResolversRef.current = [];
178
+ resolveAllSettledResolvers();
134
179
  }
135
180
  },
136
181
  });
@@ -139,47 +184,49 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses,
139
184
  .pipeThrough(new AssistantMetaTransformStream())
140
185
  .pipeTo(new WritableStream({
141
186
  write(chunk) {
142
- if (chunk.type === "result") {
143
- if (shouldIgnoreAndCleanupResult(chunk.meta.toolCallId)) {
144
- return;
145
- }
146
- const logicalToolCallId = getLogicalToolCallId(chunk.meta.toolCallId);
147
- if (logicalToolCallId !== chunk.meta.toolCallId) {
148
- toolCallIdAliasesRef.current.delete(chunk.meta.toolCallId);
149
- }
150
- // the tool call result was already set by the backend
151
- if (lastToolStates.current[logicalToolCallId]?.hasResult)
152
- return;
153
- onResult({
154
- type: "add-tool-result",
155
- toolCallId: logicalToolCallId,
156
- toolName: chunk.meta.toolName,
157
- result: chunk.result,
158
- isError: chunk.isError,
159
- ...(chunk.artifact && { artifact: chunk.artifact }),
160
- });
187
+ if (chunk.type !== "result")
188
+ return;
189
+ const streamId = chunk.meta.toolCallId;
190
+ const logicalToolCallId = getLogicalToolCallId(streamId);
191
+ const entry = entriesRef.current.get(logicalToolCallId);
192
+ // Result chunk from a rewrite-superseded stream: drop and clean
193
+ // up the alias.
194
+ if (abandonedStreamIdsRef.current.delete(streamId)) {
195
+ streamToLogicalRef.current.delete(streamId);
196
+ return;
197
+ }
198
+ // Pre-resolved tool call whose entry has been cleared by
199
+ // `reset()`. Both the real result chunk and the post-abort
200
+ // cancellation chunk can land here in either order; suppress
201
+ // both via the long-lived `skipExecuteStreamIdsRef` marker.
202
+ if (!entry && skipExecuteStreamIdsRef.current.has(streamId)) {
203
+ return;
161
204
  }
205
+ // The host already set the result (via the live snapshot's
206
+ // `setResponse` path). Suppress the executor's redundant emit.
207
+ if (entry?.hasResult)
208
+ return;
209
+ if (streamId !== logicalToolCallId) {
210
+ streamToLogicalRef.current.delete(streamId);
211
+ }
212
+ onResult({
213
+ type: "add-tool-result",
214
+ toolCallId: logicalToolCallId,
215
+ toolName: chunk.meta.toolName,
216
+ result: chunk.result,
217
+ isError: chunk.isError,
218
+ ...(chunk.artifact !== undefined && {
219
+ artifact: chunk.artifact,
220
+ }),
221
+ ...(chunk.modelContent !== undefined && {
222
+ modelContent: chunk.modelContent,
223
+ }),
224
+ });
162
225
  },
163
226
  }));
164
227
  return controller;
165
228
  });
166
- const ignoredToolIds = useRef(new Set());
167
- const isInitialState = useRef(true);
168
229
  useEffect(() => {
169
- const createToolState = ({ controller, streamToolCallId, }) => ({
170
- argsText: "",
171
- hasResult: false,
172
- argsComplete: false,
173
- streamToolCallId,
174
- controller,
175
- });
176
- const setToolState = (toolCallId, state) => {
177
- lastToolStates.current[toolCallId] = state;
178
- return state;
179
- };
180
- const patchToolState = (toolCallId, state, patch) => {
181
- return setToolState(toolCallId, { ...state, ...patch });
182
- };
183
230
  const hasExecutableTool = (toolName) => {
184
231
  const tool = getTools()?.[toolName];
185
232
  return tool?.execute !== undefined || tool?.streamCall !== undefined;
@@ -188,191 +235,210 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses,
188
235
  if (hasResult)
189
236
  return true;
190
237
  if (!hasExecutableTool(toolName)) {
191
- // Non-executable tools can emit parseable snapshots mid-stream.
192
- // Wait until the run settles before closing the args stream.
238
+ // Non-executable tools can emit parseable JSON mid-stream; wait for
239
+ // the run to settle before closing.
193
240
  return !state.isRunning && isArgsTextComplete(argsText);
194
241
  }
195
242
  return isArgsTextComplete(argsText);
196
243
  };
197
- const restartToolArgsStream = ({ toolCallId, toolName, state, }) => {
198
- ignoredResultToolCallIdsRef.current.add(state.streamToolCallId);
199
- state.controller.argsText.close();
200
- const streamToolCallId = `${toolCallId}:rewrite:${rewriteCounterRef.current++}`;
201
- toolCallIdAliasesRef.current.set(streamToolCallId, toolCallId);
244
+ const startActiveEntry = (toolCallId, toolName, skipExecute) => {
202
245
  const toolCallController = controller.addToolCallPart({
203
246
  toolName,
204
- toolCallId: streamToolCallId,
247
+ toolCallId,
248
+ });
249
+ if (skipExecute) {
250
+ skipExecuteStreamIdsRef.current.add(toolCallId);
251
+ }
252
+ const entry = {
253
+ toolName,
254
+ controller: toolCallController,
255
+ streamId: toolCallId,
256
+ argsText: "",
257
+ hasResult: false,
258
+ argsComplete: false,
259
+ };
260
+ entriesRef.current.set(toolCallId, entry);
261
+ return entry;
262
+ };
263
+ const restartArgsStream = (entry, toolCallId) => {
264
+ if (!entry.controller)
265
+ return;
266
+ abandonedStreamIdsRef.current.add(entry.streamId);
267
+ // The wrapper's execute short-circuit follows the current stream id;
268
+ // the abandoned id stays in `skipExecuteStreamIdsRef` if it was there,
269
+ // which is harmless and keeps in-flight chunks consistent.
270
+ const wasSkipExecute = skipExecuteStreamIdsRef.current.has(entry.streamId);
271
+ entry.controller.argsText.close();
272
+ const newStreamId = `${toolCallId}:rewrite:${rewriteCounterRef.current++}`;
273
+ streamToLogicalRef.current.set(newStreamId, toolCallId);
274
+ const newController = controller.addToolCallPart({
275
+ toolName: entry.toolName,
276
+ toolCallId: newStreamId,
205
277
  });
278
+ if (wasSkipExecute) {
279
+ skipExecuteStreamIdsRef.current.add(newStreamId);
280
+ }
206
281
  if (process.env.NODE_ENV !== "production") {
207
282
  console.warn("started replacement stream tool call", {
208
283
  toolCallId,
209
- streamToolCallId,
284
+ streamToolCallId: newStreamId,
210
285
  });
211
286
  }
212
- return setToolState(toolCallId, {
213
- ...createToolState({
214
- controller: toolCallController,
215
- streamToolCallId,
216
- }),
217
- hasResult: state.hasResult,
218
- });
287
+ entry.controller = newController;
288
+ entry.streamId = newStreamId;
289
+ entry.argsText = "";
290
+ entry.argsComplete = false;
291
+ };
292
+ const processArgsText = (entry, content) => {
293
+ if (!entry.controller)
294
+ return;
295
+ const hasResult = content.result !== undefined;
296
+ if (content.argsText !== entry.argsText) {
297
+ let shouldWriteArgsText = true;
298
+ if (entry.argsComplete) {
299
+ if (isEquivalentCompleteArgsText(entry.argsText, content.argsText)) {
300
+ entry.argsText = content.argsText;
301
+ shouldWriteArgsText = false;
302
+ }
303
+ else {
304
+ const canRestart = !entry.hasResult && !executingRef.current.has(entry.streamId);
305
+ if (process.env.NODE_ENV !== "production") {
306
+ console.warn(canRestart
307
+ ? "argsText updated after controller was closed, restarting tool args stream:"
308
+ : "argsText updated after controller was closed:", { previous: entry.argsText, next: content.argsText });
309
+ }
310
+ if (!canRestart) {
311
+ entry.argsText = content.argsText;
312
+ shouldWriteArgsText = false;
313
+ }
314
+ else {
315
+ restartArgsStream(entry, content.toolCallId);
316
+ }
317
+ }
318
+ }
319
+ else if (!content.argsText.startsWith(entry.argsText)) {
320
+ // Mid-stream rewrite. If both texts parse to equivalent JSON it's a
321
+ // key-reorder snapshot — accept silently. Otherwise restart.
322
+ if (isArgsTextComplete(entry.argsText) &&
323
+ isArgsTextComplete(content.argsText) &&
324
+ isEquivalentCompleteArgsText(entry.argsText, content.argsText)) {
325
+ const shouldClose = shouldCloseArgsStream({
326
+ toolName: content.toolName,
327
+ argsText: content.argsText,
328
+ hasResult,
329
+ });
330
+ if (shouldClose)
331
+ entry.controller.argsText.close();
332
+ entry.argsText = content.argsText;
333
+ entry.argsComplete = shouldClose;
334
+ shouldWriteArgsText = false;
335
+ }
336
+ else {
337
+ if (process.env.NODE_ENV !== "production") {
338
+ console.warn("argsText rewrote previous snapshot, restarting tool args stream:", {
339
+ previous: entry.argsText,
340
+ next: content.argsText,
341
+ toolCallId: content.toolCallId,
342
+ });
343
+ }
344
+ restartArgsStream(entry, content.toolCallId);
345
+ }
346
+ }
347
+ if (shouldWriteArgsText) {
348
+ const delta = content.argsText.slice(entry.argsText.length);
349
+ entry.controller.argsText.append(delta);
350
+ const shouldClose = shouldCloseArgsStream({
351
+ toolName: content.toolName,
352
+ argsText: content.argsText,
353
+ hasResult,
354
+ });
355
+ if (shouldClose)
356
+ entry.controller.argsText.close();
357
+ entry.argsText = content.argsText;
358
+ entry.argsComplete = shouldClose;
359
+ }
360
+ }
361
+ if (!entry.argsComplete) {
362
+ const shouldClose = shouldCloseArgsStream({
363
+ toolName: content.toolName,
364
+ argsText: content.argsText,
365
+ hasResult,
366
+ });
367
+ if (shouldClose) {
368
+ entry.controller.argsText.close();
369
+ entry.argsText = content.argsText;
370
+ entry.argsComplete = true;
371
+ }
372
+ }
219
373
  };
220
374
  const processMessages = (messages) => {
375
+ const isRestore = pendingRestoreRef.current;
221
376
  messages.forEach((message) => {
222
377
  message.content.forEach((content) => {
223
- if (content.type === "tool-call") {
224
- if (isInitialState.current) {
225
- ignoredToolIds.current.add(content.toolCallId);
226
- }
227
- else {
228
- if (ignoredToolIds.current.has(content.toolCallId)) {
229
- return;
230
- }
231
- let lastState = lastToolStates.current[content.toolCallId];
232
- if (!lastState) {
233
- if (content.result !== undefined) {
234
- if (content.messages) {
235
- processMessages(content.messages);
236
- }
237
- return;
238
- }
239
- toolCallIdAliasesRef.current.set(content.toolCallId, content.toolCallId);
240
- const toolCallController = controller.addToolCallPart({
241
- toolName: content.toolName,
242
- toolCallId: content.toolCallId,
243
- });
244
- lastState = setToolState(content.toolCallId, createToolState({
245
- controller: toolCallController,
246
- streamToolCallId: content.toolCallId,
247
- }));
248
- }
249
- if (content.argsText !== lastState.argsText) {
250
- let shouldWriteArgsText = true;
251
- if (lastState.argsComplete) {
252
- if (isEquivalentCompleteArgsText(lastState.argsText, content.argsText)) {
253
- lastState = patchToolState(content.toolCallId, lastState, {
254
- argsText: content.argsText,
255
- });
256
- shouldWriteArgsText = false;
257
- }
258
- if (shouldWriteArgsText) {
259
- const canRestartClosedArgsStream = !lastState.hasResult &&
260
- !startedExecutionToolCallIdsRef.current.has(lastState.streamToolCallId);
261
- if (process.env.NODE_ENV !== "production") {
262
- console.warn(canRestartClosedArgsStream
263
- ? "argsText updated after controller was closed, restarting tool args stream:"
264
- : "argsText updated after controller was closed:", {
265
- previous: lastState.argsText,
266
- next: content.argsText,
267
- });
268
- }
269
- if (!canRestartClosedArgsStream) {
270
- lastState = patchToolState(content.toolCallId, lastState, {
271
- argsText: content.argsText,
272
- });
273
- shouldWriteArgsText = false;
274
- }
275
- }
276
- if (shouldWriteArgsText) {
277
- lastState = restartToolArgsStream({
278
- toolCallId: content.toolCallId,
279
- toolName: content.toolName,
280
- state: lastState,
281
- });
282
- }
283
- }
284
- else if (!content.argsText.startsWith(lastState.argsText)) {
285
- // Check if this is key reordering (both are complete JSON)
286
- // This happens when transitioning from streaming to complete state
287
- // and the provider returns keys in a different order
288
- if (isArgsTextComplete(lastState.argsText) &&
289
- isArgsTextComplete(content.argsText) &&
290
- isEquivalentCompleteArgsText(lastState.argsText, content.argsText)) {
291
- const shouldClose = shouldCloseArgsStream({
292
- toolName: content.toolName,
293
- argsText: content.argsText,
294
- hasResult: content.result !== undefined,
295
- });
296
- if (shouldClose) {
297
- lastState.controller.argsText.close();
298
- }
299
- lastState = patchToolState(content.toolCallId, lastState, {
300
- argsText: content.argsText,
301
- argsComplete: shouldClose,
302
- });
303
- shouldWriteArgsText = false;
304
- }
305
- if (shouldWriteArgsText) {
306
- if (process.env.NODE_ENV !== "production") {
307
- console.warn("argsText rewrote previous snapshot, restarting tool args stream:", {
308
- previous: lastState.argsText,
309
- next: content.argsText,
310
- toolCallId: content.toolCallId,
311
- });
312
- }
313
- lastState = restartToolArgsStream({
314
- toolCallId: content.toolCallId,
315
- toolName: content.toolName,
316
- state: lastState,
317
- });
318
- }
319
- }
320
- if (shouldWriteArgsText) {
321
- const argsTextDelta = content.argsText.slice(lastState.argsText.length);
322
- lastState.controller.argsText.append(argsTextDelta);
323
- const shouldClose = shouldCloseArgsStream({
324
- toolName: content.toolName,
325
- argsText: content.argsText,
326
- hasResult: content.result !== undefined,
327
- });
328
- if (shouldClose) {
329
- lastState.controller.argsText.close();
330
- }
331
- lastState = patchToolState(content.toolCallId, lastState, {
332
- argsText: content.argsText,
333
- argsComplete: shouldClose,
334
- });
335
- }
336
- }
337
- if (!lastState.argsComplete) {
338
- const shouldClose = shouldCloseArgsStream({
339
- toolName: content.toolName,
340
- argsText: content.argsText,
341
- hasResult: content.result !== undefined,
342
- });
343
- if (shouldClose) {
344
- lastState.controller.argsText.close();
345
- lastState = patchToolState(content.toolCallId, lastState, {
346
- argsText: content.argsText,
347
- argsComplete: true,
348
- });
349
- }
350
- }
351
- if (content.result !== undefined && !lastState.hasResult) {
352
- patchToolState(content.toolCallId, lastState, {
353
- hasResult: true,
354
- argsComplete: true,
355
- });
356
- lastState.controller.setResponse(new ToolResponse({
357
- result: content.result,
358
- artifact: content.artifact,
359
- isError: content.isError,
360
- }));
361
- lastState.controller.close();
362
- }
378
+ if (content.type !== "tool-call")
379
+ return;
380
+ const existing = entriesRef.current.get(content.toolCallId);
381
+ if (isRestore) {
382
+ // Don't overwrite an already-active entry (e.g. live tool-call
383
+ // observed before this restore snapshot landed). Restore can only
384
+ // seed entries the runtime has never seen.
385
+ if (!existing?.controller) {
386
+ entriesRef.current.set(content.toolCallId, {
387
+ toolName: content.toolName,
388
+ argsText: content.argsText,
389
+ hasResult: content.result !== undefined,
390
+ });
363
391
  }
364
- // Recursively process nested messages
365
- if (content.messages) {
392
+ if (content.messages)
366
393
  processMessages(content.messages);
394
+ return;
395
+ }
396
+ // Live snapshot.
397
+ let entry = existing;
398
+ if (entry && !entry.controller) {
399
+ // Restored entry observed in a live snapshot. Promote if its
400
+ // signature has changed; otherwise treat as still-historical.
401
+ const signatureChanged = content.argsText !== entry.argsText ||
402
+ (content.result !== undefined) !== entry.hasResult;
403
+ if (!signatureChanged) {
404
+ if (content.messages)
405
+ processMessages(content.messages);
406
+ return;
367
407
  }
408
+ entriesRef.current.delete(content.toolCallId);
409
+ entry = undefined;
410
+ }
411
+ if (!entry) {
412
+ entry = startActiveEntry(content.toolCallId, content.toolName, content.result !== undefined);
368
413
  }
414
+ processArgsText(entry, content);
415
+ if (content.result !== undefined && !entry.hasResult) {
416
+ // `entry` is in active phase from this point — either it was
417
+ // just created by `startActiveEntry` above, or it pre-existed
418
+ // and `processArgsText` preserved (or replaced via rewrite) its
419
+ // controller. Narrow once instead of asserting at every use.
420
+ const { controller: activeController } = entry;
421
+ if (!activeController)
422
+ return;
423
+ entry.hasResult = true;
424
+ entry.argsComplete = true;
425
+ activeController.setResponse(new ToolResponse({
426
+ result: content.result,
427
+ artifact: content.artifact,
428
+ isError: content.isError,
429
+ ...(content.modelContent !== undefined
430
+ ? { modelContent: content.modelContent }
431
+ : {}),
432
+ }));
433
+ activeController.close();
434
+ }
435
+ if (content.messages)
436
+ processMessages(content.messages);
369
437
  });
370
438
  });
371
439
  };
372
440
  processMessages(state.messages);
373
- if (isInitialState.current) {
374
- isInitialState.current = false;
375
- }
441
+ pendingRestoreRef.current = false;
376
442
  }, [state, controller, getTools]);
377
443
  const abort = () => {
378
444
  humanInputRef.current.forEach(({ reject }) => {
@@ -381,7 +447,6 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses,
381
447
  humanInputRef.current.clear();
382
448
  acRef.current.abort();
383
449
  acRef.current = new AbortController();
384
- // Return a promise that resolves when all executing tools have settled
385
450
  if (executingCountRef.current === 0) {
386
451
  return Promise.resolve();
387
452
  }
@@ -391,13 +456,17 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses,
391
456
  };
392
457
  return {
393
458
  reset: () => {
394
- isInitialState.current = true;
395
- ignoredToolIds.current.clear();
396
- lastToolStates.current = {};
459
+ pendingRestoreRef.current = true;
460
+ entriesRef.current.clear();
461
+ // `skipExecuteStreamIdsRef` is not cleared: it has to outlive `reset()`
462
+ // so (a) any wrapper call still inbound through the stream pipeline
463
+ // continues to short-circuit `execute`, and (b) the consumer can
464
+ // recognize and drop any post-abort cancellation `result` chunks for
465
+ // pre-resolved streams whose entries have been cleared. Membership
466
+ // grows by one per pre-resolved tool call observed in the session.
397
467
  void abort().finally(() => {
398
- startedExecutionToolCallIdsRef.current.clear();
399
- toolCallIdAliasesRef.current.clear();
400
- ignoredResultToolCallIdsRef.current.clear();
468
+ executingRef.current.clear();
469
+ streamToLogicalRef.current.clear();
401
470
  rewriteCounterRef.current = 0;
402
471
  });
403
472
  },