@assistant-ui/core 0.2.18 → 0.2.20

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 (231) hide show
  1. package/dist/index.d.ts +4 -2
  2. package/dist/index.js +3 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/internal.d.ts +2 -1
  5. package/dist/internal.js +2 -1
  6. package/dist/model-context/interactable-composer-metadata.d.ts +105 -0
  7. package/dist/model-context/interactable-composer-metadata.d.ts.map +1 -0
  8. package/dist/model-context/interactable-composer-metadata.js +259 -0
  9. package/dist/model-context/interactable-composer-metadata.js.map +1 -0
  10. package/dist/model-context/registry.d.ts.map +1 -1
  11. package/dist/model-context/registry.js +1 -0
  12. package/dist/model-context/registry.js.map +1 -1
  13. package/dist/model-context/types.d.ts +6 -0
  14. package/dist/model-context/types.d.ts.map +1 -1
  15. package/dist/model-context/types.js +4 -0
  16. package/dist/model-context/types.js.map +1 -1
  17. package/dist/react/AssistantProvider.d.ts +1 -1
  18. package/dist/react/AssistantProvider.js +1 -1
  19. package/dist/react/AssistantRuntimeProvider.d.ts +1 -1
  20. package/dist/react/RuntimeAdapter.js +1 -1
  21. package/dist/react/client/Interactables.d.ts +8 -2
  22. package/dist/react/client/Interactables.d.ts.map +1 -1
  23. package/dist/react/client/Interactables.js +190 -94
  24. package/dist/react/client/Interactables.js.map +1 -1
  25. package/dist/react/client/Tools.js +1 -1
  26. package/dist/react/client/interactable-model-context.d.ts +9 -6
  27. package/dist/react/client/interactable-model-context.d.ts.map +1 -1
  28. package/dist/react/client/interactable-model-context.js +154 -32
  29. package/dist/react/client/interactable-model-context.js.map +1 -1
  30. package/dist/react/index.d.ts +15 -6
  31. package/dist/react/index.js +12 -5
  32. package/dist/react/interactables-legacy/Interactables.d.ts +12 -0
  33. package/dist/react/interactables-legacy/Interactables.d.ts.map +1 -0
  34. package/dist/react/interactables-legacy/Interactables.js +264 -0
  35. package/dist/react/interactables-legacy/Interactables.js.map +1 -0
  36. package/dist/react/interactables-legacy/interactable-model-context.d.ts +12 -0
  37. package/dist/react/interactables-legacy/interactable-model-context.d.ts.map +1 -0
  38. package/dist/react/interactables-legacy/interactable-model-context.js +54 -0
  39. package/dist/react/interactables-legacy/interactable-model-context.js.map +1 -0
  40. package/dist/react/interactables-legacy/scopes.d.ts +58 -0
  41. package/dist/react/interactables-legacy/scopes.d.ts.map +1 -0
  42. package/dist/react/interactables-legacy/scopes.js +0 -0
  43. package/dist/react/interactables-legacy/useAssistantInteractable.d.ts +31 -0
  44. package/dist/react/interactables-legacy/useAssistantInteractable.d.ts.map +1 -0
  45. package/dist/react/{model-context → interactables-legacy}/useAssistantInteractable.js +6 -2
  46. package/dist/react/interactables-legacy/useAssistantInteractable.js.map +1 -0
  47. package/dist/react/interactables-legacy/useInteractableState.d.ts +21 -0
  48. package/dist/react/interactables-legacy/useInteractableState.d.ts.map +1 -0
  49. package/dist/react/interactables-legacy/useInteractableState.js +84 -0
  50. package/dist/react/interactables-legacy/useInteractableState.js.map +1 -0
  51. package/dist/react/model-context/interactableTool.d.ts +53 -0
  52. package/dist/react/model-context/interactableTool.d.ts.map +1 -0
  53. package/dist/react/model-context/interactableTool.js +188 -0
  54. package/dist/react/model-context/interactableTool.js.map +1 -0
  55. package/dist/react/model-context/useAssistantContext.js +1 -1
  56. package/dist/react/model-context/useAssistantDataUI.js +1 -1
  57. package/dist/react/model-context/useAssistantInstructions.js +1 -1
  58. package/dist/react/model-context/useAssistantTool.js +1 -1
  59. package/dist/react/model-context/useAssistantToolUI.js +1 -1
  60. package/dist/react/model-context/useAuiToolOverrides.js +1 -1
  61. package/dist/react/model-context/useInteractable.d.ts +73 -0
  62. package/dist/react/model-context/useInteractable.d.ts.map +1 -0
  63. package/dist/react/model-context/useInteractable.js +100 -0
  64. package/dist/react/model-context/useInteractable.js.map +1 -0
  65. package/dist/react/model-context/useInteractableState.d.ts +8 -5
  66. package/dist/react/model-context/useInteractableState.d.ts.map +1 -1
  67. package/dist/react/model-context/useInteractableState.js +48 -48
  68. package/dist/react/model-context/useInteractableState.js.map +1 -1
  69. package/dist/react/model-context/useInteractableVersions.d.ts +19 -0
  70. package/dist/react/model-context/useInteractableVersions.d.ts.map +1 -0
  71. package/dist/react/model-context/useInteractableVersions.js +51 -0
  72. package/dist/react/model-context/useInteractableVersions.js.map +1 -0
  73. package/dist/react/primitive-hooks/useThreadMessageIds.d.ts +15 -0
  74. package/dist/react/primitive-hooks/useThreadMessageIds.d.ts.map +1 -0
  75. package/dist/react/primitive-hooks/useThreadMessageIds.js +26 -0
  76. package/dist/react/primitive-hooks/useThreadMessageIds.js.map +1 -0
  77. package/dist/react/primitive-hooks/useVoice.js +1 -1
  78. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js +1 -1
  79. package/dist/react/primitives/composer/ComposerAttachments.js +1 -1
  80. package/dist/react/primitives/composer/ComposerQueue.js +1 -1
  81. package/dist/react/primitives/generativeUI/GenerativeUI.d.ts.map +1 -1
  82. package/dist/react/primitives/generativeUI/GenerativeUI.js +2 -2
  83. package/dist/react/primitives/generativeUI/GenerativeUI.js.map +1 -1
  84. package/dist/react/primitives/message/MessageAttachments.js +1 -1
  85. package/dist/react/primitives/message/MessageGroupedParts.js +1 -1
  86. package/dist/react/primitives/message/MessageParts.js +1 -1
  87. package/dist/react/primitives/message/MessageQuote.js +1 -1
  88. package/dist/react/primitives/part/PartMessages.d.ts +5 -2
  89. package/dist/react/primitives/part/PartMessages.d.ts.map +1 -1
  90. package/dist/react/primitives/part/PartMessages.js +8 -8
  91. package/dist/react/primitives/part/PartMessages.js.map +1 -1
  92. package/dist/react/primitives/thread/ThreadMessages.d.ts +31 -1
  93. package/dist/react/primitives/thread/ThreadMessages.d.ts.map +1 -1
  94. package/dist/react/primitives/thread/ThreadMessages.js +64 -2
  95. package/dist/react/primitives/thread/ThreadMessages.js.map +1 -1
  96. package/dist/react/primitives/thread/ThreadSuggestions.js +1 -1
  97. package/dist/react/primitives/threadList/ThreadListItems.js +1 -1
  98. package/dist/react/providers/MessageByIdProvider.d.ts +9 -0
  99. package/dist/react/providers/MessageByIdProvider.d.ts.map +1 -0
  100. package/dist/react/providers/MessageByIdProvider.js +57 -0
  101. package/dist/react/providers/MessageByIdProvider.js.map +1 -0
  102. package/dist/react/providers/ReadonlyThreadProvider.js +1 -1
  103. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +1 -1
  104. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +3 -0
  105. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  106. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +16 -1
  107. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  108. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +1 -1
  109. package/dist/react/runtimes/cloud/auiV0.js +1 -1
  110. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +1 -1
  111. package/dist/react/runtimes/createRuntimeExtras.d.ts +20 -0
  112. package/dist/react/runtimes/createRuntimeExtras.d.ts.map +1 -0
  113. package/dist/react/runtimes/createRuntimeExtras.js +43 -0
  114. package/dist/react/runtimes/createRuntimeExtras.js.map +1 -0
  115. package/dist/react/runtimes/useLocalRuntime.js +1 -1
  116. package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
  117. package/dist/react/runtimes/useRemoteThreadListRuntime.js +6 -2
  118. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  119. package/dist/react/runtimes/useStreamingTiming.d.ts +26 -0
  120. package/dist/react/runtimes/useStreamingTiming.d.ts.map +1 -0
  121. package/dist/react/runtimes/useStreamingTiming.js +43 -0
  122. package/dist/react/runtimes/useStreamingTiming.js.map +1 -0
  123. package/dist/react/types/scopes/interactables.d.ts +40 -24
  124. package/dist/react/types/scopes/interactables.d.ts.map +1 -1
  125. package/dist/react/types/store-augmentation.d.ts +3 -1
  126. package/dist/react/types/store-augmentation.d.ts.map +1 -1
  127. package/dist/react/utils/useJSONEqual.d.ts +11 -0
  128. package/dist/react/utils/useJSONEqual.d.ts.map +1 -0
  129. package/dist/react/utils/useJSONEqual.js +30 -0
  130. package/dist/react/utils/useJSONEqual.js.map +1 -0
  131. package/dist/runtime/base/base-composer-runtime-core.d.ts +5 -0
  132. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  133. package/dist/runtime/base/base-composer-runtime-core.js +13 -0
  134. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  135. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  136. package/dist/runtime/base/default-edit-composer-runtime-core.js +6 -1
  137. package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
  138. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  139. package/dist/runtime/base/default-thread-composer-runtime-core.js +4 -1
  140. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  141. package/dist/runtime/utils/message-repository.d.ts +1 -0
  142. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  143. package/dist/runtime/utils/message-repository.js +6 -3
  144. package/dist/runtime/utils/message-repository.js.map +1 -1
  145. package/dist/runtime/utils/streaming-timing.d.ts +59 -0
  146. package/dist/runtime/utils/streaming-timing.d.ts.map +1 -0
  147. package/dist/runtime/utils/streaming-timing.js +76 -0
  148. package/dist/runtime/utils/streaming-timing.js.map +1 -0
  149. package/dist/runtime/utils/thread-message-like.js +2 -1
  150. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  151. package/dist/runtimes/external-store/external-store-adapter.d.ts +26 -1
  152. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  153. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +9 -0
  154. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  155. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +19 -0
  156. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  157. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  158. package/dist/runtimes/local/local-thread-runtime-core.js +1 -1
  159. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  160. package/dist/runtimes/remote-thread-list/types.d.ts +12 -0
  161. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  162. package/dist/store/clients/suggestions.js +1 -1
  163. package/dist/store/clients/thread-message-client.js +1 -1
  164. package/dist/store/runtime-clients/composer-runtime-client.js +1 -1
  165. package/dist/store/runtime-clients/message-runtime-client.js +1 -1
  166. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +1 -1
  167. package/dist/store/runtime-clients/thread-runtime-client.js +1 -1
  168. package/dist/tests/remote-thread-list-test-helpers.d.ts +1 -1
  169. package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
  170. package/dist/tests/remote-thread-list-test-helpers.js +3 -2
  171. package/dist/tests/remote-thread-list-test-helpers.js.map +1 -1
  172. package/package.json +9 -9
  173. package/src/index.ts +17 -0
  174. package/src/internal.ts +7 -0
  175. package/src/model-context/interactable-composer-metadata.test.ts +550 -0
  176. package/src/model-context/interactable-composer-metadata.ts +395 -0
  177. package/src/model-context/registry.ts +5 -0
  178. package/src/model-context/types.ts +12 -0
  179. package/src/react/client/Interactables.test.ts +358 -0
  180. package/src/react/client/Interactables.ts +339 -86
  181. package/src/react/client/interactable-model-context.test.ts +418 -0
  182. package/src/react/client/interactable-model-context.ts +252 -58
  183. package/src/react/index.ts +72 -3
  184. package/src/react/interactables-legacy/Interactables.ts +332 -0
  185. package/src/react/interactables-legacy/interactable-model-context.test.ts +54 -0
  186. package/src/react/interactables-legacy/interactable-model-context.ts +80 -0
  187. package/src/react/interactables-legacy/scopes.ts +66 -0
  188. package/src/react/{model-context → interactables-legacy}/useAssistantInteractable.ts +10 -1
  189. package/src/react/interactables-legacy/useInteractableState.ts +67 -0
  190. package/src/react/model-context/interactableTool.tsx +175 -0
  191. package/src/react/model-context/useInteractable.ts +233 -0
  192. package/src/react/model-context/useInteractableState.ts +31 -23
  193. package/src/react/model-context/useInteractableVersions.ts +48 -0
  194. package/src/react/primitive-hooks/useThreadMessageIds.ts +27 -0
  195. package/src/react/primitives/generativeUI/GenerativeUI.tsx +5 -1
  196. package/src/react/primitives/part/PartMessages.test.tsx +17 -0
  197. package/src/react/primitives/part/PartMessages.tsx +13 -8
  198. package/src/react/primitives/thread/ThreadMessages.tsx +67 -0
  199. package/src/react/providers/MessageByIdProvider.tsx +23 -0
  200. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +21 -1
  201. package/src/react/runtimes/createRuntimeExtras.test.ts +58 -0
  202. package/src/react/runtimes/createRuntimeExtras.ts +68 -0
  203. package/src/react/runtimes/createRuntimeExtras.use.test.ts +50 -0
  204. package/src/react/runtimes/useRemoteThreadListRuntime.ts +13 -1
  205. package/src/react/runtimes/useStreamingTiming.ts +68 -0
  206. package/src/react/types/scopes/interactables.ts +45 -23
  207. package/src/react/types/store-augmentation.ts +4 -2
  208. package/src/react/utils/useJSONEqual.ts +20 -0
  209. package/src/runtime/base/base-composer-runtime-core.ts +13 -0
  210. package/src/runtime/base/default-edit-composer-runtime-core.ts +18 -1
  211. package/src/runtime/base/default-thread-composer-runtime-core.ts +11 -1
  212. package/src/runtime/utils/message-repository.ts +11 -7
  213. package/src/runtime/utils/streaming-timing.test.ts +301 -0
  214. package/src/runtime/utils/streaming-timing.ts +161 -0
  215. package/src/runtime/utils/thread-message-like.ts +2 -1
  216. package/src/runtimes/external-store/external-store-adapter.ts +28 -0
  217. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +33 -1
  218. package/src/runtimes/local/local-thread-runtime-core.ts +4 -1
  219. package/src/runtimes/remote-thread-list/types.ts +13 -0
  220. package/src/tests/MessageRepository.test.ts +2 -0
  221. package/src/tests/composer-can-send.test.ts +1 -0
  222. package/src/tests/default-edit-composer-runtime-core.test.ts +78 -1
  223. package/src/tests/default-thread-composer-runtime-core.test.ts +82 -0
  224. package/src/tests/external-store-thread-runtime-core.test.ts +337 -0
  225. package/src/tests/groupParts.test.ts +32 -0
  226. package/src/tests/remote-thread-list-reactive-threadId.test.ts +90 -0
  227. package/src/tests/remote-thread-list-test-helpers.ts +2 -1
  228. package/src/tests/thread-message-like.test.ts +50 -0
  229. package/dist/react/model-context/useAssistantInteractable.d.ts +0 -22
  230. package/dist/react/model-context/useAssistantInteractable.d.ts.map +0 -1
  231. package/dist/react/model-context/useAssistantInteractable.js.map +0 -1
@@ -189,6 +189,16 @@ export class MessageRepository {
189
189
  return this.head?.current.id ?? null;
190
190
  }
191
191
 
192
+ get canonicalHeadId() {
193
+ // Optimistic messages are ephemeral, so persisted callers need the nearest
194
+ // non-optimistic ancestor rather than the raw head.
195
+ let head = this.head;
196
+ while (head?.current.metadata?.isOptimistic) {
197
+ head = head.prev;
198
+ }
199
+ return head?.current.id ?? null;
200
+ }
201
+
192
202
  getMessages(headId?: string) {
193
203
  if (headId === undefined || headId === this.head?.current.id) {
194
204
  return this._messages.value;
@@ -436,14 +446,8 @@ export class MessageRepository {
436
446
  });
437
447
  }
438
448
 
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
-
445
449
  return {
446
- headId: head?.current.id ?? null,
450
+ headId: this.canonicalHeadId,
447
451
  messages: exportItems,
448
452
  };
449
453
  }
@@ -0,0 +1,301 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ stepStreamingTiming,
4
+ type StreamingTimingAccessors,
5
+ } from "./streaming-timing";
6
+
7
+ type TestMessage = {
8
+ readonly id: string;
9
+ readonly role: "assistant" | "user";
10
+ readonly text: string;
11
+ readonly toolCalls: number;
12
+ };
13
+
14
+ const accessors: StreamingTimingAccessors<TestMessage> = {
15
+ getAssistantMessageId: (messages) => {
16
+ for (let i = messages.length - 1; i >= 0; i--) {
17
+ if (messages[i]!.role === "assistant") return messages[i]!.id;
18
+ }
19
+ return undefined;
20
+ },
21
+ getTextLength: (messages, id) =>
22
+ messages.find((m) => m.id === id)?.text.length ?? 0,
23
+ getToolCallCount: (messages, id) =>
24
+ messages.find((m) => m.id === id)?.toolCalls ?? 0,
25
+ };
26
+
27
+ const makeClock = () => {
28
+ let t = 0;
29
+ return () => (t += 10);
30
+ };
31
+
32
+ const msg = (id: string, text: string, toolCalls = 0): TestMessage => ({
33
+ id,
34
+ role: "assistant",
35
+ text,
36
+ toolCalls,
37
+ });
38
+
39
+ describe("stepStreamingTiming", () => {
40
+ it("is idle when not running and there is no state", () => {
41
+ const now = makeClock();
42
+ expect(
43
+ stepStreamingTiming(null, [], false, accessors, undefined, now),
44
+ ).toEqual({ state: null, timings: {} });
45
+ });
46
+
47
+ it("starts tracking on the first running update and counts content growth as chunks", () => {
48
+ const now = makeClock();
49
+ const r1 = stepStreamingTiming(
50
+ null,
51
+ [msg("m1", "")],
52
+ true,
53
+ accessors,
54
+ undefined,
55
+ now,
56
+ );
57
+ // No growth yet (length 0), state is initialised but no chunks.
58
+ expect(r1.timings).toEqual({});
59
+ expect(r1.state).toMatchObject({ messageId: "m1", totalChunks: 0 });
60
+
61
+ const r2 = stepStreamingTiming(
62
+ r1.state,
63
+ [msg("m1", "Hello")],
64
+ true,
65
+ accessors,
66
+ undefined,
67
+ now,
68
+ );
69
+ expect(r2.state).toMatchObject({
70
+ messageId: "m1",
71
+ totalChunks: 1,
72
+ lastContentLength: 5,
73
+ firstTokenTime: 10,
74
+ });
75
+ expect(r2.timings).toEqual({});
76
+ });
77
+
78
+ it("finalizes timing when streaming stops and counts the final chunk", () => {
79
+ const now = makeClock();
80
+ const r1 = stepStreamingTiming(
81
+ null,
82
+ [msg("m1", "a")],
83
+ true,
84
+ accessors,
85
+ undefined,
86
+ now,
87
+ );
88
+ const r2 = stepStreamingTiming(
89
+ r1.state,
90
+ [msg("m1", "ab")],
91
+ true,
92
+ accessors,
93
+ undefined,
94
+ now,
95
+ );
96
+ // Final content delta arrives in the SAME update as isRunning -> false.
97
+ // The finalize re-reads the final length (3) rather than the last delta.
98
+ const r3 = stepStreamingTiming(
99
+ r2.state,
100
+ [msg("m1", "abc")],
101
+ false,
102
+ accessors,
103
+ undefined,
104
+ now,
105
+ );
106
+ expect(r3.state).toBeNull();
107
+ const timing = r3.timings["m1"]!;
108
+ expect(timing).toBeDefined();
109
+ // The final delta ("abc", arrived with the stop signal) is reconciled,
110
+ // so totalChunks and tokenCount count the same three-character content.
111
+ expect(timing.totalChunks).toBe(3);
112
+ expect(timing.tokenCount).toBe(Math.ceil(3 / 4));
113
+ expect(timing.toolCallCount).toBe(0);
114
+ expect(timing.tokensPerSecond).toBeCloseTo(
115
+ timing.tokenCount! / (timing.totalStreamTime! / 1000),
116
+ );
117
+ });
118
+
119
+ it("reconciles totalChunks and tokenCount when the only growth lands at finalize", () => {
120
+ const now = makeClock();
121
+ // Running the whole time with empty content; the text arrives only with
122
+ // the stop signal.
123
+ const r1 = stepStreamingTiming(
124
+ null,
125
+ [msg("m1", "")],
126
+ true,
127
+ accessors,
128
+ undefined,
129
+ now,
130
+ );
131
+ const r2 = stepStreamingTiming(
132
+ r1.state,
133
+ [msg("m1", "done")],
134
+ false,
135
+ accessors,
136
+ undefined,
137
+ now,
138
+ );
139
+ const timing = r2.timings["m1"]!;
140
+ expect(timing.totalChunks).toBe(1);
141
+ expect(timing.tokenCount).toBe(Math.ceil(4 / 4));
142
+ expect(timing.firstTokenTime).toBe(timing.totalStreamTime);
143
+ });
144
+
145
+ it("does not finalize while still running", () => {
146
+ const now = makeClock();
147
+ const r1 = stepStreamingTiming(
148
+ null,
149
+ [msg("m1", "content")],
150
+ true,
151
+ accessors,
152
+ undefined,
153
+ now,
154
+ );
155
+ const r2 = stepStreamingTiming(
156
+ r1.state,
157
+ [msg("m1", "content")],
158
+ true,
159
+ accessors,
160
+ undefined,
161
+ now,
162
+ );
163
+ expect(r2.timings).toEqual({});
164
+ expect(r2.state).not.toBeNull();
165
+ });
166
+
167
+ it("resets tracking when the assistant message id changes mid-stream", () => {
168
+ const now = makeClock();
169
+ const r1 = stepStreamingTiming(
170
+ null,
171
+ [msg("m1", "hi")],
172
+ true,
173
+ accessors,
174
+ undefined,
175
+ now,
176
+ );
177
+ const r2 = stepStreamingTiming(
178
+ r1.state,
179
+ [msg("m1", "hi"), msg("m2", "")],
180
+ true,
181
+ accessors,
182
+ undefined,
183
+ now,
184
+ );
185
+ expect(r2.state?.messageId).toBe("m2");
186
+ expect(r2.state?.totalChunks).toBe(0);
187
+ expect(r2.state?.lastContentLength).toBe(0);
188
+ });
189
+
190
+ it("records firstTokenTime only once, at the first content growth", () => {
191
+ const now = makeClock();
192
+ const r1 = stepStreamingTiming(
193
+ null,
194
+ [msg("m1", "")],
195
+ true,
196
+ accessors,
197
+ undefined,
198
+ now,
199
+ );
200
+ const r2 = stepStreamingTiming(
201
+ r1.state,
202
+ [msg("m1", "first")],
203
+ true,
204
+ accessors,
205
+ undefined,
206
+ now,
207
+ );
208
+ expect(r2.state?.firstTokenTime).toBe(10);
209
+ const r3 = stepStreamingTiming(
210
+ r2.state,
211
+ [msg("m1", "first second")],
212
+ true,
213
+ accessors,
214
+ undefined,
215
+ now,
216
+ );
217
+ // firstTokenTime unchanged on later growth.
218
+ expect(r3.state?.firstTokenTime).toBe(10);
219
+ expect(r3.state?.totalChunks).toBe(2);
220
+ });
221
+
222
+ it("counts tool calls from the final message", () => {
223
+ const now = makeClock();
224
+ const r1 = stepStreamingTiming(
225
+ null,
226
+ [msg("m1", "a", 2)],
227
+ true,
228
+ accessors,
229
+ undefined,
230
+ now,
231
+ );
232
+ const r2 = stepStreamingTiming(
233
+ r1.state,
234
+ [msg("m1", "ab", 2)],
235
+ false,
236
+ accessors,
237
+ undefined,
238
+ now,
239
+ );
240
+ expect(r2.timings["m1"]?.toolCallCount).toBe(2);
241
+ });
242
+
243
+ it("omits tokenCount and tokensPerSecond when the final text is empty", () => {
244
+ const now = makeClock();
245
+ const r1 = stepStreamingTiming(
246
+ null,
247
+ [msg("m1", "")],
248
+ true,
249
+ accessors,
250
+ undefined,
251
+ now,
252
+ );
253
+ const r2 = stepStreamingTiming(
254
+ r1.state,
255
+ [msg("m1", "")],
256
+ false,
257
+ accessors,
258
+ undefined,
259
+ now,
260
+ );
261
+ const timing = r2.timings["m1"]!;
262
+ expect(timing.tokenCount).toBeUndefined();
263
+ expect(timing.tokensPerSecond).toBeUndefined();
264
+ expect(timing.totalChunks).toBe(0);
265
+ });
266
+
267
+ it("respects a custom estimateTokens option", () => {
268
+ const now = makeClock();
269
+ const r1 = stepStreamingTiming(
270
+ null,
271
+ [msg("m1", "abcdefgh")],
272
+ true,
273
+ accessors,
274
+ { estimateTokens: (n) => Math.floor(n / 2) },
275
+ now,
276
+ );
277
+ const r2 = stepStreamingTiming(
278
+ r1.state,
279
+ [msg("m1", "abcdefgh")],
280
+ false,
281
+ accessors,
282
+ { estimateTokens: (n) => Math.floor(n / 2) },
283
+ now,
284
+ );
285
+ expect(r2.timings["m1"]?.tokenCount).toBe(4);
286
+ });
287
+
288
+ it("produces no timings when running but no assistant message is present", () => {
289
+ const now = makeClock();
290
+ const r = stepStreamingTiming(
291
+ null,
292
+ [{ id: "u1", role: "user", text: "hi", toolCalls: 0 }],
293
+ true,
294
+ accessors,
295
+ undefined,
296
+ now,
297
+ );
298
+ expect(r.state).toBeNull();
299
+ expect(r.timings).toEqual({});
300
+ });
301
+ });
@@ -0,0 +1,161 @@
1
+ import type { MessageTiming } from "../../types/message";
2
+
3
+ /**
4
+ * Shape-specific accessors that adapt {@link stepStreamingTiming} /
5
+ * {@link useStreamingTiming} to a runtime's message list. Each adapter
6
+ * provides three pure functions over its own message type; the timing state
7
+ * machine itself lives once in core.
8
+ */
9
+ export type StreamingTimingAccessors<TMessage> = {
10
+ /** Resolve the id of the last assistant message, or `undefined` if none. */
11
+ readonly getAssistantMessageId: (
12
+ messages: readonly TMessage[],
13
+ ) => string | undefined;
14
+ /** Total text length (incl. reasoning/thinking) of the assistant message. */
15
+ readonly getTextLength: (
16
+ messages: readonly TMessage[],
17
+ messageId: string,
18
+ ) => number;
19
+ /** Number of tool calls recorded on the assistant message. */
20
+ readonly getToolCallCount: (
21
+ messages: readonly TMessage[],
22
+ messageId: string,
23
+ ) => number;
24
+ };
25
+
26
+ export type StreamingTimingOptions = {
27
+ /**
28
+ * Estimate token count from a text length. Defaults to `Math.ceil(n / 4)`.
29
+ * Adapters with real usage data can override this.
30
+ */
31
+ readonly estimateTokens?: (textLength: number) => number;
32
+ };
33
+
34
+ /**
35
+ * Mutable tracking state for a single in-flight assistant message. Kept
36
+ * between updates by the caller (a ref in the React hook); `null` means no
37
+ * message is being tracked.
38
+ *
39
+ * `totalChunks` counts content growth deltas observed while streaming. The
40
+ * final delta that lands in the same update as the `isRunning -> false`
41
+ * transition is reconciled at finalize, so `totalChunks` and the token
42
+ * estimate (derived from final length) count the same content.
43
+ */
44
+ export type StreamingTimingState = {
45
+ readonly messageId: string;
46
+ readonly startTime: number;
47
+ readonly firstTokenTime?: number;
48
+ readonly lastContentLength: number;
49
+ readonly totalChunks: number;
50
+ };
51
+
52
+ const defaultEstimateTokens = (textLength: number): number =>
53
+ Math.ceil(textLength / 4);
54
+
55
+ /**
56
+ * Apply one content growth delta to `state`, recording the first-token time
57
+ * on the first growth and bumping the chunk count. Returns `state` unchanged
58
+ * when `len` has not grown.
59
+ */
60
+ const applyGrowth = (
61
+ state: StreamingTimingState,
62
+ len: number,
63
+ now: () => number,
64
+ ): StreamingTimingState => {
65
+ if (len <= state.lastContentLength) return state;
66
+ return {
67
+ ...state,
68
+ firstTokenTime: state.firstTokenTime ?? now() - state.startTime,
69
+ lastContentLength: len,
70
+ totalChunks: state.totalChunks + 1,
71
+ };
72
+ };
73
+
74
+ /**
75
+ * Advance the client-side streaming timing tracker by one update.
76
+ *
77
+ * Pure: given the previous state and the current `(messages, isRunning)`
78
+ * snapshot, returns the next state plus any `MessageTiming` finalized this
79
+ * update (empty when streaming is still in flight). The caller owns the
80
+ * state (e.g. a ref) and merges finalized timings into its store.
81
+ *
82
+ * At finalize the text length is recomputed from the final messages, and any
83
+ * final growth delta that landed in the same update as the
84
+ * `isRunning -> false` transition is reconciled, so the token estimate and
85
+ * `totalChunks` count the same content (the per-adapter hooks this replaces
86
+ * reuse a stale growth delta and miss that last chunk).
87
+ */
88
+ export const stepStreamingTiming = <TMessage>(
89
+ state: StreamingTimingState | null,
90
+ messages: readonly TMessage[],
91
+ isRunning: boolean,
92
+ accessors: StreamingTimingAccessors<TMessage>,
93
+ options: StreamingTimingOptions | undefined,
94
+ now: () => number = Date.now,
95
+ ): {
96
+ readonly state: StreamingTimingState | null;
97
+ readonly timings: Record<string, MessageTiming>;
98
+ } => {
99
+ const lastId = accessors.getAssistantMessageId(messages);
100
+ const estimateTokens = options?.estimateTokens ?? defaultEstimateTokens;
101
+
102
+ if (isRunning && lastId !== undefined) {
103
+ let next: StreamingTimingState;
104
+ if (state === null || state.messageId !== lastId) {
105
+ next = {
106
+ messageId: lastId,
107
+ startTime: now(),
108
+ lastContentLength: 0,
109
+ totalChunks: 0,
110
+ };
111
+ } else {
112
+ next = state;
113
+ }
114
+
115
+ return {
116
+ state: applyGrowth(
117
+ next,
118
+ accessors.getTextLength(messages, next.messageId),
119
+ now,
120
+ ),
121
+ timings: {},
122
+ };
123
+ }
124
+
125
+ if (!isRunning && state !== null) {
126
+ const nowMs = now();
127
+ // Reconcile any final growth that landed with the stop signal so the
128
+ // token estimate (derived from final length) and totalChunks agree.
129
+ const reconciled = applyGrowth(
130
+ state,
131
+ accessors.getTextLength(messages, state.messageId),
132
+ () => nowMs,
133
+ );
134
+
135
+ const totalStreamTime = nowMs - reconciled.startTime;
136
+ const tokenCount = estimateTokens(reconciled.lastContentLength);
137
+ const toolCallCount = accessors.getToolCallCount(
138
+ messages,
139
+ reconciled.messageId,
140
+ );
141
+
142
+ const timing: MessageTiming = {
143
+ streamStartTime: reconciled.startTime,
144
+ totalStreamTime,
145
+ totalChunks: reconciled.totalChunks,
146
+ toolCallCount,
147
+ ...(reconciled.firstTokenTime !== undefined && {
148
+ firstTokenTime: reconciled.firstTokenTime,
149
+ }),
150
+ ...(tokenCount > 0 && { tokenCount }),
151
+ ...(totalStreamTime > 0 &&
152
+ tokenCount > 0 && {
153
+ tokensPerSecond: tokenCount / (totalStreamTime / 1000),
154
+ }),
155
+ };
156
+
157
+ return { state: null, timings: { [reconciled.messageId]: timing } };
158
+ }
159
+
160
+ return { state, timings: {} };
161
+ };
@@ -127,6 +127,7 @@ export const fromThreadMessageLike = (
127
127
  image,
128
128
  ...rest
129
129
  }: ImageMessagePart): ImageMessagePart | null => {
130
+ if (typeof image !== "string") return null;
130
131
  const dataUri = image.match(
131
132
  /^data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,(.*)$/,
132
133
  );
@@ -160,7 +161,7 @@ export const fromThreadMessageLike = (
160
161
  switch (type) {
161
162
  case "text":
162
163
  case "reasoning":
163
- if (part.text.trim().length === 0) return null;
164
+ if (!part.text?.trim()) return null;
164
165
  return part;
165
166
 
166
167
  case "file":
@@ -64,6 +64,14 @@ export type ExternalStoreMessageConverter<T> = (
64
64
  idx: number,
65
65
  ) => ThreadMessageLike;
66
66
 
67
+ /**
68
+ * @deprecated This API is still under active development and might change without notice.
69
+ */
70
+ export type ExternalStoreBranchChange = {
71
+ headId: string | null;
72
+ visibleMessageIds: readonly string[];
73
+ };
74
+
67
75
  type ExternalStoreMessageConverterAdapter<T> = {
68
76
  convertMessage: ExternalStoreMessageConverter<T>;
69
77
  };
@@ -102,6 +110,26 @@ type ExternalStoreAdapterBase<T> = {
102
110
  extras?: unknown;
103
111
 
104
112
  setMessages?: ((messages: readonly T[]) => void) | undefined;
113
+ /**
114
+ * Fires when the user explicitly switches branches via the runtime's
115
+ * `switchToBranch` action (e.g. a BranchPicker click). It does not fire on
116
+ * adapter resync, `append`, edit/regenerate, content-only updates, or while
117
+ * the thread is running. Consecutive switches that resolve to the same
118
+ * canonical head are de-duped.
119
+ *
120
+ * `headId` is the canonical (persisted) head of the now-visible branch —
121
+ * optimistic/transient ids are never surfaced. `visibleMessageIds` lists the
122
+ * visible path in order.
123
+ *
124
+ * This complements `setMessages` rather than replacing it: switching still
125
+ * requires `setMessages`, and this callback does not on its own enable branch
126
+ * switching.
127
+ *
128
+ * @deprecated This API is still under active development and might change without notice.
129
+ */
130
+ unstable_onBranchChange?:
131
+ | ((event: ExternalStoreBranchChange) => void)
132
+ | undefined;
105
133
  onImport?: ((messages: readonly ThreadMessage[]) => void) | undefined;
106
134
  onExportExternalState?: (() => any) | undefined;
107
135
  onLoadExternalState?: ((state: any) => void) | undefined;
@@ -8,7 +8,10 @@ import type {
8
8
  ThreadSuggestion,
9
9
  } from "../../runtime/interfaces/thread-runtime-core";
10
10
 
11
- import type { ExternalStoreAdapter } from "./external-store-adapter";
11
+ import type {
12
+ ExternalStoreAdapter,
13
+ ExternalStoreBranchChange,
14
+ } from "./external-store-adapter";
12
15
  import {
13
16
  getExternalStoreMessages,
14
17
  bindExternalStoreMessage,
@@ -405,8 +408,37 @@ export class ExternalStoreThreadRuntimeCore
405
408
  return;
406
409
  }
407
410
 
411
+ const onBranchChange = this._store.unstable_onBranchChange;
412
+ const previousHeadId = onBranchChange
413
+ ? this.repository.canonicalHeadId
414
+ : null;
415
+
408
416
  this.repository.switchToBranch(branchId);
409
417
  this.updateMessages(this.repository.getMessages());
418
+ if (onBranchChange) {
419
+ this._notifyBranchChange(previousHeadId, onBranchChange);
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Emit `unstable_onBranchChange` for an explicit branch switch. Reads the
425
+ * canonical head from the repository (which skips optimistic/transient
426
+ * messages) and de-dupes switches that leave the canonical head unchanged.
427
+ * Comparing against the head observed just before the switch — rather than the
428
+ * last emitted head — keeps a switch firing after an adapter resync moved the
429
+ * head elsewhere in the meantime.
430
+ */
431
+ private _notifyBranchChange(
432
+ previousHeadId: string | null,
433
+ onBranchChange: (event: ExternalStoreBranchChange) => void,
434
+ ): void {
435
+ const headId = this.repository.canonicalHeadId;
436
+ if (headId === previousHeadId) return;
437
+
438
+ onBranchChange({
439
+ headId,
440
+ visibleMessageIds: this.repository.getMessages().map((m) => m.id),
441
+ });
410
442
  }
411
443
 
412
444
  public async append(message: AppendMessage): Promise<void> {
@@ -493,7 +493,10 @@ export class LocalThreadRuntimeCore
493
493
 
494
494
  try {
495
495
  this._lastRunConfig = runConfig ?? {};
496
- const context = this.getModelContext();
496
+ // unstable_composerMetadata is composer-only (stamped onto the outgoing
497
+ // message); never expose it to the chat-model adapter's run context.
498
+ const { unstable_composerMetadata: _, ...context } =
499
+ this.getModelContext();
497
500
 
498
501
  runCallback =
499
502
  runCallback ??
@@ -77,6 +77,19 @@ export type RemoteThreadListOptions = {
77
77
  */
78
78
  threadId?: string | undefined;
79
79
 
80
+ /**
81
+ * Called whenever the active thread's canonical (remote) ID changes, so the
82
+ * value can be treated as a managed/controlled variable (e.g. synced to a
83
+ * URL query param). Together with `threadId` this forms the controlled
84
+ * pattern: `threadId` in, `onThreadIdChange` out.
85
+ *
86
+ * Only the settled remote ID is emitted: while a freshly created thread is
87
+ * still optimistic (no remote ID yet) the value is `undefined`, and the real
88
+ * ID is emitted once the thread is initialized. The transient local ID is
89
+ * never surfaced.
90
+ */
91
+ onThreadIdChange?: ((threadId: string | undefined) => void) | undefined;
92
+
80
93
  /**
81
94
  * When true, if this runtime is used inside another RemoteThreadListRuntime,
82
95
  * it becomes a no-op and simply calls the runtimeHook directly.
@@ -302,6 +302,8 @@ describe("MessageRepository", () => {
302
302
  const exported = repository.export();
303
303
 
304
304
  expect(exported.messages.map((m) => m.message.id)).toEqual(["u"]);
305
+ expect(repository.headId).toBe("placeholder");
306
+ expect(repository.canonicalHeadId).toBe("u");
305
307
  // head was the optimistic placeholder; the exported head must fall back
306
308
  // to the nearest persisted ancestor so it always resolves on import.
307
309
  expect(exported.headId).toBe("u");
@@ -15,6 +15,7 @@ const makeRuntimeStub = (
15
15
  const stub = {
16
16
  append: vi.fn(),
17
17
  cancelRun: vi.fn(),
18
+ getModelContext: () => ({}),
18
19
  subscribe: (cb: () => void) => {
19
20
  subscribers.add(cb);
20
21
  return () => subscribers.delete(cb);