@assistant-ui/core 0.2.6 → 0.2.8

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 (192) hide show
  1. package/dist/adapters/attachment.d.ts.map +1 -1
  2. package/dist/adapters/speech.d.ts.map +1 -1
  3. package/dist/adapters/speech.js.map +1 -1
  4. package/dist/index.d.ts +4 -1
  5. package/dist/index.js +8 -1
  6. package/dist/index.js.map +1 -0
  7. package/dist/internal/duplicate-detection.d.ts +5 -0
  8. package/dist/internal/duplicate-detection.d.ts.map +1 -0
  9. package/dist/internal/duplicate-detection.js +11 -0
  10. package/dist/internal/duplicate-detection.js.map +1 -0
  11. package/dist/internal.d.ts +2 -2
  12. package/dist/internal.js +2 -2
  13. package/dist/model-context/frame/host.d.ts.map +1 -1
  14. package/dist/model-context/frame/host.js.map +1 -1
  15. package/dist/model-context/frame/provider.d.ts.map +1 -1
  16. package/dist/model-context/frame/provider.js.map +1 -1
  17. package/dist/model-context/registry.d.ts.map +1 -1
  18. package/dist/model-context/tool.d.ts.map +1 -1
  19. package/dist/react/AssistantProvider.d.ts.map +1 -1
  20. package/dist/react/AssistantProvider.js.map +1 -1
  21. package/dist/react/client/Interactables.js.map +1 -1
  22. package/dist/react/client/Tools.d.ts.map +1 -1
  23. package/dist/react/client/Tools.js +26 -15
  24. package/dist/react/client/Tools.js.map +1 -1
  25. package/dist/react/index.d.ts +5 -4
  26. package/dist/react/index.js +2 -2
  27. package/dist/react/model-context/toolbox.d.ts +29 -2
  28. package/dist/react/model-context/toolbox.d.ts.map +1 -1
  29. package/dist/react/model-context/toolbox.js +18 -0
  30. package/dist/react/model-context/toolbox.js.map +1 -0
  31. package/dist/react/model-context/useAssistantTool.d.ts.map +1 -1
  32. package/dist/react/model-context/useAssistantTool.js +6 -3
  33. package/dist/react/model-context/useAssistantTool.js.map +1 -1
  34. package/dist/react/model-context/useAssistantToolUI.d.ts +6 -0
  35. package/dist/react/model-context/useAssistantToolUI.d.ts.map +1 -1
  36. package/dist/react/model-context/useAssistantToolUI.js +4 -2
  37. package/dist/react/model-context/useAssistantToolUI.js.map +1 -1
  38. package/dist/react/model-context/useInlineRender.js.map +1 -1
  39. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
  40. package/dist/react/primitives/message/MessageGroupedParts.d.ts +49 -7
  41. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
  42. package/dist/react/primitives/message/MessageGroupedParts.js +28 -3
  43. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  44. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  45. package/dist/react/primitives/message/MessageParts.js +2 -7
  46. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  47. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  48. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  49. package/dist/react/runtimes/RuntimeAdapterProvider.d.ts.map +1 -1
  50. package/dist/react/runtimes/RuntimeAdapterProvider.js +6 -5
  51. package/dist/react/runtimes/RuntimeAdapterProvider.js.map +1 -1
  52. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
  53. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  54. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  55. package/dist/react/runtimes/external-message-converter.d.ts +1 -1
  56. package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
  57. package/dist/react/runtimes/external-message-converter.js +1 -0
  58. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  59. package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts +7 -0
  60. package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts.map +1 -0
  61. package/dist/react/runtimes/useExternalStoreSharedOptions.js +21 -0
  62. package/dist/react/runtimes/useExternalStoreSharedOptions.js.map +1 -0
  63. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  64. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  65. package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
  66. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  67. package/dist/react/types/scopes/tools.d.ts +19 -2
  68. package/dist/react/types/scopes/tools.d.ts.map +1 -1
  69. package/dist/react/utils/groupParts.d.ts +32 -11
  70. package/dist/react/utils/groupParts.d.ts.map +1 -1
  71. package/dist/react/utils/groupParts.js +13 -6
  72. package/dist/react/utils/groupParts.js.map +1 -1
  73. package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
  74. package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
  75. package/dist/runtime/api/attachment-runtime.js.map +1 -1
  76. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  77. package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
  78. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  79. package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
  80. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  81. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  82. package/dist/runtime/base/base-assistant-runtime-core.d.ts.map +1 -1
  83. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  84. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  85. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  86. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  87. package/dist/runtime/interfaces/thread-runtime-core.d.ts +8 -0
  88. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  89. package/dist/runtime/utils/message-repository.d.ts +9 -1
  90. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  91. package/dist/runtime/utils/message-repository.js +34 -14
  92. package/dist/runtime/utils/message-repository.js.map +1 -1
  93. package/dist/runtime/utils/thread-message-like.d.ts +1 -0
  94. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  95. package/dist/runtime/utils/thread-message-like.js +2 -1
  96. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  97. package/dist/runtimes/external-store/external-store-adapter.d.ts +31 -0
  98. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  99. package/dist/runtimes/external-store/external-store-shared-options.d.ts +8 -0
  100. package/dist/runtimes/external-store/external-store-shared-options.d.ts.map +1 -0
  101. package/dist/runtimes/external-store/external-store-shared-options.js +11 -0
  102. package/dist/runtimes/external-store/external-store-shared-options.js.map +1 -0
  103. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  104. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  105. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +25 -2
  106. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  107. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +106 -26
  108. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  109. package/dist/runtimes/external-store/thread-message-converter.d.ts.map +1 -1
  110. package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
  111. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  112. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  113. package/dist/runtimes/remote-thread-list/adapter/in-memory.d.ts.map +1 -1
  114. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  115. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts +168 -0
  116. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -0
  117. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +449 -0
  118. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -0
  119. package/dist/subscribable/subscribable.d.ts.map +1 -1
  120. package/dist/subscribable/subscribable.js.map +1 -1
  121. package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
  122. package/dist/types/message.d.ts +6 -0
  123. package/dist/types/message.d.ts.map +1 -1
  124. package/dist/types/message.js.map +1 -1
  125. package/dist/utils/composite-context-provider.d.ts.map +1 -1
  126. package/dist/utils/id.d.ts +1 -3
  127. package/dist/utils/id.d.ts.map +1 -1
  128. package/dist/utils/id.js +1 -4
  129. package/dist/utils/id.js.map +1 -1
  130. package/package.json +10 -10
  131. package/src/adapters/index.ts +1 -4
  132. package/src/adapters/speech.ts +0 -1
  133. package/src/index.ts +12 -0
  134. package/src/internal/duplicate-detection.ts +26 -0
  135. package/src/internal.ts +0 -2
  136. package/src/model-context/frame/host.ts +0 -1
  137. package/src/model-context/frame/provider.ts +0 -1
  138. package/src/react/AssistantProvider.tsx +2 -3
  139. package/src/react/client/Interactables.ts +0 -1
  140. package/src/react/client/Tools.ts +50 -25
  141. package/src/react/index.ts +9 -8
  142. package/src/react/model-context/toolbox.ts +46 -1
  143. package/src/react/model-context/useAssistantTool.ts +8 -3
  144. package/src/react/model-context/useAssistantToolUI.ts +9 -2
  145. package/src/react/model-context/useInlineRender.ts +0 -1
  146. package/src/react/primitives/chainOfThought/ChainOfThoughtParts.tsx +1 -2
  147. package/src/react/primitives/message/MessageAttachments.test.tsx +1 -1
  148. package/src/react/primitives/message/MessageGroupedParts.tsx +102 -13
  149. package/src/react/primitives/message/MessageParts.tsx +4 -7
  150. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +0 -3
  151. package/src/react/runtimes/RuntimeAdapterProvider.tsx +12 -7
  152. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +0 -3
  153. package/src/react/runtimes/external-message-converter.ts +5 -1
  154. package/src/react/runtimes/useExternalStoreSharedOptions.ts +23 -0
  155. package/src/react/runtimes/useLocalRuntime.ts +0 -10
  156. package/src/react/runtimes/useRemoteThreadListRuntime.ts +0 -6
  157. package/src/react/types/scopes/tools.ts +20 -1
  158. package/src/react/utils/groupParts.ts +49 -18
  159. package/src/runtime/api/attachment-runtime.ts +1 -2
  160. package/src/runtime/interfaces/thread-runtime-core.ts +8 -0
  161. package/src/runtime/internal.ts +1 -4
  162. package/src/runtime/utils/message-repository.ts +57 -16
  163. package/src/runtime/utils/thread-message-like.ts +2 -0
  164. package/src/runtimes/external-store/external-store-adapter.ts +33 -0
  165. package/src/runtimes/external-store/external-store-shared-options.ts +18 -0
  166. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +1 -3
  167. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +179 -37
  168. package/src/runtimes/tool-invocations/EDGE_CASES.md +194 -0
  169. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +1054 -0
  170. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +782 -0
  171. package/src/subscribable/subscribable.ts +3 -3
  172. package/src/tests/MessageRepository.test.ts +83 -52
  173. package/src/tests/OptimisticState-delete-crash.test.ts +2 -0
  174. package/src/tests/OptimisticState-list-race.test.ts +2 -4
  175. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +5 -5
  176. package/src/tests/auiV0Encode.test.ts +1 -1
  177. package/src/tests/composer-can-send.test.ts +8 -4
  178. package/src/tests/duplicate-detection.test.ts +34 -0
  179. package/src/tests/external-store-thread-list-runtime-core.test.ts +1 -1
  180. package/src/tests/external-store-thread-runtime-core.test.ts +112 -79
  181. package/src/tests/groupParts.test.ts +70 -0
  182. package/src/tests/no-unsafe-process-env.test.ts +1 -0
  183. package/src/tests/remote-thread-list-isLoading.test.ts +2 -5
  184. package/src/tests/thread-message-like.test.ts +4 -1
  185. package/src/types/index.ts +1 -4
  186. package/src/types/message.ts +6 -0
  187. package/src/utils/id.ts +0 -4
  188. package/dist/react/runtimes/useToolInvocations.d.ts +0 -53
  189. package/dist/react/runtimes/useToolInvocations.d.ts.map +0 -1
  190. package/dist/react/runtimes/useToolInvocations.js +0 -380
  191. package/dist/react/runtimes/useToolInvocations.js.map +0 -1
  192. package/src/react/runtimes/useToolInvocations.ts +0 -694
@@ -1,6 +1,6 @@
1
1
  import type { ThreadMessage } from "../../types/message";
2
2
  import type { RunConfig } from "../../types/message";
3
- import { generateId, generateOptimisticId } from "../../utils/id";
3
+ import { generateId } from "../../utils/id";
4
4
  import type { ThreadMessageLike } from "./thread-message-like";
5
5
  import { getAutoStatus } from "./auto-status";
6
6
  import { fromThreadMessageLike } from "./thread-message-like";
@@ -259,20 +259,6 @@ export class MessageRepository {
259
259
  };
260
260
  }
261
261
 
262
- appendOptimisticMessage(parentId: string | null, message: ThreadMessageLike) {
263
- let optimisticId: string;
264
- do {
265
- optimisticId = generateOptimisticId();
266
- } while (this.messages.has(optimisticId));
267
-
268
- this.addOrUpdateMessage(
269
- parentId,
270
- fromThreadMessageLike(message, optimisticId, { type: "running" }),
271
- );
272
-
273
- return optimisticId;
274
- }
275
-
276
262
  deleteMessage(messageId: string, replacementId?: string | null | undefined) {
277
263
  const message = this.messages.get(messageId);
278
264
 
@@ -322,6 +308,45 @@ export class MessageRepository {
322
308
  return children;
323
309
  }
324
310
 
311
+ /**
312
+ * Evicts optimistic messages (`metadata.isOptimistic`) the head just moved
313
+ * away from. Since eviction runs on every head move, the only optimistic
314
+ * messages in the repository live on the branch the head previously pointed
315
+ * at — so we walk just that branch rather than the whole repository. Keeps a
316
+ * client→server id swap from leaving a phantom sibling, and drops off-branch
317
+ * placeholders.
318
+ */
319
+ private evictOffBranchOptimisticMessages(
320
+ previousHead: RepositoryMessage | null,
321
+ currentHead: RepositoryMessage | null,
322
+ ) {
323
+ if (!previousHead) return;
324
+
325
+ const onHeadBranch = new Set<string>();
326
+ for (let current = currentHead; current; current = current.prev) {
327
+ onHeadBranch.add(current.current.id);
328
+ }
329
+
330
+ const stale: string[] = [];
331
+ for (
332
+ let current: RepositoryMessage | null = previousHead;
333
+ current;
334
+ current = current.prev
335
+ ) {
336
+ // Stop at the first node shared with the current head branch: every
337
+ // ancestor above it is shared too, so nothing further can be off-branch.
338
+ if (onHeadBranch.has(current.current.id)) break;
339
+ if (current.current.metadata?.isOptimistic) {
340
+ stale.push(current.current.id);
341
+ }
342
+ }
343
+
344
+ for (const id of stale) {
345
+ // A prior deletion may have already removed this node.
346
+ if (this.messages.has(id)) this.deleteMessage(id);
347
+ }
348
+ }
349
+
325
350
  switchToBranch(messageId: string) {
326
351
  const message = this.messages.get(messageId);
327
352
  if (!message)
@@ -329,11 +354,14 @@ export class MessageRepository {
329
354
  "MessageRepository(switchToBranch): Branch not found. This is likely an internal bug in assistant-ui.",
330
355
  );
331
356
 
357
+ const previousHead = this.head;
332
358
  const prevOrRoot = message.prev ?? this.root;
333
359
  prevOrRoot.next = message;
334
360
 
335
361
  this.head = findHead(message);
336
362
 
363
+ this.evictOffBranchOptimisticMessages(previousHead, this.head);
364
+
337
365
  this._messages.dirty();
338
366
  }
339
367
 
@@ -349,6 +377,8 @@ export class MessageRepository {
349
377
  "MessageRepository(resetHead): Branch not found. This is likely an internal bug in assistant-ui.",
350
378
  );
351
379
 
380
+ const previousHead = this.head;
381
+
352
382
  if (message.children.length > 0) {
353
383
  const deleteDescendants = (msg: RepositoryMessage) => {
354
384
  for (const childId of msg.children) {
@@ -378,6 +408,8 @@ export class MessageRepository {
378
408
  }
379
409
  }
380
410
 
411
+ this.evictOffBranchOptimisticMessages(previousHead, this.head);
412
+
381
413
  this._messages.dirty();
382
414
  }
383
415
 
@@ -394,15 +426,24 @@ export class MessageRepository {
394
426
  export(): ExportedMessageRepository {
395
427
  const exportItems: ExportedMessageRepository["messages"] = [];
396
428
 
429
+ // Optimistic messages are ephemeral and never persisted. They're always
430
+ // leaf nodes, so skipping them can't orphan a persisted child.
397
431
  for (const [, message] of this.messages) {
432
+ if (message.current.metadata?.isOptimistic) continue;
398
433
  exportItems.push({
399
434
  message: message.current,
400
435
  parentId: message.prev?.current.id ?? null,
401
436
  });
402
437
  }
403
438
 
439
+ // The head may itself be optimistic; walk up to the nearest persisted ancestor.
440
+ let head = this.head;
441
+ while (head?.current.metadata?.isOptimistic) {
442
+ head = head.prev;
443
+ }
444
+
404
445
  return {
405
- headId: this.head?.current.id ?? null,
446
+ headId: head?.current.id ?? null,
406
447
  messages: exportItems,
407
448
  };
408
449
  }
@@ -81,6 +81,7 @@ export type ThreadMessageLike = {
81
81
  readonly steps?: readonly ThreadStep[] | undefined;
82
82
  readonly timing?: MessageTiming | undefined;
83
83
  readonly submittedFeedback?: { readonly type: "positive" | "negative" };
84
+ readonly isOptimistic?: boolean | undefined;
84
85
  readonly custom?: Record<string, unknown> | undefined;
85
86
  }
86
87
  | undefined;
@@ -210,6 +211,7 @@ export const fromThreadMessageLike = (
210
211
  ...(metadata?.submittedFeedback && {
211
212
  submittedFeedback: metadata.submittedFeedback,
212
213
  }),
214
+ ...(metadata?.isOptimistic && { isOptimistic: true }),
213
215
  },
214
216
  } satisfies ThreadAssistantMessage;
215
217
 
@@ -16,6 +16,7 @@ import type {
16
16
  } from "../../runtime/interfaces/thread-runtime-core";
17
17
  import type { ExportedMessageRepository } from "../../runtime/utils/message-repository";
18
18
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
19
+ import type { ToolExecutionStatus } from "../tool-invocations/ToolInvocationTracker";
19
20
 
20
21
  export type ExternalStoreThreadData<TState extends "regular" | "archived"> = {
21
22
  status: TState;
@@ -131,6 +132,38 @@ type ExternalStoreAdapterBase<T> = {
131
132
  copy?: boolean | undefined;
132
133
  }
133
134
  | undefined;
135
+ /**
136
+ * Opt in to the built-in client-side tool-invocations pipeline
137
+ * (`streamCall` / `execute` / tool-status tracking) for this thread.
138
+ *
139
+ * Defaults to `false` — the runtime does *not* drive client-side tool
140
+ * callbacks on its own. Set to `true` to have the runtime construct a
141
+ * `ToolInvocationTracker` and feed every snapshot through it, so tool
142
+ * callbacks fire automatically for tool-call parts in `messages`.
143
+ *
144
+ * Opt-in by default because most external-store runtimes either run
145
+ * tools entirely server-side, or already wire their own client-side
146
+ * dispatch path. Enabling the embedded tracker on top of an existing
147
+ * dispatch path would cause tool callbacks to run twice.
148
+ *
149
+ * When enabled, client-side tool results (from `execute()` returning,
150
+ * or from `streamCall` resolving) flow back through
151
+ * `adapter.onAddToolResult` like any other tool result, with
152
+ * `modelContent` populated when present.
153
+ */
154
+ unstable_enableToolInvocations?: boolean | undefined;
155
+ /**
156
+ * Receives the current per-tool-call execution status map whenever it
157
+ * changes. Only invoked when `unstable_enableToolInvocations` is `true`
158
+ * — the runtime maintains the map via the embedded tracker.
159
+ *
160
+ * Wire this into local React state and feed it into the converter's
161
+ * `metadata.toolStatuses` so the UI can render `executing` spinners
162
+ * and human-input prompts.
163
+ */
164
+ setToolStatuses?:
165
+ | ((statuses: Record<string, ToolExecutionStatus>) => void)
166
+ | undefined;
134
167
  };
135
168
 
136
169
  export type ExternalStoreAdapter<T = ThreadMessage> =
@@ -0,0 +1,18 @@
1
+ import type { ExternalStoreAdapter } from "./external-store-adapter";
2
+
3
+ export type ExternalStoreSharedOptions = Pick<
4
+ ExternalStoreAdapter,
5
+ "isDisabled" | "isSendDisabled" | "unstable_capabilities" | "suggestions"
6
+ >;
7
+
8
+ export const pickExternalStoreSharedOptions = (
9
+ options: ExternalStoreSharedOptions,
10
+ ): ExternalStoreSharedOptions =>
11
+ ({
12
+ isDisabled: options.isDisabled,
13
+ isSendDisabled: options.isSendDisabled,
14
+ unstable_capabilities: options.unstable_capabilities,
15
+ suggestions: options.suggestions,
16
+ }) satisfies {
17
+ [K in keyof Required<ExternalStoreSharedOptions>]: ExternalStoreSharedOptions[K];
18
+ };
@@ -22,9 +22,7 @@ const DEFAULT_THREAD_DATA = Object.freeze({
22
22
  [DEFAULT_THREAD_ID]: DEFAULT_THREAD,
23
23
  });
24
24
 
25
- export class ExternalStoreThreadListRuntimeCore
26
- implements ThreadListRuntimeCore
27
- {
25
+ export class ExternalStoreThreadListRuntimeCore implements ThreadListRuntimeCore {
28
26
  private _mainThreadId: string = DEFAULT_THREAD_ID;
29
27
  private _threads: readonly string[] = DEFAULT_THREADS;
30
28
  private _archivedThreads: readonly string[] = EMPTY_ARRAY;
@@ -30,6 +30,8 @@ import {
30
30
  ExportedMessageRepository,
31
31
  MessageRepository,
32
32
  } from "../../runtime/utils/message-repository";
33
+ import { generateId } from "../../utils/id";
34
+ import { ToolInvocationTracker } from "../tool-invocations/ToolInvocationTracker";
33
35
 
34
36
  const EMPTY_ARRAY: readonly ThreadSuggestion[] = Object.freeze([]);
35
37
 
@@ -53,9 +55,6 @@ export class ExternalStoreThreadRuntimeCore
53
55
  extends BaseThreadRuntimeCore
54
56
  implements ThreadRuntimeCore
55
57
  {
56
- private _assistantOptimisticId: string | null = null;
57
- private _lastSyncedMessageIds = new Set<string>();
58
-
59
58
  private _capabilities: RuntimeCapabilities = {
60
59
  switchToBranch: false,
61
60
  switchBranchDuringRun: false,
@@ -105,6 +104,12 @@ export class ExternalStoreThreadRuntimeCore
105
104
 
106
105
  private _store!: ExternalStoreAdapter<any>;
107
106
 
107
+ /**
108
+ * Client-side tool-invocations pipeline. Constructed lazily on first
109
+ * snapshot — only when `adapter.unstable_enableToolInvocations === true`.
110
+ */
111
+ private _toolInvocations: ToolInvocationTracker | null = null;
112
+
108
113
  public override beginEdit(messageId: string) {
109
114
  if (!this._store.onEdit)
110
115
  throw new Error("Runtime does not support editing.");
@@ -169,10 +174,7 @@ export class ExternalStoreThreadRuntimeCore
169
174
  return;
170
175
  }
171
176
 
172
- // Clear and import the message repository
173
177
  this.repository.clear();
174
- this._assistantOptimisticId = null;
175
- this._lastSyncedMessageIds = new Set();
176
178
  this.repository.import(store.messageRepository);
177
179
 
178
180
  messages = this.repository.getMessages();
@@ -225,12 +227,6 @@ export class ExternalStoreThreadRuntimeCore
225
227
  return newMessage;
226
228
  });
227
229
 
228
- const nextIds = new Set(messages.map((m) => m.id));
229
- for (const prevId of this._lastSyncedMessageIds) {
230
- if (!nextIds.has(prevId)) this.repository.deleteMessage(prevId);
231
- }
232
- this._lastSyncedMessageIds = nextIds;
233
-
234
230
  for (let i = 0; i < messages.length; i++) {
235
231
  const message = messages[i]!;
236
232
  const parent = messages[i - 1];
@@ -253,29 +249,132 @@ export class ExternalStoreThreadRuntimeCore
253
249
  }
254
250
  }
255
251
 
256
- if (this._assistantOptimisticId) {
257
- this.repository.deleteMessage(this._assistantOptimisticId);
258
- this._assistantOptimisticId = null;
259
- }
260
-
252
+ // Append an optimistic placeholder while running but before a trailing
253
+ // assistant message exists. resetHead evicts off-branch optimistic messages
254
+ // (prior placeholders, mid-run id-swap siblings); export() never persists them.
255
+ let optimisticId: string | null = null;
261
256
  if (hasUpcomingMessage(isRunning, messages)) {
262
- this._assistantOptimisticId = this.repository.appendOptimisticMessage(
257
+ optimisticId = generateId();
258
+ this.repository.addOrUpdateMessage(
263
259
  messages.at(-1)?.id ?? null,
264
- {
265
- role: "assistant",
266
- content: [],
267
- },
260
+ fromThreadMessageLike(
261
+ { role: "assistant", content: [], metadata: { isOptimistic: true } },
262
+ optimisticId,
263
+ { type: "running" },
264
+ ),
268
265
  );
269
266
  }
270
267
 
271
- this.repository.resetHead(
272
- this._assistantOptimisticId ?? messages.at(-1)?.id ?? null,
273
- );
268
+ this.repository.resetHead(optimisticId ?? messages.at(-1)?.id ?? null);
274
269
 
275
270
  this._messages = this.repository.getMessages();
271
+
272
+ this._driveToolInvocations();
273
+
276
274
  this._notifySubscribers();
277
275
  }
278
276
 
277
+ /**
278
+ * Feed the current message snapshot into the tool-invocations tracker.
279
+ * Opt-in via `adapter.unstable_enableToolInvocations: true`. The tracker
280
+ * itself is fail-silent — see ToolInvocationTracker for the
281
+ * state-transition contract.
282
+ */
283
+ private _driveToolInvocations(): void {
284
+ if (!this._store.unstable_enableToolInvocations) {
285
+ // Adapter did not opt in (default). If a tracker was previously
286
+ // constructed (e.g. the adapter just toggled the flag off via a
287
+ // dynamic swap), drop it so subsequent snapshots are no-ops.
288
+ if (this._toolInvocations) {
289
+ this._toolInvocations.reset();
290
+ this._toolInvocations = null;
291
+ this._store.setToolStatuses?.({});
292
+ }
293
+ return;
294
+ }
295
+
296
+ if (!this._toolInvocations) {
297
+ this._toolInvocations = new ToolInvocationTracker(
298
+ () => this.getModelContext().tools,
299
+ {
300
+ onResult: (command) => {
301
+ try {
302
+ const messageId = this._findMessageIdForToolCall(
303
+ command.toolCallId,
304
+ );
305
+ if (messageId === undefined) {
306
+ // The tool call no longer exists in the snapshot (e.g.
307
+ // rolled back). Drop the result.
308
+ return;
309
+ }
310
+ this._store.onAddToolResult?.({
311
+ messageId,
312
+ toolCallId: command.toolCallId,
313
+ toolName: command.toolName,
314
+ result: command.result,
315
+ isError: command.isError,
316
+ ...(command.artifact !== undefined && {
317
+ artifact: command.artifact,
318
+ }),
319
+ ...(command.modelContent !== undefined && {
320
+ modelContent: command.modelContent,
321
+ }),
322
+ });
323
+ } catch (err) {
324
+ console.error(
325
+ "[ExternalStoreThreadRuntimeCore] onAddToolResult dispatch failed",
326
+ err,
327
+ );
328
+ }
329
+ },
330
+ onStatusesChange: (statuses) => {
331
+ this._store.setToolStatuses?.(Object.fromEntries(statuses));
332
+ },
333
+ },
334
+ );
335
+ }
336
+
337
+ this._toolInvocations.setState({
338
+ messages: this._messages,
339
+ isRunning: this._store.isRunning ?? false,
340
+ ...(this._store.isLoading !== undefined && {
341
+ isLoading: this._store.isLoading,
342
+ }),
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Lookup table from `toolCallId` to the owning assistant message's `id`,
348
+ * rebuilt lazily when `_messages` changes (see `_messagesForToolCallIndex`).
349
+ */
350
+ private _toolCallToMessageId = new Map<string, string>();
351
+ private _messagesForToolCallIndex: readonly ThreadMessage[] | null = null;
352
+
353
+ /**
354
+ * Look up the assistant message that owns a tool-call part. Lazily builds
355
+ * (and caches) a `toolCallId → messageId` map keyed off the current
356
+ * `_messages` reference, so onResult dispatches stay O(1) instead of
357
+ * walking the full thread on every result.
358
+ */
359
+ private _findMessageIdForToolCall(toolCallId: string): string | undefined {
360
+ if (this._messagesForToolCallIndex !== this._messages) {
361
+ this._toolCallToMessageId.clear();
362
+ const visit = (messages: readonly ThreadMessage[]): void => {
363
+ for (const message of messages) {
364
+ if (!Array.isArray(message.content)) continue;
365
+ for (const part of message.content) {
366
+ if (!part || part.type !== "tool-call") continue;
367
+ this._toolCallToMessageId.set(part.toolCallId, message.id);
368
+ if (part.messages) visit(part.messages);
369
+ }
370
+ }
371
+ };
372
+ visit(this._messages);
373
+ this._messagesForToolCallIndex = this._messages;
374
+ }
375
+ return this._toolCallToMessageId.get(toolCallId);
376
+ }
377
+
279
378
  public override switchToBranch(branchId: string): void {
280
379
  if (!this._store.setMessages)
281
380
  throw new Error("Runtime does not support switching branches.");
@@ -290,6 +389,16 @@ export class ExternalStoreThreadRuntimeCore
290
389
  }
291
390
 
292
391
  public async append(message: AppendMessage): Promise<void> {
392
+ // Auto-abort in-flight client-side tool executions when a new run is
393
+ // about to start. Without this, a tool that finishes after the new turn
394
+ // begins would feed a stale result into `onAddToolResult`, racing with
395
+ // the new turn the user just initiated. `startRun` defaults to true for
396
+ // user messages — matches the satellites' historical opt-in cancel
397
+ // behavior, which is now built in.
398
+ if (message.startRun ?? message.role === "user") {
399
+ await this._toolInvocations?.abort();
400
+ }
401
+
293
402
  if (message.parentId !== (this.messages.at(-1)?.id ?? null)) {
294
403
  if (!this._store.onEdit)
295
404
  throw new Error("Runtime does not support editing messages.");
@@ -303,6 +412,11 @@ export class ExternalStoreThreadRuntimeCore
303
412
  if (!this._store.onReload)
304
413
  throw new Error("Runtime does not support reloading messages.");
305
414
 
415
+ // Auto-abort in-flight client-side tool executions when a run reloads;
416
+ // any results that land afterward would target a turn that no longer
417
+ // exists. See `append` above for full rationale.
418
+ await this._toolInvocations?.abort();
419
+
306
420
  await this._store.onReload(config.parentId, config);
307
421
  }
308
422
 
@@ -324,6 +438,18 @@ export class ExternalStoreThreadRuntimeCore
324
438
  if (!this._store.onLoadExternalState)
325
439
  throw new Error("Runtime does not support importing external states.");
326
440
 
441
+ // Re-arm the tracker so the next adapter snapshot (containing the
442
+ // imported state) is treated as historical — no streamCall/execute
443
+ // fires for the loaded tool calls. The adapter is expected to update
444
+ // its messages in response to onLoadExternalState; that update flows
445
+ // back here via __internal_setAdapter. We only clear adapter-side
446
+ // tool statuses when the tracker is the source of truth — otherwise
447
+ // we'd wipe statuses the adapter is managing on its own.
448
+ if (this._toolInvocations) {
449
+ this._toolInvocations.reset();
450
+ this._store.setToolStatuses?.({});
451
+ }
452
+
327
453
  this._store.onLoadExternalState(state);
328
454
  }
329
455
 
@@ -331,11 +457,18 @@ export class ExternalStoreThreadRuntimeCore
331
457
  if (!this._store.onCancel)
332
458
  throw new Error("Runtime does not support cancelling runs.");
333
459
 
460
+ // Abort any in-flight client-side tool executions. Fire-and-forget —
461
+ // the abort resolves once executions settle, but we don't gate the
462
+ // cancel on it.
463
+ void this._toolInvocations?.abort();
464
+
334
465
  this._store.onCancel();
335
466
 
336
- if (this._assistantOptimisticId) {
337
- this.repository.deleteMessage(this._assistantOptimisticId);
338
- this._assistantOptimisticId = null;
467
+ // Drop an empty optimistic head (placeholder or pre-stream message); a
468
+ // partially-streamed one is kept and re-supplied by the store on resync.
469
+ const head = this.repository.getMessages().at(-1);
470
+ if (head && head.metadata.isOptimistic && head.content.length === 0) {
471
+ this.repository.deleteMessage(head.id);
339
472
  }
340
473
 
341
474
  let messages = this.repository.getMessages();
@@ -345,7 +478,6 @@ export class ExternalStoreThreadRuntimeCore
345
478
  previousMessage.id === messages.at(-1)?.id // ensure the previous message is a leaf node
346
479
  ) {
347
480
  this.repository.deleteMessage(previousMessage.id);
348
- this._lastSyncedMessageIds.delete(previousMessage.id);
349
481
  if (!this.composer.text.trim()) {
350
482
  this.composer.setText(getThreadMessageText(previousMessage));
351
483
  }
@@ -362,15 +494,29 @@ export class ExternalStoreThreadRuntimeCore
362
494
  }
363
495
 
364
496
  public addToolResult(options: AddToolResultOptions) {
365
- if (!this._store.onAddToolResult && !this._store.onAddToolResult)
497
+ if (!this._store.onAddToolResult)
366
498
  throw new Error("Runtime does not support tool results.");
367
499
  this._store.onAddToolResult?.(options);
368
500
  }
369
501
 
370
502
  public resumeToolCall(options: ResumeToolCallOptions) {
371
- if (!this._store.onResumeToolCall)
372
- throw new Error("Runtime does not support resuming tool calls.");
373
- this._store.onResumeToolCall(options);
503
+ // Tracker owns its own human-input handlers — let it resume in-process
504
+ // tool calls without round-tripping through the adapter. Falls back to
505
+ // the adapter's onResumeToolCall (if any) for tool calls the tracker
506
+ // doesn't know about.
507
+ const handled =
508
+ this._toolInvocations?.resume(options.toolCallId, options.payload) ??
509
+ false;
510
+ if (handled) return;
511
+
512
+ if (this._store.onResumeToolCall) {
513
+ this._store.onResumeToolCall(options);
514
+ return;
515
+ }
516
+
517
+ throw new Error(
518
+ `Tool call ${options.toolCallId} is not waiting for resume.`,
519
+ );
374
520
  }
375
521
 
376
522
  public respondToToolApproval(options: RespondToToolApprovalOptions) {
@@ -380,16 +526,12 @@ export class ExternalStoreThreadRuntimeCore
380
526
  }
381
527
 
382
528
  public override reset(initialMessages?: readonly ThreadMessageLike[]) {
383
- this._lastSyncedMessageIds = new Set();
384
529
  const repo = new MessageRepository();
385
530
  repo.import(ExportedMessageRepository.fromArray(initialMessages ?? []));
386
531
  this.updateMessages(repo.getMessages());
387
532
  }
388
533
 
389
534
  public override import(data: ExportedMessageRepository) {
390
- this._assistantOptimisticId = null;
391
- this._lastSyncedMessageIds = new Set();
392
-
393
535
  super.import(data);
394
536
 
395
537
  if (this._store.onImport) {