@assistant-ui/core 0.2.7 → 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 (149) 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 +2 -1
  5. package/dist/index.js +2 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/internal/duplicate-detection.d.ts.map +1 -1
  8. package/dist/internal.d.ts +2 -2
  9. package/dist/internal.js +2 -2
  10. package/dist/model-context/frame/host.d.ts.map +1 -1
  11. package/dist/model-context/frame/host.js.map +1 -1
  12. package/dist/model-context/frame/provider.d.ts.map +1 -1
  13. package/dist/model-context/frame/provider.js.map +1 -1
  14. package/dist/model-context/registry.d.ts.map +1 -1
  15. package/dist/model-context/tool.d.ts.map +1 -1
  16. package/dist/react/client/Interactables.js.map +1 -1
  17. package/dist/react/client/Tools.d.ts.map +1 -1
  18. package/dist/react/client/Tools.js +26 -15
  19. package/dist/react/client/Tools.js.map +1 -1
  20. package/dist/react/index.d.ts +4 -3
  21. package/dist/react/index.js +2 -1
  22. package/dist/react/model-context/toolbox.d.ts +29 -2
  23. package/dist/react/model-context/toolbox.d.ts.map +1 -1
  24. package/dist/react/model-context/toolbox.js +18 -0
  25. package/dist/react/model-context/toolbox.js.map +1 -0
  26. package/dist/react/model-context/useAssistantTool.d.ts.map +1 -1
  27. package/dist/react/model-context/useAssistantTool.js +6 -3
  28. package/dist/react/model-context/useAssistantTool.js.map +1 -1
  29. package/dist/react/model-context/useAssistantToolUI.d.ts +6 -0
  30. package/dist/react/model-context/useAssistantToolUI.d.ts.map +1 -1
  31. package/dist/react/model-context/useAssistantToolUI.js +4 -2
  32. package/dist/react/model-context/useAssistantToolUI.js.map +1 -1
  33. package/dist/react/model-context/useInlineRender.js.map +1 -1
  34. package/dist/react/primitives/message/MessageGroupedParts.d.ts +49 -7
  35. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
  36. package/dist/react/primitives/message/MessageGroupedParts.js +28 -3
  37. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  38. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  39. package/dist/react/primitives/message/MessageParts.js +2 -7
  40. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  41. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  42. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  43. package/dist/react/runtimes/RuntimeAdapterProvider.d.ts.map +1 -1
  44. package/dist/react/runtimes/RuntimeAdapterProvider.js +6 -5
  45. package/dist/react/runtimes/RuntimeAdapterProvider.js.map +1 -1
  46. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
  47. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  48. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  49. package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
  50. package/dist/react/runtimes/external-message-converter.js +1 -0
  51. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  52. package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts +7 -0
  53. package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts.map +1 -0
  54. package/dist/react/runtimes/useExternalStoreSharedOptions.js +21 -0
  55. package/dist/react/runtimes/useExternalStoreSharedOptions.js.map +1 -0
  56. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  57. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  58. package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
  59. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  60. package/dist/react/types/scopes/tools.d.ts +19 -2
  61. package/dist/react/types/scopes/tools.d.ts.map +1 -1
  62. package/dist/react/utils/groupParts.d.ts +32 -11
  63. package/dist/react/utils/groupParts.d.ts.map +1 -1
  64. package/dist/react/utils/groupParts.js +13 -6
  65. package/dist/react/utils/groupParts.js.map +1 -1
  66. package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
  67. package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
  68. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  69. package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
  70. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  71. package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
  72. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  73. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  74. package/dist/runtime/base/base-assistant-runtime-core.d.ts.map +1 -1
  75. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  76. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  77. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  78. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  79. package/dist/runtime/utils/message-repository.d.ts +9 -1
  80. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  81. package/dist/runtime/utils/message-repository.js +34 -14
  82. package/dist/runtime/utils/message-repository.js.map +1 -1
  83. package/dist/runtime/utils/thread-message-like.d.ts +1 -0
  84. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  85. package/dist/runtime/utils/thread-message-like.js +2 -1
  86. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  87. package/dist/runtimes/external-store/external-store-shared-options.d.ts +8 -0
  88. package/dist/runtimes/external-store/external-store-shared-options.d.ts.map +1 -0
  89. package/dist/runtimes/external-store/external-store-shared-options.js +11 -0
  90. package/dist/runtimes/external-store/external-store-shared-options.js.map +1 -0
  91. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  92. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +0 -2
  93. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  94. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +12 -23
  95. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  96. package/dist/runtimes/external-store/thread-message-converter.d.ts.map +1 -1
  97. package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
  98. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  99. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  100. package/dist/runtimes/remote-thread-list/adapter/in-memory.d.ts.map +1 -1
  101. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  102. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
  103. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
  104. package/dist/subscribable/subscribable.d.ts.map +1 -1
  105. package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
  106. package/dist/types/message.d.ts +6 -0
  107. package/dist/types/message.d.ts.map +1 -1
  108. package/dist/types/message.js.map +1 -1
  109. package/dist/utils/composite-context-provider.d.ts.map +1 -1
  110. package/dist/utils/id.d.ts +1 -3
  111. package/dist/utils/id.d.ts.map +1 -1
  112. package/dist/utils/id.js +1 -4
  113. package/dist/utils/id.js.map +1 -1
  114. package/package.json +10 -10
  115. package/src/adapters/speech.ts +0 -1
  116. package/src/index.ts +2 -0
  117. package/src/internal.ts +0 -2
  118. package/src/model-context/frame/host.ts +0 -1
  119. package/src/model-context/frame/provider.ts +0 -1
  120. package/src/react/client/Interactables.ts +0 -1
  121. package/src/react/client/Tools.ts +50 -25
  122. package/src/react/index.ts +8 -2
  123. package/src/react/model-context/toolbox.ts +46 -1
  124. package/src/react/model-context/useAssistantTool.ts +8 -3
  125. package/src/react/model-context/useAssistantToolUI.ts +9 -2
  126. package/src/react/model-context/useInlineRender.ts +0 -1
  127. package/src/react/primitives/message/MessageGroupedParts.tsx +101 -12
  128. package/src/react/primitives/message/MessageParts.tsx +4 -7
  129. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +0 -3
  130. package/src/react/runtimes/RuntimeAdapterProvider.tsx +12 -7
  131. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +0 -3
  132. package/src/react/runtimes/external-message-converter.ts +4 -0
  133. package/src/react/runtimes/useExternalStoreSharedOptions.ts +23 -0
  134. package/src/react/runtimes/useLocalRuntime.ts +0 -10
  135. package/src/react/runtimes/useRemoteThreadListRuntime.ts +0 -6
  136. package/src/react/types/scopes/tools.ts +20 -1
  137. package/src/react/utils/groupParts.ts +49 -18
  138. package/src/runtime/utils/message-repository.ts +57 -16
  139. package/src/runtime/utils/thread-message-like.ts +2 -0
  140. package/src/runtimes/external-store/external-store-shared-options.ts +18 -0
  141. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +18 -33
  142. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +0 -1
  143. package/src/tests/MessageRepository.test.ts +83 -52
  144. package/src/tests/OptimisticState-list-race.test.ts +0 -4
  145. package/src/tests/external-store-thread-runtime-core.test.ts +105 -73
  146. package/src/tests/groupParts.test.ts +70 -0
  147. package/src/tests/remote-thread-list-isLoading.test.ts +0 -5
  148. package/src/types/message.ts +6 -0
  149. package/src/utils/id.ts +0 -4
@@ -1,5 +1,17 @@
1
1
  import { isMcpAppUri } from "../../types/message";
2
2
  import type { PartState } from "../../store/scopes/part";
3
+ import type { ToolsState } from "../types/scopes/tools";
4
+
5
+ /**
6
+ * Registry context passed to a `groupBy` function as its second argument by
7
+ * `<MessagePrimitive.GroupedParts>`. Carries the live tool-UI registry so a
8
+ * `groupBy` can resolve registry-driven grouping (e.g. standalone tool calls)
9
+ * without the part itself having to carry that information.
10
+ */
11
+ export type GroupByContext = {
12
+ /** Tool UIs registered in the tool-UI registry, keyed by tool name. */
13
+ readonly toolUIs?: ToolsState["toolUIs"];
14
+ };
3
15
 
4
16
  /**
5
17
  * Hierarchical adjacent-coalescing grouping for message parts.
@@ -25,12 +37,20 @@ export const GROUPBY_MEMO_KEY: unique symbol = Symbol.for(
25
37
  );
26
38
 
27
39
  /**
28
- * Synthetic part-type key recognized by {@link groupPartByType}: a
29
- * tool-call whose `mcp.app.resourceUri` points at an assistant-ui MCP
30
- * app. Map this key to control how MCP-app tool calls are grouped —
31
- * separately from regular `"tool-call"` parts.
40
+ * Synthetic part-type keys recognized by {@link groupPartByType}, in
41
+ * addition to real {@link PartState} types:
42
+ *
43
+ * - `"standalone-tool-call"` — a tool-call whose UI should be presented on its
44
+ * own, outside the chain-of-thought grouping. Matches MCP-app tool calls plus
45
+ * any tool-call whose registered UI opts into standalone display (human
46
+ * tools, the built-in generative-UI tool, and tools that set
47
+ * `display: "standalone"`). Resolving the registry-driven cases reads the
48
+ * {@link GroupByContext} passed to the `groupBy` function. Takes precedence
49
+ * over the `"tool-call"` entry.
50
+ * - `"mcp-app"` — **deprecated**, kept for back-compat. Matches only MCP-app
51
+ * tool calls. Prefer `"standalone-tool-call"`, which is a superset.
32
52
  */
33
- type GroupPartType = PartState["type"] | "mcp-app";
53
+ type GroupPartType = PartState["type"] | "standalone-tool-call" | "mcp-app";
34
54
 
35
55
  /**
36
56
  * Build a `groupBy` from a `part.type → group-key path` lookup.
@@ -38,9 +58,12 @@ type GroupPartType = PartState["type"] | "mcp-app";
38
58
  * function carries a stable {@link GROUPBY_MEMO_KEY} fingerprint so
39
59
  * `<MessagePrimitive.GroupedParts>` can memoize its tree across renders.
40
60
  *
41
- * Special key `"mcp-app"` matches tool-call parts that point at an
42
- * assistant-ui MCP app resource (`ui://...`) and takes precedence over
43
- * the `"tool-call"` entry for those parts.
61
+ * The synthetic `"standalone-tool-call"` key matches tool calls that should
62
+ * render outside the chain-of-thought grouping. MCP-app calls are detected from
63
+ * the part alone; the registry-driven cases (human tools, the generative-UI
64
+ * tool, `display: "standalone"` opt-ins) are resolved from the
65
+ * {@link GroupByContext} that `<MessagePrimitive.GroupedParts>` passes to the
66
+ * `groupBy` function — the helper needs nothing threaded into it.
44
67
  *
45
68
  * @example
46
69
  * ```tsx
@@ -48,7 +71,7 @@ type GroupPartType = PartState["type"] | "mcp-app";
48
71
  * groupBy={groupPartByType({
49
72
  * reasoning: ["group-thought", "group-reasoning"],
50
73
  * "tool-call": ["group-thought", "group-tool"],
51
- * "mcp-app": [],
74
+ * "standalone-tool-call": [],
52
75
  * })}
53
76
  * >
54
77
  * {({ part, children }) => { ... }}
@@ -57,18 +80,26 @@ type GroupPartType = PartState["type"] | "mcp-app";
57
80
  */
58
81
  export const groupPartByType = <TKey extends `group-${string}`>(
59
82
  map: Partial<Readonly<Record<GroupPartType, readonly TKey[]>>>,
60
- ): ((part: PartState) => readonly TKey[]) => {
83
+ ): ((part: PartState, context?: GroupByContext) => readonly TKey[]) => {
61
84
  const lookup = map as Readonly<Record<string, readonly TKey[] | undefined>>;
62
- const fn = ((part) => {
63
- if (
64
- part.type === "tool-call" &&
65
- lookup["mcp-app"] !== undefined &&
66
- isMcpAppUri(part.mcp?.app?.resourceUri)
67
- ) {
68
- return lookup["mcp-app"]!;
85
+ const fn = ((part, context) => {
86
+ if (part.type === "tool-call") {
87
+ const isMcpApp = isMcpAppUri(part.mcp?.app?.resourceUri);
88
+ // Read the first registration's flag — the same one `resolveToolRender`
89
+ // renders — so grouping and rendering never disagree for a tool name.
90
+ const isStandalone =
91
+ isMcpApp ||
92
+ (context?.toolUIs?.[part.toolName]?.[0]?.standalone ?? false);
93
+ if (isStandalone && lookup["standalone-tool-call"] !== undefined) {
94
+ return lookup["standalone-tool-call"]!;
95
+ }
96
+ // TODO(v0.15): drop the deprecated "mcp-app" key (superseded by "standalone-tool-call").
97
+ if (isMcpApp && lookup["mcp-app"] !== undefined) {
98
+ return lookup["mcp-app"]!;
99
+ }
69
100
  }
70
101
  return lookup[part.type] ?? [];
71
- }) as ((part: PartState) => readonly TKey[]) & {
102
+ }) as ((part: PartState, context?: GroupByContext) => readonly TKey[]) & {
72
103
  [GROUPBY_MEMO_KEY]?: string;
73
104
  };
74
105
  // Sort keys so the fingerprint is insensitive to map insertion order —
@@ -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
 
@@ -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
+ };
@@ -30,6 +30,7 @@ import {
30
30
  ExportedMessageRepository,
31
31
  MessageRepository,
32
32
  } from "../../runtime/utils/message-repository";
33
+ import { generateId } from "../../utils/id";
33
34
  import { ToolInvocationTracker } from "../tool-invocations/ToolInvocationTracker";
34
35
 
35
36
  const EMPTY_ARRAY: readonly ThreadSuggestion[] = Object.freeze([]);
@@ -54,9 +55,6 @@ export class ExternalStoreThreadRuntimeCore
54
55
  extends BaseThreadRuntimeCore
55
56
  implements ThreadRuntimeCore
56
57
  {
57
- private _assistantOptimisticId: string | null = null;
58
- private _lastSyncedMessageIds = new Set<string>();
59
-
60
58
  private _capabilities: RuntimeCapabilities = {
61
59
  switchToBranch: false,
62
60
  switchBranchDuringRun: false,
@@ -176,10 +174,7 @@ export class ExternalStoreThreadRuntimeCore
176
174
  return;
177
175
  }
178
176
 
179
- // Clear and import the message repository
180
177
  this.repository.clear();
181
- this._assistantOptimisticId = null;
182
- this._lastSyncedMessageIds = new Set();
183
178
  this.repository.import(store.messageRepository);
184
179
 
185
180
  messages = this.repository.getMessages();
@@ -232,12 +227,6 @@ export class ExternalStoreThreadRuntimeCore
232
227
  return newMessage;
233
228
  });
234
229
 
235
- const nextIds = new Set(messages.map((m) => m.id));
236
- for (const prevId of this._lastSyncedMessageIds) {
237
- if (!nextIds.has(prevId)) this.repository.deleteMessage(prevId);
238
- }
239
- this._lastSyncedMessageIds = nextIds;
240
-
241
230
  for (let i = 0; i < messages.length; i++) {
242
231
  const message = messages[i]!;
243
232
  const parent = messages[i - 1];
@@ -260,24 +249,23 @@ export class ExternalStoreThreadRuntimeCore
260
249
  }
261
250
  }
262
251
 
263
- if (this._assistantOptimisticId) {
264
- this.repository.deleteMessage(this._assistantOptimisticId);
265
- this._assistantOptimisticId = null;
266
- }
267
-
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;
268
256
  if (hasUpcomingMessage(isRunning, messages)) {
269
- this._assistantOptimisticId = this.repository.appendOptimisticMessage(
257
+ optimisticId = generateId();
258
+ this.repository.addOrUpdateMessage(
270
259
  messages.at(-1)?.id ?? null,
271
- {
272
- role: "assistant",
273
- content: [],
274
- },
260
+ fromThreadMessageLike(
261
+ { role: "assistant", content: [], metadata: { isOptimistic: true } },
262
+ optimisticId,
263
+ { type: "running" },
264
+ ),
275
265
  );
276
266
  }
277
267
 
278
- this.repository.resetHead(
279
- this._assistantOptimisticId ?? messages.at(-1)?.id ?? null,
280
- );
268
+ this.repository.resetHead(optimisticId ?? messages.at(-1)?.id ?? null);
281
269
 
282
270
  this._messages = this.repository.getMessages();
283
271
 
@@ -476,9 +464,11 @@ export class ExternalStoreThreadRuntimeCore
476
464
 
477
465
  this._store.onCancel();
478
466
 
479
- if (this._assistantOptimisticId) {
480
- this.repository.deleteMessage(this._assistantOptimisticId);
481
- 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);
482
472
  }
483
473
 
484
474
  let messages = this.repository.getMessages();
@@ -488,7 +478,6 @@ export class ExternalStoreThreadRuntimeCore
488
478
  previousMessage.id === messages.at(-1)?.id // ensure the previous message is a leaf node
489
479
  ) {
490
480
  this.repository.deleteMessage(previousMessage.id);
491
- this._lastSyncedMessageIds.delete(previousMessage.id);
492
481
  if (!this.composer.text.trim()) {
493
482
  this.composer.setText(getThreadMessageText(previousMessage));
494
483
  }
@@ -537,16 +526,12 @@ export class ExternalStoreThreadRuntimeCore
537
526
  }
538
527
 
539
528
  public override reset(initialMessages?: readonly ThreadMessageLike[]) {
540
- this._lastSyncedMessageIds = new Set();
541
529
  const repo = new MessageRepository();
542
530
  repo.import(ExportedMessageRepository.fromArray(initialMessages ?? []));
543
531
  this.updateMessages(repo.getMessages());
544
532
  }
545
533
 
546
534
  public override import(data: ExportedMessageRepository) {
547
- this._assistantOptimisticId = null;
548
- this._lastSyncedMessageIds = new Set();
549
-
550
535
  super.import(data);
551
536
 
552
537
  if (this._store.onImport) {
@@ -446,7 +446,6 @@ export class ToolInvocationTracker {
446
446
 
447
447
  if (this._executing.size === 0) {
448
448
  const resolvers = this._settledResolvers.splice(0);
449
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return
450
449
  resolvers.forEach((resolve) => {
451
450
  try {
452
451
  resolve();
@@ -7,20 +7,14 @@ import type { ThreadMessage } from "../types/message";
7
7
  import type { TextMessagePart } from "../types/message";
8
8
  import type { ThreadMessageLike } from "../runtime/utils/thread-message-like";
9
9
 
10
- // Mock generateId and generateOptimisticId to make tests deterministic
10
+ // Mock generateId to make tests deterministic
11
11
  const mockGenerateId = vi.fn();
12
- const mockGenerateOptimisticId = vi.fn();
13
- const mockIsOptimisticId = vi.fn((id: string) =>
14
- id.startsWith("__optimistic__"),
15
- );
16
12
 
17
13
  vi.mock("../utils/id", async (importOriginal) => {
18
14
  const original = await importOriginal<typeof import("../utils/id")>();
19
15
  return {
20
16
  ...original,
21
17
  generateId: () => mockGenerateId(),
22
- generateOptimisticId: () => mockGenerateOptimisticId(),
23
- isOptimisticId: (id: string) => mockIsOptimisticId(id),
24
18
  };
25
19
  });
26
20
 
@@ -58,23 +52,11 @@ describe("MessageRepository", () => {
58
52
  ...overrides,
59
53
  });
60
54
 
61
- /**
62
- * Creates a test CoreMessage with the given overrides.
63
- */
64
- const createThreadMessageLike = (overrides = {}): ThreadMessageLike => ({
65
- role: "assistant",
66
- content: [{ type: "text", text: "Test message" }],
67
- ...overrides,
68
- });
69
-
70
55
  beforeEach(() => {
71
56
  repository = new MessageRepository();
72
57
  // Reset mocks with predictable counter-based values
73
58
  nextMockId = 1;
74
59
  mockGenerateId.mockImplementation(() => `mock-id-${nextMockId++}`);
75
- mockGenerateOptimisticId.mockImplementation(
76
- () => `__optimistic__mock-id-${nextMockId++}`,
77
- );
78
60
  });
79
61
 
80
62
  afterEach(() => {
@@ -297,53 +279,102 @@ describe("MessageRepository", () => {
297
279
  });
298
280
 
299
281
  describe("Optimistic messages", () => {
300
- it("should create an optimistic message with a unique ID", () => {
301
- mockGenerateOptimisticId.mockReturnValue("__optimistic__generated-id");
282
+ const optimistic = (overrides = {}) =>
283
+ createTestMessage({
284
+ status: { type: "running" },
285
+ metadata: {
286
+ unstable_state: null,
287
+ unstable_annotations: [],
288
+ unstable_data: [],
289
+ steps: [],
290
+ custom: {},
291
+ isOptimistic: true,
292
+ },
293
+ ...overrides,
294
+ });
302
295
 
303
- const coreMessage = createThreadMessageLike();
304
- const optimisticId = repository.appendOptimisticMessage(
305
- null,
306
- coreMessage,
307
- );
296
+ it("excludes optimistic messages from export()", () => {
297
+ const parent = createTestMessage({ id: "u" });
298
+ repository.addOrUpdateMessage(null, parent);
299
+ repository.addOrUpdateMessage("u", optimistic({ id: "placeholder" }));
300
+ repository.resetHead("placeholder");
308
301
 
309
- expect(optimisticId).toBe("__optimistic__generated-id");
310
- expect(repository.getMessage(optimisticId).message.status?.type).toBe(
311
- "running",
312
- );
302
+ const exported = repository.export();
303
+
304
+ expect(exported.messages.map((m) => m.message.id)).toEqual(["u"]);
305
+ // head was the optimistic placeholder; the exported head must fall back
306
+ // to the nearest persisted ancestor so it always resolves on import.
307
+ expect(exported.headId).toBe("u");
313
308
  });
314
309
 
315
- it("should create an optimistic message as a child of a specified parent", () => {
316
- const parent = createTestMessage({ id: "parent-id" });
310
+ it("round-trips through export/import without resurrecting the placeholder", () => {
311
+ const parent = createTestMessage({ id: "u" });
312
+ const real = createTestMessage({ id: "a" });
317
313
  repository.addOrUpdateMessage(null, parent);
314
+ repository.addOrUpdateMessage("u", real);
315
+ repository.resetHead("a");
318
316
 
319
- const coreMessage = createThreadMessageLike();
320
- const optimisticId = repository.appendOptimisticMessage(
321
- "parent-id",
322
- coreMessage,
323
- );
317
+ const restored = new MessageRepository();
318
+ restored.import(repository.export());
324
319
 
325
- const result = repository.getMessage(optimisticId);
326
- expect(result.parentId).toBe("parent-id");
320
+ expect(restored.export().messages.map((m) => m.message.id)).toEqual([
321
+ "u",
322
+ "a",
323
+ ]);
327
324
  });
328
325
 
329
- it("should retry generating unique optimistic IDs if initial one exists", () => {
330
- mockGenerateOptimisticId.mockReturnValueOnce("__optimistic__existing-id");
326
+ describe("HEAD-branch invariant", () => {
327
+ it("evicts an off-branch optimistic sibling when resetHead moves the head", () => {
328
+ // u -> { client_id (optimistic), server_id (optimistic) }. When the
329
+ // head moves to server_id, the dangling client_id sibling is evicted.
330
+ const parent = createTestMessage({ id: "u" });
331
+ repository.addOrUpdateMessage(null, parent);
332
+ repository.addOrUpdateMessage("u", optimistic({ id: "client_id" }));
333
+ repository.addOrUpdateMessage("u", optimistic({ id: "server_id" }));
334
+
335
+ repository.resetHead("server_id");
331
336
 
332
- const existingMessage = createTestMessage({
333
- id: "__optimistic__existing-id",
337
+ expect(repository.getBranches("server_id")).toEqual(["server_id"]);
338
+ expect(() => repository.getMessage("client_id")).toThrow();
334
339
  });
335
- repository.addOrUpdateMessage(null, existingMessage);
336
340
 
337
- mockGenerateOptimisticId.mockReturnValueOnce("__optimistic__unique-id");
341
+ it("keeps the optimistic message that is on the head branch", () => {
342
+ const parent = createTestMessage({ id: "u" });
343
+ repository.addOrUpdateMessage(null, parent);
344
+ repository.addOrUpdateMessage("u", optimistic({ id: "a" }));
338
345
 
339
- const coreMessage = createThreadMessageLike();
340
- const optimisticId = repository.appendOptimisticMessage(
341
- null,
342
- coreMessage,
343
- );
346
+ repository.resetHead("a");
347
+
348
+ expect(repository.getMessage("a").message.id).toBe("a");
349
+ });
344
350
 
345
- expect(optimisticId).toBe("__optimistic__unique-id");
346
- expect(mockGenerateOptimisticId).toHaveBeenCalledTimes(2);
351
+ it("never evicts real (non-optimistic) sibling branches", () => {
352
+ const parent = createTestMessage({ id: "u" });
353
+ repository.addOrUpdateMessage(null, parent);
354
+ repository.addOrUpdateMessage("u", createTestMessage({ id: "a1" }));
355
+ repository.addOrUpdateMessage("u", createTestMessage({ id: "a2" }));
356
+
357
+ repository.resetHead("a2");
358
+
359
+ // a1 is off the head branch but not optimistic, so it survives.
360
+ expect(repository.getBranches("a2")).toEqual(["a1", "a2"]);
361
+ });
362
+
363
+ it("evicts optimistic messages from the previous branch on switchToBranch", () => {
364
+ // Two real branches under u; the head branch (a2) carries an optimistic
365
+ // child. Switching to a1 must drop the optimistic message left on a2.
366
+ const parent = createTestMessage({ id: "u" });
367
+ repository.addOrUpdateMessage(null, parent);
368
+ repository.addOrUpdateMessage("u", createTestMessage({ id: "a1" }));
369
+ repository.addOrUpdateMessage("u", createTestMessage({ id: "a2" }));
370
+ repository.addOrUpdateMessage("a2", optimistic({ id: "opt" }));
371
+ repository.resetHead("opt");
372
+
373
+ repository.switchToBranch("a1");
374
+
375
+ expect(() => repository.getMessage("opt")).toThrow();
376
+ expect(repository.getBranches("a1")).toEqual(["a1", "a2"]);
377
+ });
347
378
  });
348
379
  });
349
380
 
@@ -89,7 +89,6 @@ describe("list + delete race condition", () => {
89
89
  const listPromise = state.optimisticUpdate({
90
90
  execute: () => listDeferred.promise,
91
91
  loading: (s) => ({ ...s, isLoading: true }),
92
- // biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
93
92
  then: applyListResult,
94
93
  });
95
94
 
@@ -147,7 +146,6 @@ describe("list + delete race condition", () => {
147
146
  const listPromise = state.optimisticUpdate({
148
147
  execute: () => listDeferred.promise,
149
148
  loading: (s) => ({ ...s, isLoading: true }),
150
- // biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
151
149
  then: applyListResult,
152
150
  });
153
151
 
@@ -185,7 +183,6 @@ describe("list + delete race condition", () => {
185
183
  const listPromise = state.optimisticUpdate({
186
184
  execute: () => listDeferred.promise,
187
185
  loading: (s) => ({ ...s, isLoading: true }),
188
- // biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
189
186
  then: applyListResult,
190
187
  });
191
188
 
@@ -223,7 +220,6 @@ describe("list + delete race condition", () => {
223
220
  const listPromise = state.optimisticUpdate({
224
221
  execute: () => listDeferred.promise,
225
222
  loading: (s) => ({ ...s, isLoading: true }),
226
- // biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
227
223
  then: applyListResult,
228
224
  });
229
225