@assistant-ui/core 0.3.2 → 0.3.4

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 (169) hide show
  1. package/dist/index.d.ts +4 -4
  2. package/dist/index.js.map +1 -1
  3. package/dist/internal.d.ts +9 -7
  4. package/dist/internal.js +8 -6
  5. package/dist/react/primitive-hooks/useActionBarCopy.js +51 -24
  6. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  7. package/dist/react/primitive-hooks/useSuggestionTrigger.js +29 -25
  8. package/dist/react/primitive-hooks/useSuggestionTrigger.js.map +1 -1
  9. package/dist/react/primitives/message/MessageGroupedParts.d.ts +2 -1
  10. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
  11. package/dist/react/primitives/message/MessageGroupedParts.js +2 -2
  12. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  13. package/dist/react/primitives/message/MessageParts.d.ts +6 -1
  14. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  15. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  16. package/dist/react/providers/ReadonlyThreadProvider.d.ts.map +1 -1
  17. package/dist/react/providers/ReadonlyThreadProvider.js +1 -0
  18. package/dist/react/providers/ReadonlyThreadProvider.js.map +1 -1
  19. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +67 -0
  20. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  21. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +76 -15
  22. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  23. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +4 -0
  24. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  25. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +21 -0
  26. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  27. package/dist/react/runtimes/cloud/auiV0.d.ts +26 -2
  28. package/dist/react/runtimes/cloud/auiV0.d.ts.map +1 -1
  29. package/dist/react/runtimes/cloud/auiV0.js +29 -8
  30. package/dist/react/runtimes/cloud/auiV0.js.map +1 -1
  31. package/dist/react/types/MessagePartComponentTypes.d.ts +8 -0
  32. package/dist/react/types/MessagePartComponentTypes.d.ts.map +1 -1
  33. package/dist/runtime/api/bindings.d.ts +5 -0
  34. package/dist/runtime/api/bindings.d.ts.map +1 -1
  35. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  36. package/dist/runtime/api/message-runtime.js +5 -1
  37. package/dist/runtime/api/message-runtime.js.map +1 -1
  38. package/dist/runtime/api/thread-list-runtime.d.ts +14 -1
  39. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  40. package/dist/runtime/api/thread-list-runtime.js +6 -1
  41. package/dist/runtime/api/thread-list-runtime.js.map +1 -1
  42. package/dist/runtime/api/thread-runtime.d.ts +7 -1
  43. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  44. package/dist/runtime/api/thread-runtime.js +11 -3
  45. package/dist/runtime/api/thread-runtime.js.map +1 -1
  46. package/dist/runtime/base/base-composer-runtime-core.d.ts +3 -0
  47. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  48. package/dist/runtime/base/base-composer-runtime-core.js +54 -12
  49. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  50. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +10 -0
  51. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
  52. package/dist/runtime/interfaces/thread-runtime-core.d.ts +14 -0
  53. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  54. package/dist/runtime/internal.d.ts +4 -4
  55. package/dist/runtime/internal.js +4 -4
  56. package/dist/runtimes/external-store/external-store-adapter.d.ts +9 -0
  57. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  58. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts +1 -0
  59. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  60. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js +4 -0
  61. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  62. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +1 -0
  63. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  64. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +6 -0
  65. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  66. package/dist/runtimes/local/local-thread-runtime-core.d.ts +1 -0
  67. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  68. package/dist/runtimes/local/local-thread-runtime-core.js +1 -0
  69. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  70. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -0
  71. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  72. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +1 -0
  73. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  74. package/dist/runtimes/remote-thread-list/empty-thread-core.js +1 -0
  75. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  76. package/dist/store/clients/chain-of-thought-client.d.ts.map +1 -1
  77. package/dist/store/clients/chain-of-thought-client.js +29 -23
  78. package/dist/store/clients/chain-of-thought-client.js.map +1 -1
  79. package/dist/store/clients/thread-message-client.d.ts.map +1 -1
  80. package/dist/store/clients/thread-message-client.js +101 -89
  81. package/dist/store/clients/thread-message-client.js.map +1 -1
  82. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts +1 -0
  83. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  84. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +66 -45
  85. package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
  86. package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
  87. package/dist/store/runtime-clients/thread-list-runtime-client.js +91 -75
  88. package/dist/store/runtime-clients/thread-list-runtime-client.js.map +1 -1
  89. package/dist/store/scopes/thread-list-item.d.ts +7 -0
  90. package/dist/store/scopes/thread-list-item.d.ts.map +1 -1
  91. package/dist/store/scopes/threads.d.ts +1 -0
  92. package/dist/store/scopes/threads.d.ts.map +1 -1
  93. package/dist/types/index.d.ts +2 -2
  94. package/dist/types/message.d.ts +22 -1
  95. package/dist/types/message.d.ts.map +1 -1
  96. package/dist/types/message.js.map +1 -1
  97. package/dist/utils/data-url.d.ts +14 -1
  98. package/dist/utils/data-url.d.ts.map +1 -1
  99. package/dist/utils/data-url.js +23 -1
  100. package/dist/utils/data-url.js.map +1 -1
  101. package/dist/utils/getGroupStatus.d.ts +9 -0
  102. package/dist/utils/getGroupStatus.d.ts.map +1 -0
  103. package/dist/utils/getGroupStatus.js +15 -0
  104. package/dist/utils/getGroupStatus.js.map +1 -0
  105. package/dist/utils/image-media-type.d.ts +13 -0
  106. package/dist/utils/image-media-type.d.ts.map +1 -0
  107. package/dist/utils/image-media-type.js +141 -0
  108. package/dist/utils/image-media-type.js.map +1 -0
  109. package/dist/utils/normalizePartStatus.d.ts +8 -0
  110. package/dist/utils/normalizePartStatus.d.ts.map +1 -0
  111. package/dist/utils/normalizePartStatus.js +39 -0
  112. package/dist/utils/normalizePartStatus.js.map +1 -0
  113. package/dist/utils/wire-media.d.ts +35 -0
  114. package/dist/utils/wire-media.d.ts.map +1 -0
  115. package/dist/utils/wire-media.js +55 -0
  116. package/dist/utils/wire-media.js.map +1 -0
  117. package/package.json +4 -4
  118. package/src/index.ts +1 -0
  119. package/src/internal.ts +13 -1
  120. package/src/react/primitive-hooks/useActionBarCopy.test.ts +109 -14
  121. package/src/react/primitive-hooks/useActionBarCopy.ts +27 -2
  122. package/src/react/primitive-hooks/useSuggestionTrigger.test.ts +148 -0
  123. package/src/react/primitive-hooks/useSuggestionTrigger.ts +10 -4
  124. package/src/react/primitives/message/MessageGroupedParts.tsx +4 -4
  125. package/src/react/primitives/message/MessageParts.tsx +6 -1
  126. package/src/react/providers/ReadonlyThreadProvider.tsx +1 -0
  127. package/src/react/runtimes/RemoteThreadListHookInstanceManager.running.test.tsx +157 -0
  128. package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +160 -0
  129. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +135 -32
  130. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +37 -0
  131. package/src/react/runtimes/cloud/auiV0.ts +59 -14
  132. package/src/react/types/MessagePartComponentTypes.ts +8 -0
  133. package/src/runtime/api/bindings.ts +5 -0
  134. package/src/runtime/api/message-runtime.test.ts +140 -0
  135. package/src/runtime/api/message-runtime.ts +10 -5
  136. package/src/runtime/api/thread-list-runtime.ts +22 -1
  137. package/src/runtime/api/thread-runtime.ts +15 -6
  138. package/src/runtime/base/base-composer-runtime-core.ts +72 -19
  139. package/src/runtime/interfaces/thread-list-runtime-core.ts +11 -0
  140. package/src/runtime/interfaces/thread-runtime-core.ts +15 -0
  141. package/src/runtimes/external-store/external-store-adapter.ts +9 -0
  142. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +9 -0
  143. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +8 -0
  144. package/src/runtimes/local/local-thread-runtime-core.ts +1 -0
  145. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +1 -0
  146. package/src/runtimes/remote-thread-list/empty-thread-core.ts +1 -0
  147. package/src/store/clients/chain-of-thought-client.ts +2 -7
  148. package/src/store/clients/thread-message-client.test.ts +84 -0
  149. package/src/store/clients/thread-message-client.ts +14 -4
  150. package/src/store/runtime-clients/thread-list-item-runtime-client.ts +13 -2
  151. package/src/store/runtime-clients/thread-list-runtime-client.ts +13 -1
  152. package/src/store/scopes/thread-list-item.ts +7 -0
  153. package/src/store/scopes/threads.ts +1 -0
  154. package/src/tests/RemoteThreadListThreadListRuntimeCore-reloadMainThread.test.ts +257 -0
  155. package/src/tests/auiV0Encode.test.ts +218 -1
  156. package/src/tests/base-composer-runtime-core-addAttachment.test.ts +296 -0
  157. package/src/tests/event-subscription-listener-errors.test.ts +1 -0
  158. package/src/tests/external-store-thread-list-runtime-core.test.ts +39 -1
  159. package/src/types/index.ts +1 -0
  160. package/src/types/message.ts +30 -0
  161. package/src/utils/data-url.ts +24 -0
  162. package/src/utils/getGroupStatus.test.ts +36 -0
  163. package/src/utils/getGroupStatus.ts +31 -0
  164. package/src/utils/image-media-type.test.ts +96 -0
  165. package/src/utils/image-media-type.ts +118 -0
  166. package/src/utils/normalizePartStatus.test.ts +59 -0
  167. package/src/utils/normalizePartStatus.ts +55 -0
  168. package/src/utils/wire-media.test.ts +96 -0
  169. package/src/utils/wire-media.ts +70 -0
@@ -0,0 +1,148 @@
1
+ /** @vitest-environment jsdom */
2
+ import { cleanup, renderHook } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ const mocks = vi.hoisted(() => {
6
+ const append = vi.fn();
7
+ const setText = vi.fn();
8
+
9
+ const state = {
10
+ thread: {
11
+ isDisabled: false,
12
+ isRunning: false,
13
+ capabilities: { queue: false },
14
+ },
15
+ };
16
+ const composerState = {
17
+ text: "",
18
+ runConfig: { custom: { model: "gpt-test" } },
19
+ };
20
+
21
+ return {
22
+ append,
23
+ setText,
24
+ state,
25
+ composerState,
26
+ aui: {
27
+ thread: {
28
+ getState: () => state.thread,
29
+ append,
30
+ },
31
+ composer: {
32
+ getState: () => composerState,
33
+ setText,
34
+ },
35
+ },
36
+ };
37
+ });
38
+
39
+ vi.mock("@assistant-ui/store", async (importOriginal) => ({
40
+ ...(await importOriginal<typeof import("@assistant-ui/store")>()),
41
+ useAui: () => mocks.aui,
42
+ useAuiState: ((selector: (state: typeof mocks.state) => unknown) =>
43
+ selector(mocks.state)) as typeof import("@assistant-ui/store").useAuiState,
44
+ }));
45
+
46
+ import { useSuggestionTrigger } from "./useSuggestionTrigger";
47
+
48
+ afterEach(() => {
49
+ cleanup();
50
+ vi.clearAllMocks();
51
+ mocks.state.thread.isDisabled = false;
52
+ mocks.state.thread.isRunning = false;
53
+ mocks.state.thread.capabilities = { queue: false };
54
+ mocks.composerState.text = "";
55
+ });
56
+
57
+ describe("useSuggestionTrigger", () => {
58
+ it("appends the prompt and clears the composer when sending while idle", () => {
59
+ const { result } = renderHook(() =>
60
+ useSuggestionTrigger({ prompt: "Hello", send: true }),
61
+ );
62
+
63
+ result.current.trigger();
64
+
65
+ expect(result.current.disabled).toBe(false);
66
+ expect(mocks.append).toHaveBeenCalledWith({
67
+ content: [{ type: "text", text: "Hello" }],
68
+ runConfig: { custom: { model: "gpt-test" } },
69
+ });
70
+ expect(mocks.setText).toHaveBeenCalledWith("");
71
+ });
72
+
73
+ it("disables and no-ops when sending while running without queue support", () => {
74
+ mocks.state.thread.isRunning = true;
75
+ mocks.composerState.text = "my draft";
76
+ const { result } = renderHook(() =>
77
+ useSuggestionTrigger({ prompt: "Hello", send: true }),
78
+ );
79
+
80
+ result.current.trigger();
81
+
82
+ expect(result.current.disabled).toBe(true);
83
+ expect(mocks.append).not.toHaveBeenCalled();
84
+ expect(mocks.setText).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it("queues without touching the composer while running when the thread supports queueing", () => {
88
+ mocks.state.thread.isRunning = true;
89
+ mocks.state.thread.capabilities = { queue: true };
90
+ mocks.composerState.text = "my draft";
91
+ const { result } = renderHook(() =>
92
+ useSuggestionTrigger({ prompt: "Hello", send: true }),
93
+ );
94
+
95
+ result.current.trigger();
96
+
97
+ expect(result.current.disabled).toBe(false);
98
+ expect(mocks.append).toHaveBeenCalledWith({
99
+ content: [{ type: "text", text: "Hello" }],
100
+ runConfig: { custom: { model: "gpt-test" } },
101
+ });
102
+ expect(mocks.setText).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("replaces the composer text when send is false, even while running", () => {
106
+ mocks.state.thread.isRunning = true;
107
+ mocks.composerState.text = "my draft";
108
+ const { result } = renderHook(() =>
109
+ useSuggestionTrigger({ prompt: "Hello" }),
110
+ );
111
+
112
+ result.current.trigger();
113
+
114
+ expect(result.current.disabled).toBe(false);
115
+ expect(mocks.append).not.toHaveBeenCalled();
116
+ expect(mocks.setText).toHaveBeenCalledWith("Hello");
117
+ });
118
+
119
+ it("appends to the composer text when clearComposer is false", () => {
120
+ mocks.composerState.text = "my draft";
121
+ const { result } = renderHook(() =>
122
+ useSuggestionTrigger({ prompt: "Hello", clearComposer: false }),
123
+ );
124
+
125
+ result.current.trigger();
126
+
127
+ expect(mocks.setText).toHaveBeenCalledWith("my draft Hello");
128
+ });
129
+
130
+ it("inserts the prompt alone when the composer is empty and clearComposer is false", () => {
131
+ const { result } = renderHook(() =>
132
+ useSuggestionTrigger({ prompt: "Hello", clearComposer: false }),
133
+ );
134
+
135
+ result.current.trigger();
136
+
137
+ expect(mocks.setText).toHaveBeenCalledWith("Hello");
138
+ });
139
+
140
+ it("disables when the thread is disabled", () => {
141
+ mocks.state.thread.isDisabled = true;
142
+ const { result } = renderHook(() =>
143
+ useSuggestionTrigger({ prompt: "Hello" }),
144
+ );
145
+
146
+ expect(result.current.disabled).toBe(true);
147
+ });
148
+ });
@@ -13,18 +13,24 @@ export const useSuggestionTrigger = ({
13
13
  clearComposer = true,
14
14
  }: UseSuggestionTriggerOptions) => {
15
15
  const aui = useAui();
16
- const disabled = useAuiState((s) => s.thread.isDisabled);
17
16
  const resolvedSend = send ?? false;
17
+ const disabled = useAuiState(
18
+ (s) =>
19
+ s.thread.isDisabled ||
20
+ (resolvedSend && s.thread.isRunning && !s.thread.capabilities.queue),
21
+ );
18
22
 
19
23
  const trigger = useCallback(() => {
20
- const isRunning = aui.thread.getState().isRunning;
24
+ if (resolvedSend) {
25
+ const { isRunning, capabilities } = aui.thread.getState();
26
+ if (isRunning && !capabilities.queue) return;
21
27
 
22
- if (resolvedSend && !isRunning) {
23
28
  aui.thread.append({
24
29
  content: [{ type: "text", text: prompt }],
25
30
  runConfig: aui.composer.getState().runConfig,
26
31
  });
27
- if (clearComposer) {
32
+ // A queued send must not clear the draft the user is still composing.
33
+ if (clearComposer && !isRunning) {
28
34
  aui.composer.setText("");
29
35
  }
30
36
  } else {
@@ -8,6 +8,7 @@ import type {
8
8
  MessagePartStatus,
9
9
  ToolCallMessagePartStatus,
10
10
  } from "../../../types/message";
11
+ import { getGroupStatus } from "../../../utils/getGroupStatus";
11
12
  import {
12
13
  buildGroupTree,
13
14
  GROUPBY_MEMO_KEY,
@@ -21,7 +22,8 @@ export namespace MessagePrimitiveGroupedParts {
21
22
  * A coalesced group of adjacent parts. Surfaced through the same
22
23
  * `{ part }` channel as a leaf {@link EnrichedPartState} so consumers
23
24
  * dispatch on a single `switch (part.type)`. `type` is the group key
24
- * (always `"group-…"`); `status` mirrors the last contained part.
25
+ * (always `"group-…"`); `status` is running when any contained part runs,
26
+ * otherwise it mirrors the last contained part.
25
27
  */
26
28
  export type GroupPart<TKey extends `group-${string}` = `group-${string}`> = {
27
29
  readonly type: TKey;
@@ -133,8 +135,6 @@ export namespace MessagePrimitiveGroupedParts {
133
135
  };
134
136
  }
135
137
 
136
- const COMPLETE_STATUS: MessagePartStatus = Object.freeze({ type: "complete" });
137
-
138
138
  const shouldShowIndicator = (
139
139
  mode: MessagePrimitiveGroupedParts.IndicatorMode,
140
140
  parts: readonly PartState[],
@@ -194,7 +194,7 @@ const renderNode = <TKey extends `group-${string}`>(
194
194
  );
195
195
  }
196
196
 
197
- const status = parts[node.indices.at(-1)!]?.status ?? COMPLETE_STATUS;
197
+ const status = getGroupStatus(parts, node.indices);
198
198
  const groupPart: MessagePrimitiveGroupedParts.GroupPart<TKey> = {
199
199
  type: node.key as TKey,
200
200
  status,
@@ -214,7 +214,12 @@ export namespace MessagePrimitiveParts {
214
214
  Image?: ImageMessagePartComponent | undefined;
215
215
  /** Component for rendering file content */
216
216
  File?: FileMessagePartComponent | undefined;
217
- /** Component for rendering audio content (experimental) */
217
+ /**
218
+ * Component for rendering audio content.
219
+ *
220
+ * @deprecated Render audio through the `File` slot instead, branching on an
221
+ * `audio/*` mime type.
222
+ */
218
223
  Unstable_Audio?: Unstable_AudioMessagePartComponent | undefined;
219
224
  /** Configuration for data part rendering */
220
225
  data?: DataConfig | undefined;
@@ -26,6 +26,7 @@ const READONLY_THREAD_LIST_ITEM: ThreadListItemState = Object.freeze({
26
26
  remoteId: undefined,
27
27
  externalId: undefined,
28
28
  isMain: true,
29
+ isRunning: false,
29
30
  status: "regular" as const,
30
31
  title: undefined,
31
32
  });
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { ThreadListRuntimeCore } from "../../runtime/interfaces/thread-list-runtime-core";
3
+ import type { ThreadRuntimeCore } from "../../runtime/interfaces/thread-runtime-core";
4
+ import type { ThreadMessage } from "../../types/message";
5
+ import { RemoteThreadListHookInstanceManager } from "./RemoteThreadListHookInstanceManager";
6
+
7
+ const makeRuntime = (
8
+ initial: Partial<Pick<ThreadRuntimeCore, "isRunning" | "messages">> = {},
9
+ ) => {
10
+ const subscribers = new Set<() => void>();
11
+ const runtime = {
12
+ isRunning: initial.isRunning,
13
+ messages: initial.messages ?? [],
14
+ subscribe: (callback: () => void) => {
15
+ subscribers.add(callback);
16
+ return () => subscribers.delete(callback);
17
+ },
18
+ } as unknown as ThreadRuntimeCore & {
19
+ isRunning: boolean | undefined;
20
+ messages: readonly ThreadMessage[];
21
+ };
22
+
23
+ return {
24
+ runtime,
25
+ subscriberCount: () => subscribers.size,
26
+ setRunning: (isRunning: boolean | undefined) => {
27
+ runtime.isRunning = isRunning;
28
+ for (const callback of subscribers) callback();
29
+ },
30
+ };
31
+ };
32
+
33
+ const makeManager = () =>
34
+ new RemoteThreadListHookInstanceManager(
35
+ () => ({}) as never,
36
+ {} as ThreadListRuntimeCore,
37
+ );
38
+
39
+ // mirrors what the React binder does on every publication
40
+ const publish = (
41
+ manager: RemoteThreadListHookInstanceManager,
42
+ threadId: string,
43
+ runtime: ThreadRuntimeCore,
44
+ ) => {
45
+ const internals = manager as unknown as {
46
+ instances: Map<string, { generation: number }>;
47
+ _publishThreadRuntime: (
48
+ threadId: string,
49
+ runtime: ThreadRuntimeCore,
50
+ generation: number,
51
+ ) => void;
52
+ };
53
+ const generation = internals.instances.get(threadId)!.generation;
54
+ internals._publishThreadRuntime(threadId, runtime, generation);
55
+ };
56
+
57
+ const start = (manager: RemoteThreadListHookInstanceManager, id: string) => {
58
+ manager.startThreadRuntime(id).catch(() => {});
59
+ };
60
+
61
+ describe("RemoteThreadListHookInstanceManager run tracking", () => {
62
+ it("reports a thread with no attached runtime as not running", () => {
63
+ const manager = makeManager();
64
+ start(manager, "thread-1");
65
+
66
+ expect(manager.__internal_isThreadRunning("thread-1")).toBe(false);
67
+ expect(manager.__internal_isThreadRunning("never-started")).toBe(false);
68
+ });
69
+
70
+ it("adopts the run state of the published runtime", () => {
71
+ const manager = makeManager();
72
+ start(manager, "thread-1");
73
+ const { runtime } = makeRuntime({ isRunning: true });
74
+
75
+ publish(manager, "thread-1", runtime);
76
+
77
+ expect(manager.__internal_isThreadRunning("thread-1")).toBe(true);
78
+ });
79
+
80
+ it("falls back to the trailing assistant message when the runtime does not track runs", () => {
81
+ const manager = makeManager();
82
+ start(manager, "thread-1");
83
+ const { runtime } = makeRuntime({
84
+ messages: [
85
+ { role: "assistant", status: { type: "running" } },
86
+ ] as unknown as readonly ThreadMessage[],
87
+ });
88
+
89
+ publish(manager, "thread-1", runtime);
90
+
91
+ expect(manager.__internal_isThreadRunning("thread-1")).toBe(true);
92
+ });
93
+
94
+ it("tracks a thread the user has switched away from", () => {
95
+ const manager = makeManager();
96
+ start(manager, "background");
97
+ start(manager, "main");
98
+ const background = makeRuntime({ isRunning: false });
99
+ publish(manager, "background", background.runtime);
100
+ publish(manager, "main", makeRuntime({ isRunning: false }).runtime);
101
+
102
+ background.setRunning(true);
103
+
104
+ expect(manager.__internal_isThreadRunning("background")).toBe(true);
105
+ expect(manager.__internal_isThreadRunning("main")).toBe(false);
106
+ });
107
+
108
+ it("notifies the thread list only when the thread crosses the running boundary", () => {
109
+ const manager = makeManager();
110
+ start(manager, "thread-1");
111
+ const thread = makeRuntime({ isRunning: false });
112
+ publish(manager, "thread-1", thread.runtime);
113
+
114
+ const onChange = vi.fn();
115
+ manager.__internal_subscribeRunningChanged(onChange);
116
+
117
+ thread.setRunning(false);
118
+ expect(onChange).not.toHaveBeenCalled();
119
+
120
+ thread.setRunning(true);
121
+ expect(onChange).toHaveBeenCalledTimes(1);
122
+
123
+ thread.setRunning(true);
124
+ expect(onChange).toHaveBeenCalledTimes(1);
125
+
126
+ thread.setRunning(false);
127
+ expect(onChange).toHaveBeenCalledTimes(2);
128
+ });
129
+
130
+ it("moves tracking to the runtime a restart publishes", () => {
131
+ const manager = makeManager();
132
+ start(manager, "thread-1");
133
+ const before = makeRuntime({ isRunning: true });
134
+ publish(manager, "thread-1", before.runtime);
135
+
136
+ const after = makeRuntime({ isRunning: false });
137
+ publish(manager, "thread-1", after.runtime);
138
+
139
+ expect(manager.__internal_isThreadRunning("thread-1")).toBe(false);
140
+ expect(before.subscriberCount()).toBe(0);
141
+
142
+ after.setRunning(true);
143
+ expect(manager.__internal_isThreadRunning("thread-1")).toBe(true);
144
+ });
145
+
146
+ it("releases the run subscription when the thread runtime stops", () => {
147
+ const manager = makeManager();
148
+ start(manager, "thread-1");
149
+ const thread = makeRuntime({ isRunning: true });
150
+ publish(manager, "thread-1", thread.runtime);
151
+
152
+ manager.stopThreadRuntime("thread-1");
153
+
154
+ expect(thread.subscriberCount()).toBe(0);
155
+ expect(manager.__internal_isThreadRunning("thread-1")).toBe(false);
156
+ });
157
+ });
@@ -16,3 +16,163 @@ describe("RemoteThreadListHookInstanceManager", () => {
16
16
  );
17
17
  });
18
18
  });
19
+
20
+ describe("RemoteThreadListHookInstanceManager.__internal_restartThreadRuntime", () => {
21
+ const makeManager = () =>
22
+ new RemoteThreadListHookInstanceManager(
23
+ () => ({}) as never,
24
+ {} as ThreadListRuntimeCore,
25
+ );
26
+
27
+ // no React binder attaches a runtime in these tests, so the returned promises
28
+ // stay pending or reject on stop; neither is what is under test here
29
+ const start = (manager: RemoteThreadListHookInstanceManager, id: string) => {
30
+ manager.startThreadRuntime(id).catch(() => {});
31
+ };
32
+ const restart = (
33
+ manager: RemoteThreadListHookInstanceManager,
34
+ id: string,
35
+ ) => {
36
+ manager.__internal_restartThreadRuntime(id).catch(() => {});
37
+ };
38
+
39
+ type InstanceInternals = {
40
+ instances: Map<
41
+ string,
42
+ {
43
+ runtime?: unknown;
44
+ publishedGeneration?: number;
45
+ generation: number;
46
+ }
47
+ >;
48
+ _notifySubscribers: () => void;
49
+ };
50
+ const internalsOf = (manager: RemoteThreadListHookInstanceManager) =>
51
+ manager as unknown as InstanceInternals;
52
+
53
+ const renderedKeys = (manager: RemoteThreadListHookInstanceManager) =>
54
+ Array.from(internalsOf(manager).instances.entries()).map(
55
+ ([id, { generation }]) => `${id}:${generation}`,
56
+ );
57
+
58
+ it("changes the binder key so React remounts the runtime hook", () => {
59
+ const manager = makeManager();
60
+ start(manager, "thread-1");
61
+ const before = renderedKeys(manager);
62
+
63
+ restart(manager, "thread-1");
64
+
65
+ expect(renderedKeys(manager)).not.toEqual(before);
66
+ expect(renderedKeys(manager)).toEqual(["thread-1:1"]);
67
+ });
68
+
69
+ it("keeps the thread rendered across the restart, unlike stop", () => {
70
+ const restarted = makeManager();
71
+ start(restarted, "thread-1");
72
+ restart(restarted, "thread-1");
73
+
74
+ const stopped = makeManager();
75
+ start(stopped, "thread-1");
76
+ stopped.stopThreadRuntime("thread-1");
77
+
78
+ expect(renderedKeys(restarted)).toHaveLength(1);
79
+ expect(renderedKeys(stopped)).toHaveLength(0);
80
+ });
81
+
82
+ it("starts the runtime when the thread is not alive yet", () => {
83
+ const manager = makeManager();
84
+
85
+ restart(manager, "thread-1");
86
+
87
+ expect(renderedKeys(manager)).toEqual(["thread-1:0"]);
88
+ });
89
+
90
+ it("stop then start in one tick yields a fresh key, so the old still-mounted binder cannot satisfy the new start", () => {
91
+ const manager = makeManager();
92
+ start(manager, "thread-1");
93
+ const before = renderedKeys(manager);
94
+
95
+ manager.stopThreadRuntime("thread-1");
96
+ start(manager, "thread-1");
97
+
98
+ expect(renderedKeys(manager)).not.toEqual(before);
99
+ expect(renderedKeys(manager)).toEqual(["thread-1:1"]);
100
+ });
101
+
102
+ // simulates the binder's updateRuntime: publish a runtime for the
103
+ // instance's current generation (or an explicit stale one)
104
+ const publish = (
105
+ manager: RemoteThreadListHookInstanceManager,
106
+ id: string,
107
+ runtime: unknown,
108
+ options?: { generation?: number },
109
+ ) => {
110
+ const instance = internalsOf(manager).instances.get(id)!;
111
+ instance.runtime = runtime;
112
+ instance.publishedGeneration = options?.generation ?? instance.generation;
113
+ internalsOf(manager)._notifySubscribers();
114
+ };
115
+
116
+ it("does not settle with the pre-restart runtime; only the incoming binder's publication resolves it", async () => {
117
+ const manager = makeManager();
118
+ start(manager, "thread-1");
119
+ publish(manager, "thread-1", { tag: "pre-reload-runtime" });
120
+
121
+ let settledWith = "NOT_SETTLED";
122
+ manager.__internal_restartThreadRuntime("thread-1").then((r) => {
123
+ settledWith = (r as { tag: string }).tag;
124
+ });
125
+
126
+ // the outgoing runtime rides across the restart (stays readable)…
127
+ expect(manager.getThreadRuntimeCore("thread-1")).toEqual({
128
+ tag: "pre-reload-runtime",
129
+ });
130
+ // …but must not count as attached
131
+ await Promise.resolve();
132
+ await Promise.resolve();
133
+ await Promise.resolve();
134
+ expect(settledWith).toBe("NOT_SETTLED");
135
+
136
+ publish(manager, "thread-1", { tag: "post-reload-runtime" });
137
+ await Promise.resolve();
138
+ expect(settledWith).toBe("post-reload-runtime");
139
+ });
140
+
141
+ it("keeps the outgoing runtime readable while the restart promise is pending", () => {
142
+ const manager = makeManager();
143
+ start(manager, "thread-1");
144
+ publish(manager, "thread-1", { tag: "pre-reload-runtime" });
145
+
146
+ restart(manager, "thread-1");
147
+
148
+ expect(manager.getThreadRuntimeCore("thread-1")).toEqual({
149
+ tag: "pre-reload-runtime",
150
+ });
151
+ });
152
+
153
+ it("a stale-generation publication does not resolve the restart promise", async () => {
154
+ const manager = makeManager();
155
+ start(manager, "thread-1");
156
+ publish(manager, "thread-1", { tag: "pre-reload-runtime" });
157
+
158
+ let settled = false;
159
+ manager.__internal_restartThreadRuntime("thread-1").then(() => {
160
+ settled = true;
161
+ });
162
+
163
+ // an outgoing binder re-publishing for its old generation (e.g. a late
164
+ // outerSubscribe callback) must not count as the new attachment
165
+ const staleGeneration =
166
+ internalsOf(manager).instances.get("thread-1")!.generation - 1;
167
+ publish(
168
+ manager,
169
+ "thread-1",
170
+ { tag: "stale-publication" },
171
+ { generation: staleGeneration },
172
+ );
173
+
174
+ await Promise.resolve();
175
+ await Promise.resolve();
176
+ expect(settled).toBe(false);
177
+ });
178
+ });